diff --git a/.github/workflows/erp-ci.yml b/.github/workflows/erp-ci.yml new file mode 100644 index 00000000000..efbf42df8ca --- /dev/null +++ b/.github/workflows/erp-ci.yml @@ -0,0 +1,110 @@ +name: ERP CI + +on: + push: + branches: [main, claude/erp-phase-1-foundations-9miE5] + paths: + - 'erp/**' + pull_request: + branches: [main] + paths: + - 'erp/**' + +jobs: + php-tests: + name: PHP Tests (Laravel/Pest) + runs-on: ubuntu-latest + defaults: + run: + working-directory: erp + + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + extensions: mbstring, pdo, pdo_sqlite, sqlite3, xml, curl, zip + coverage: none + + - name: Cache Composer dependencies + uses: actions/cache@v4 + with: + path: erp/vendor + key: ${{ runner.os }}-composer-${{ hashFiles('erp/composer.lock') }} + restore-keys: ${{ runner.os }}-composer- + + - name: Install PHP dependencies + run: composer install --no-interaction --prefer-dist --optimize-autoloader + + - name: Copy .env + run: cp .env.example .env + + - name: Generate app key + run: php artisan key:generate + + - name: Run migrations + run: php artisan migrate --force + env: + DB_CONNECTION: sqlite + DB_DATABASE: ':memory:' + + - name: Run Pest tests + run: php artisan test --stop-on-failure + env: + DB_CONNECTION: sqlite + DB_DATABASE: ':memory:' + APP_ENV: testing + CACHE_STORE: array + QUEUE_CONNECTION: sync + SESSION_DRIVER: array + MAIL_MAILER: log + BROADCAST_CONNECTION: log + + typescript-check: + name: TypeScript Check + runs-on: ubuntu-latest + defaults: + run: + working-directory: erp + + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: erp/package-lock.json + + - name: Install Node dependencies + run: npm ci + + - name: TypeScript type check + run: npx tsc --noEmit + + lint: + name: Frontend Lint + runs-on: ubuntu-latest + defaults: + run: + working-directory: erp + + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: erp/package-lock.json + + - name: Install Node dependencies + run: npm ci + + - name: Run ESLint + run: npx eslint resources/js --ext .ts,.tsx --max-warnings 0 + continue-on-error: true diff --git a/.gitignore b/.gitignore index 3ae88f84d8b..3c56ff4934e 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,9 @@ yarn-debug.log* yarn-error.log* /.changelog .npm/ +.claude/ +.env.bak +*.env.bak + +# Stray PHP vendor dir +vendor/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000000..5e9f6fc03f9 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,198 @@ +# ERP System — Claude Code Guide + +## Project Overview + +Full-featured multi-tenant ERP built with **Laravel 13 + Inertia.js v2 + React 19 + TypeScript + Tailwind CSS v3**. + +The ERP application lives entirely under `erp/`. The repo root contains a legacy React Create App project — ignore it for ERP work. + +## Quick Start + +```bash +cd erp +composer install +npm install +cp .env.example .env +php artisan key:generate +php artisan migrate --seed +npm run dev +# In another terminal: +php artisan serve +``` + +## Architecture + +### Stack + +- **Backend**: Laravel 13, PHP 8.3, SQLite (dev/test), MySQL (prod) +- **Frontend**: Inertia.js v2, React 19, TypeScript, Tailwind CSS v3 +- **Auth**: Laravel Sanctum (API tokens) + Spatie Roles/Permissions +- **Queue**: Database queue (`QUEUE_CONNECTION=database`) +- **WebSockets**: Laravel Reverb + Laravel Echo +- **PDF**: barryvdh/laravel-dompdf +- **Excel**: maatwebsite/excel v3.1 +- **Testing**: Pest v3 + +### Module Structure (35 modules) + +All modules live under `app/Modules/{Name}/`: + +``` +app/Modules/ +├── Core/ # Tenant model, BelongsToTenant trait +├── Finance/ # Invoices, Bills, Contacts, Chart of Accounts +├── Inventory/ # Products, Warehouses, Stock Movements, Transfers +├── HR/ # Employees, Leave, Payroll +├── CRM/ # Leads, Opportunities, Activities +├── PM/ # Projects, Tasks, Milestones +├── Purchase/ # Purchase Orders, RFQs, Vendors +├── Accounting/ # Journal Entries, General Ledger +├── Manufacturing/ # BOMs, Work Orders, Quality +├── Maintenance/ # Assets, Work Orders +├── Subscriptions/ # Plans, Subscriptions +├── LiveChat/ # Channels, Sessions, Messages +├── Discuss/ # Channels, Messages +├── HelpDesk/ # Tickets, SLAs +├── KnowledgeBase/ # Articles +├── Survey/ # Surveys, Questions, Responses +├── Timesheets/ # Entries +├── Expenses/ # Claims +├── Fleet/ # Vehicles, Trips +├── Recruitment/ # Job Postings, Applications +├── Training/ # Programs, Enrollments +├── Events/ # Events, Registrations +├── Subcontracting/ # Contracts +├── FieldService/ # Work Orders +├── Rental/ # Items, Bookings +├── POS/ # Sessions, Orders +└── ... +``` + +### Multi-Tenancy Pattern + +Every module model uses the `BelongsToTenant` trait (`app/Traits/BelongsToTenant.php`): + +- Global scope auto-filters by `tenant_id` +- Observer auto-sets `tenant_id` on create +- Always call `app()->instance('tenant', $tenant)` in tests to set the active tenant + +### API Structure + +All REST endpoints at `/api/v1/*`. Base controller: `app/Http/Controllers/Api/V1/ApiController.php`. + +- `success($data)` — 200 with `{data: ...}` +- `error($msg, $code)` — error response +- `paginated($paginator)` — paginated response + +Auth: Bearer token via `withToken($token)` in tests. + +### Tenant Detection in Controllers + +```php +$tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; +``` + +### Broadcasting (WebSockets) + +Events in `app/Events/` implement `ShouldBroadcast`. Channels in `routes/channels.php`. + +- Live Chat: `private-chat-session.{id}` → `.NewChatMessage` +- Discuss: `private-discuss-channel.{id}` → `.NewDiscussMessage` +- Notifications: `private-tenant.{id}` → `.ErpNotification` + +Frontend hook: `useEchoPrivateChannel(channelName, event, handler)` in `resources/js/Hooks/useEchoChannel.ts`. + +### Key Conventions + +- Migrations always start with `Schema::dropIfExists('table')` before `Schema::create` +- Event auto-discovery: Laravel 13 discovers listeners automatically — no manual EventServiceProvider needed +- Use `broadcast(new Event())->toOthers()` to exclude the sender from WebSocket events +- Rate limiting: 60 req/min on `/api/v1/*`, 10 req/min on auth endpoints +- Audit logging: `LogsActivity` trait auto-logs created/updated/deleted on key models +- Security headers: `SecurityHeaders` middleware appended globally + +## Testing + +```bash +cd erp +php artisan test # Run all tests +php artisan test --filter "FinanceTest" # Filter by name +php artisan test tests/Feature/Finance/ # Run a directory +``` + +All tests use SQLite in-memory (`DB_CONNECTION=sqlite DB_DATABASE=:memory:`). `tests/Pest.php` applies `RefreshDatabase` globally. + +Test pattern: + +```php +beforeEach(function () { + $this->seed(RolePermissionSeeder::class); + $this->tenant = Tenant::create(['name' => 'Test Co', 'slug' => 'test-co']); + $this->user = User::factory()->create(['tenant_id' => $this->tenant->id]); + $this->user->assignRole('super-admin'); + $this->token = $this->user->createToken('test')->plainTextToken; + app()->instance('tenant', $this->tenant); +}); +``` + +## Development Phases Completed + +| Phase | Description | Status | +| ----- | ----------------------------------------------------------------- | ------ | +| 1–8 | Core modules, models, migrations, seeders, Inertia pages | ✅ | +| 9 | REST API — 200+ endpoints across 40 modules | ✅ | +| 10 | Demo data seeders for all 35 modules | ✅ | +| 11 | WebSockets — Laravel Reverb + Echo | ✅ | +| 12 | Queue jobs — invoice, low stock, payroll, bulk import | ✅ | +| 13 | Mail notifications — invoice, low stock, payroll, approval | ✅ | +| 14 | PDF generation — invoices, purchase orders, payslips | ✅ | +| 15 | Import/Export — CSV/XLSX for products, contacts, invoices | ✅ | +| 16 | Dashboard analytics — module stats + activity feed | ✅ | +| 17 | Tenant isolation tests — 22 cross-tenant security tests | ✅ | +| 18 | API rate limiting (60/min) + security headers | ✅ | +| 19 | Global search — 7 modules, frontend component | ✅ | +| 20 | Audit log — migration, trait, observer, API endpoint | ✅ | +| 21 | GitHub Actions CI/CD — PHP tests + TS check + ESLint | ✅ | +| 22 | Reports API — financial/inventory/HR + CLAUDE.md | ✅ | +| 23 | In-app notifications — DB model, API, frontend bell | ✅ | +| 24 | Scheduled Report Delivery — ReportSchedule model + job + mail | ✅ | +| 25 | Health Checks & Metrics — /api/v1/health + /api/v1/metrics | ✅ | +| 26 | Dashboard Widgets — per-user customizable widget layout | ✅ | +| 27 | Email Template Management — CRUD + variable preview | ✅ | +| 28 | Tenant Feature Flags — per-tenant feature toggle system | ✅ | +| 29 | User Preferences — timezone, locale, UI density, etc. | ✅ | +| 30 | Activity Feed API — filterable event stream from audit logs | ✅ | +| 31 | Unified Calendar API — tasks, leaves, events, invoices | ✅ | +| 32 | Financial Forecasting — revenue + cash-flow projections | ✅ | +| 33 | Smart Alert Rules — threshold monitoring + notifications | ✅ | +| 34 | Budget Management REST API — CRUD + activate + variance | ✅ | +| 35 | Customer Credit Limits — per-contact limits, hold, check API | ✅ | +| 36 | Product Variants REST API — attributes, variants, matrix view | ✅ | +| 37 | Webhook Management REST API — CRUD, delivery log, ping, HMAC | ✅ | +| 38 | API Token Management — named tokens with abilities and expiry | ✅ | +| 39 | Inventory Reorder Suggestions — deficit calc + urgency levels | ✅ | +| 40 | HR Leave Balance API — allocation, team view, year filters | ✅ | +| 41 | CRM Pipeline Analytics — funnel, win rate, velocity, leaderboard | ✅ | +| 42 | Project Time Tracking API — log hours, project summaries by user | ✅ | +| 43 | Expense Claim Workflow API — submit, approve, reject, paid states | ✅ | + +## File Locations Reference + +| Concern | Path | +| --------------------- | --------------------------------------- | +| Module models | `app/Modules/{Name}/Models/` | +| API controllers | `app/Http/Controllers/Api/V1/` | +| Inertia pages | `resources/js/Pages/` | +| Shared components | `resources/js/Components/` | +| Layouts | `resources/js/Layouts/AppLayout.tsx` | +| Routes (web) | `routes/web.php` | +| Routes (api) | `routes/api.php` | +| Broadcasting channels | `routes/channels.php` | +| Migrations | `database/migrations/` | +| Seeders | `database/seeders/` | +| Jobs | `app/Jobs/` | +| Mail | `app/Mail/` + `resources/views/emails/` | +| Events | `app/Events/` | +| Traits | `app/Traits/` | +| Services | `app/Services/` | +| Tests | `tests/Feature/` | diff --git a/composer.json b/composer.json new file mode 100644 index 00000000000..e833bbdec12 --- /dev/null +++ b/composer.json @@ -0,0 +1,6 @@ +{ + "require": { + "barryvdh/laravel-dompdf": "^3.1", + "maatwebsite/excel": "^3.1" + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 00000000000..bc1da9549fb --- /dev/null +++ b/composer.lock @@ -0,0 +1,2526 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "dcf2018bdbd2f3b4d1f3248a2be5406a", + "packages": [ + { + "name": "barryvdh/laravel-dompdf", + "version": "v3.1.2", + "source": { + "type": "git", + "url": "https://github.com/barryvdh/laravel-dompdf.git", + "reference": "ee3b72b19ccdf57d0243116ecb2b90261344dedc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/barryvdh/laravel-dompdf/zipball/ee3b72b19ccdf57d0243116ecb2b90261344dedc", + "reference": "ee3b72b19ccdf57d0243116ecb2b90261344dedc", + "shasum": "" + }, + "require": { + "dompdf/dompdf": "^3.0", + "illuminate/support": "^9|^10|^11|^12|^13.0", + "php": "^8.1" + }, + "require-dev": { + "larastan/larastan": "^2.7|^3.0", + "orchestra/testbench": "^7|^8|^9.16|^10|^11.0", + "phpro/grumphp": "^2.5", + "squizlabs/php_codesniffer": "^3.5" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "PDF": "Barryvdh\\DomPDF\\Facade\\Pdf", + "Pdf": "Barryvdh\\DomPDF\\Facade\\Pdf" + }, + "providers": [ + "Barryvdh\\DomPDF\\ServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "psr-4": { + "Barryvdh\\DomPDF\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Barry vd. Heuvel", + "email": "barryvdh@gmail.com" + } + ], + "description": "A DOMPDF Wrapper for Laravel", + "keywords": [ + "dompdf", + "laravel", + "pdf" + ], + "support": { + "issues": "https://github.com/barryvdh/laravel-dompdf/issues", + "source": "https://github.com/barryvdh/laravel-dompdf/tree/v3.1.2" + }, + "funding": [ + { + "url": "https://fruitcake.nl", + "type": "custom" + }, + { + "url": "https://github.com/barryvdh", + "type": "github" + } + ], + "time": "2026-02-21T08:51:10+00:00" + }, + { + "name": "carbonphp/carbon-doctrine-types", + "version": "3.2.0", + "source": { + "type": "git", + "url": "https://github.com/CarbonPHP/carbon-doctrine-types.git", + "reference": "18ba5ddfec8976260ead6e866180bd5d2f71aa1d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/CarbonPHP/carbon-doctrine-types/zipball/18ba5ddfec8976260ead6e866180bd5d2f71aa1d", + "reference": "18ba5ddfec8976260ead6e866180bd5d2f71aa1d", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "conflict": { + "doctrine/dbal": "<4.0.0 || >=5.0.0" + }, + "require-dev": { + "doctrine/dbal": "^4.0.0", + "nesbot/carbon": "^2.71.0 || ^3.0.0", + "phpunit/phpunit": "^10.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Carbon\\Doctrine\\": "src/Carbon/Doctrine/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "KyleKatarn", + "email": "kylekatarnls@gmail.com" + } + ], + "description": "Types to use Carbon in Doctrine", + "keywords": [ + "carbon", + "date", + "datetime", + "doctrine", + "time" + ], + "support": { + "issues": "https://github.com/CarbonPHP/carbon-doctrine-types/issues", + "source": "https://github.com/CarbonPHP/carbon-doctrine-types/tree/3.2.0" + }, + "funding": [ + { + "url": "https://github.com/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon", + "type": "open_collective" + }, + { + "url": "https://tidelift.com/funding/github/packagist/nesbot/carbon", + "type": "tidelift" + } + ], + "time": "2024-02-09T16:56:22+00:00" + }, + { + "name": "composer/pcre", + "version": "3.4.0", + "source": { + "type": "git", + "url": "https://github.com/composer/pcre.git", + "reference": "d5a341b3fb61f3001970940afb1d332968a183ed" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/pcre/zipball/d5a341b3fb61f3001970940afb1d332968a183ed", + "reference": "d5a341b3fb61f3001970940afb1d332968a183ed", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "conflict": { + "phpstan/phpstan": "<2.2.2" + }, + "require-dev": { + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^9" + }, + "type": "library", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Pcre\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "PCRE wrapping library that offers type-safe preg_* replacements.", + "keywords": [ + "PCRE", + "preg", + "regex", + "regular expression" + ], + "support": { + "issues": "https://github.com/composer/pcre/issues", + "source": "https://github.com/composer/pcre/tree/3.4.0" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2026-06-07T11:47:49+00:00" + }, + { + "name": "composer/semver", + "version": "3.4.4", + "source": { + "type": "git", + "url": "https://github.com/composer/semver.git", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.11", + "symfony/phpunit-bridge": "^3 || ^7" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Semver\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" + } + ], + "description": "Semver library that offers utilities, version constraint parsing and validation.", + "keywords": [ + "semantic", + "semver", + "validation", + "versioning" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/semver/issues", + "source": "https://github.com/composer/semver/tree/3.4.4" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2025-08-20T19:15:30+00:00" + }, + { + "name": "doctrine/inflector", + "version": "2.1.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/inflector.git", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/inflector/zipball/6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "doctrine/coding-standard": "^12.0 || ^13.0", + "phpstan/phpstan": "^1.12 || ^2.0", + "phpstan/phpstan-phpunit": "^1.4 || ^2.0", + "phpstan/phpstan-strict-rules": "^1.6 || ^2.0", + "phpunit/phpunit": "^8.5 || ^12.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Inflector\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Inflector is a small library that can perform string manipulations with regard to upper/lowercase and singular/plural forms of words.", + "homepage": "https://www.doctrine-project.org/projects/inflector.html", + "keywords": [ + "inflection", + "inflector", + "lowercase", + "manipulation", + "php", + "plural", + "singular", + "strings", + "uppercase", + "words" + ], + "support": { + "issues": "https://github.com/doctrine/inflector/issues", + "source": "https://github.com/doctrine/inflector/tree/2.1.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finflector", + "type": "tidelift" + } + ], + "time": "2025-08-10T19:31:58+00:00" + }, + { + "name": "dompdf/dompdf", + "version": "v3.1.5", + "source": { + "type": "git", + "url": "https://github.com/dompdf/dompdf.git", + "reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/dompdf/zipball/f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496", + "reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496", + "shasum": "" + }, + "require": { + "dompdf/php-font-lib": "^1.0.0", + "dompdf/php-svg-lib": "^1.0.0", + "ext-dom": "*", + "ext-mbstring": "*", + "masterminds/html5": "^2.0", + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "ext-gd": "*", + "ext-json": "*", + "ext-zip": "*", + "mockery/mockery": "^1.3", + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11", + "squizlabs/php_codesniffer": "^3.5", + "symfony/process": "^4.4 || ^5.4 || ^6.2 || ^7.0" + }, + "suggest": { + "ext-gd": "Needed to process images", + "ext-gmagick": "Improves image processing performance", + "ext-imagick": "Improves image processing performance", + "ext-zlib": "Needed for pdf stream compression" + }, + "type": "library", + "autoload": { + "psr-4": { + "Dompdf\\": "src/" + }, + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1" + ], + "authors": [ + { + "name": "The Dompdf Community", + "homepage": "https://github.com/dompdf/dompdf/blob/master/AUTHORS.md" + } + ], + "description": "DOMPDF is a CSS 2.1 compliant HTML to PDF converter", + "homepage": "https://github.com/dompdf/dompdf", + "support": { + "issues": "https://github.com/dompdf/dompdf/issues", + "source": "https://github.com/dompdf/dompdf/tree/v3.1.5" + }, + "time": "2026-03-03T13:54:37+00:00" + }, + { + "name": "dompdf/php-font-lib", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/dompdf/php-font-lib.git", + "reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/a6e9a688a2a80016ac080b97be73d3e10c444c9a", + "reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11 || ^12" + }, + "type": "library", + "autoload": { + "psr-4": { + "FontLib\\": "src/FontLib" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-or-later" + ], + "authors": [ + { + "name": "The FontLib Community", + "homepage": "https://github.com/dompdf/php-font-lib/blob/master/AUTHORS.md" + } + ], + "description": "A library to read, parse, export and make subsets of different types of font files.", + "homepage": "https://github.com/dompdf/php-font-lib", + "support": { + "issues": "https://github.com/dompdf/php-font-lib/issues", + "source": "https://github.com/dompdf/php-font-lib/tree/1.0.2" + }, + "time": "2026-01-20T14:10:26+00:00" + }, + { + "name": "dompdf/php-svg-lib", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/dompdf/php-svg-lib.git", + "reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/8259ffb930817e72b1ff1caef5d226501f3dfeb1", + "reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^7.1 || ^8.0", + "sabberworm/php-css-parser": "^8.4 || ^9.0" + }, + "require-dev": { + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11" + }, + "type": "library", + "autoload": { + "psr-4": { + "Svg\\": "src/Svg" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "The SvgLib Community", + "homepage": "https://github.com/dompdf/php-svg-lib/blob/master/AUTHORS.md" + } + ], + "description": "A library to read, parse and export to PDF SVG files.", + "homepage": "https://github.com/dompdf/php-svg-lib", + "support": { + "issues": "https://github.com/dompdf/php-svg-lib/issues", + "source": "https://github.com/dompdf/php-svg-lib/tree/1.0.2" + }, + "time": "2026-01-02T16:01:13+00:00" + }, + { + "name": "ezyang/htmlpurifier", + "version": "v4.19.0", + "source": { + "type": "git", + "url": "https://github.com/ezyang/htmlpurifier.git", + "reference": "b287d2a16aceffbf6e0295559b39662612b77fcf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ezyang/htmlpurifier/zipball/b287d2a16aceffbf6e0295559b39662612b77fcf", + "reference": "b287d2a16aceffbf6e0295559b39662612b77fcf", + "shasum": "" + }, + "require": { + "php": "~5.6.0 || ~7.0.0 || ~7.1.0 || ~7.2.0 || ~7.3.0 || ~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" + }, + "require-dev": { + "cerdic/css-tidy": "^1.7 || ^2.0", + "simpletest/simpletest": "dev-master" + }, + "suggest": { + "cerdic/css-tidy": "If you want to use the filter 'Filter.ExtractStyleBlocks'.", + "ext-bcmath": "Used for unit conversion and imagecrash protection", + "ext-iconv": "Converts text to and from non-UTF-8 encodings", + "ext-tidy": "Used for pretty-printing HTML" + }, + "type": "library", + "autoload": { + "files": [ + "library/HTMLPurifier.composer.php" + ], + "psr-0": { + "HTMLPurifier": "library/" + }, + "exclude-from-classmap": [ + "/library/HTMLPurifier/Language/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-or-later" + ], + "authors": [ + { + "name": "Edward Z. Yang", + "email": "admin@htmlpurifier.org", + "homepage": "http://ezyang.com" + } + ], + "description": "Standards compliant HTML filter written in PHP", + "homepage": "http://htmlpurifier.org/", + "keywords": [ + "html" + ], + "support": { + "issues": "https://github.com/ezyang/htmlpurifier/issues", + "source": "https://github.com/ezyang/htmlpurifier/tree/v4.19.0" + }, + "time": "2025-10-17T16:34:55+00:00" + }, + { + "name": "illuminate/collections", + "version": "v13.16.1", + "source": { + "type": "git", + "url": "https://github.com/illuminate/collections.git", + "reference": "1dbd2611b2cd025d3526e64bd7670033ff883173" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/collections/zipball/1dbd2611b2cd025d3526e64bd7670033ff883173", + "reference": "1dbd2611b2cd025d3526e64bd7670033ff883173", + "shasum": "" + }, + "require": { + "illuminate/conditionable": "^13.0", + "illuminate/contracts": "^13.0", + "illuminate/macroable": "^13.0", + "php": "^8.3", + "symfony/polyfill-php84": "^1.36", + "symfony/polyfill-php85": "^1.36", + "symfony/polyfill-php86": "^1.36" + }, + "suggest": { + "illuminate/http": "Required to convert collections to API resources (^13.0).", + "symfony/var-dumper": "Required to use the dump method (^7.4 || ^8.0)." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "13.0.x-dev" + } + }, + "autoload": { + "files": [ + "functions.php", + "helpers.php" + ], + "psr-4": { + "Illuminate\\Support\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Collections package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2026-05-19T14:10:53+00:00" + }, + { + "name": "illuminate/conditionable", + "version": "v13.16.1", + "source": { + "type": "git", + "url": "https://github.com/illuminate/conditionable.git", + "reference": "7f1ef52d9a346f829421b296adfb7644a951b216" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/conditionable/zipball/7f1ef52d9a346f829421b296adfb7644a951b216", + "reference": "7f1ef52d9a346f829421b296adfb7644a951b216", + "shasum": "" + }, + "require": { + "php": "^8.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "13.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Illuminate\\Support\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Conditionable package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2026-02-25T16:07:55+00:00" + }, + { + "name": "illuminate/contracts", + "version": "v13.16.1", + "source": { + "type": "git", + "url": "https://github.com/illuminate/contracts.git", + "reference": "874c81d9fe7b0458d19add1fbefd7c880b847123" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/contracts/zipball/874c81d9fe7b0458d19add1fbefd7c880b847123", + "reference": "874c81d9fe7b0458d19add1fbefd7c880b847123", + "shasum": "" + }, + "require": { + "php": "^8.3", + "psr/container": "^1.1.1 || ^2.0.1", + "psr/simple-cache": "^1.0 || ^2.0 || ^3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "13.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Illuminate\\Contracts\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Contracts package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2026-06-15T15:36:54+00:00" + }, + { + "name": "illuminate/macroable", + "version": "v13.16.1", + "source": { + "type": "git", + "url": "https://github.com/illuminate/macroable.git", + "reference": "59b5b5f3cf290a91db8cf6cd3d35ff56978bc057" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/macroable/zipball/59b5b5f3cf290a91db8cf6cd3d35ff56978bc057", + "reference": "59b5b5f3cf290a91db8cf6cd3d35ff56978bc057", + "shasum": "" + }, + "require": { + "php": "^8.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "13.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Illuminate\\Support\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Macroable package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2026-04-29T09:35:06+00:00" + }, + { + "name": "illuminate/reflection", + "version": "v13.16.1", + "source": { + "type": "git", + "url": "https://github.com/illuminate/reflection.git", + "reference": "4fe1659f068ab2b50131cf906c5d8bba4e34df0c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/reflection/zipball/4fe1659f068ab2b50131cf906c5d8bba4e34df0c", + "reference": "4fe1659f068ab2b50131cf906c5d8bba4e34df0c", + "shasum": "" + }, + "require": { + "illuminate/collections": "^13.0", + "illuminate/contracts": "^13.0", + "php": "^8.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "13.0.x-dev" + } + }, + "autoload": { + "files": [ + "helpers.php" + ], + "psr-4": { + "Illuminate\\Support\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Reflection package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2026-03-10T20:04:12+00:00" + }, + { + "name": "illuminate/support", + "version": "v13.16.1", + "source": { + "type": "git", + "url": "https://github.com/illuminate/support.git", + "reference": "a9b6430d36595b9f99b17869a81dfe28179379aa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/support/zipball/a9b6430d36595b9f99b17869a81dfe28179379aa", + "reference": "a9b6430d36595b9f99b17869a81dfe28179379aa", + "shasum": "" + }, + "require": { + "doctrine/inflector": "^2.0", + "ext-ctype": "*", + "ext-filter": "*", + "ext-mbstring": "*", + "illuminate/collections": "^13.0", + "illuminate/conditionable": "^13.0", + "illuminate/contracts": "^13.0", + "illuminate/macroable": "^13.0", + "illuminate/reflection": "^13.0", + "nesbot/carbon": "^3.8.4", + "php": "^8.3", + "symfony/polyfill-php85": "^1.36", + "voku/portable-ascii": "^2.0.2" + }, + "conflict": { + "tightenco/collect": "<5.5.33" + }, + "replace": { + "spatie/once": "*" + }, + "suggest": { + "illuminate/filesystem": "Required to use the Composer class (^13.0).", + "laravel/serializable-closure": "Required to use the once function (^2.0.10).", + "league/commonmark": "Required to use Str::markdown() and Stringable::markdown() (^2.7).", + "league/uri": "Required to use the Uri class (^7.5.1).", + "ramsey/uuid": "Required to use Str::uuid() (^4.7).", + "symfony/process": "Required to use the Composer class (^7.4 || ^8.0).", + "symfony/uid": "Required to use Str::ulid() (^7.4 || ^8.0).", + "symfony/var-dumper": "Required to use the dd function (^7.4 || ^8.0).", + "vlucas/phpdotenv": "Required to use the Env class and env helper (^5.6.1)." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "13.0.x-dev" + } + }, + "autoload": { + "files": [ + "functions.php", + "helpers.php" + ], + "psr-4": { + "Illuminate\\Support\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Support package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2026-06-16T15:02:05+00:00" + }, + { + "name": "maatwebsite/excel", + "version": "3.1.69", + "source": { + "type": "git", + "url": "https://github.com/SpartnerNL/Laravel-Excel.git", + "reference": "ae5d65b7c9a2fac43bff4d44f796ac95d7a8e760" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/SpartnerNL/Laravel-Excel/zipball/ae5d65b7c9a2fac43bff4d44f796ac95d7a8e760", + "reference": "ae5d65b7c9a2fac43bff4d44f796ac95d7a8e760", + "shasum": "" + }, + "require": { + "composer/semver": "^3.3", + "ext-json": "*", + "illuminate/support": "5.8.*||^6.0||^7.0||^8.0||^9.0||^10.0||^11.0||^12.0||^13.0", + "php": "^7.0||^8.0", + "phpoffice/phpspreadsheet": "^1.30.4", + "psr/simple-cache": "^1.0||^2.0||^3.0" + }, + "require-dev": { + "laravel/scout": "^7.0||^8.0||^9.0||^10.0||^11.0", + "orchestra/testbench": "^6.0||^7.0||^8.0||^9.0||^10.0||^11.0", + "predis/predis": "^1.1" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Excel": "Maatwebsite\\Excel\\Facades\\Excel" + }, + "providers": [ + "Maatwebsite\\Excel\\ExcelServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Maatwebsite\\Excel\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Patrick Brouwers", + "email": "patrick@spartner.nl" + } + ], + "description": "Supercharged Excel exports and imports in Laravel", + "keywords": [ + "PHPExcel", + "batch", + "csv", + "excel", + "export", + "import", + "laravel", + "php", + "phpspreadsheet" + ], + "support": { + "issues": "https://github.com/SpartnerNL/Laravel-Excel/issues", + "source": "https://github.com/SpartnerNL/Laravel-Excel/tree/3.1.69" + }, + "funding": [ + { + "url": "https://laravel-excel.com/commercial-support", + "type": "custom" + }, + { + "url": "https://github.com/patrickbrouwers", + "type": "github" + } + ], + "time": "2026-04-30T20:03:58+00:00" + }, + { + "name": "maennchen/zipstream-php", + "version": "3.2.2", + "source": { + "type": "git", + "url": "https://github.com/maennchen/ZipStream-PHP.git", + "reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e", + "reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "ext-zlib": "*", + "php-64bit": "^8.3" + }, + "require-dev": { + "brianium/paratest": "^7.7", + "ext-zip": "*", + "friendsofphp/php-cs-fixer": "^3.86", + "guzzlehttp/guzzle": "^7.5", + "mikey179/vfsstream": "^1.6", + "php-coveralls/php-coveralls": "^2.5", + "phpunit/phpunit": "^12.0", + "vimeo/psalm": "^6.0" + }, + "suggest": { + "guzzlehttp/psr7": "^2.4", + "psr/http-message": "^2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "ZipStream\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paul Duncan", + "email": "pabs@pablotron.org" + }, + { + "name": "Jonatan Männchen", + "email": "jonatan@maennchen.ch" + }, + { + "name": "Jesse Donat", + "email": "donatj@gmail.com" + }, + { + "name": "András Kolesár", + "email": "kolesar@kolesar.hu" + } + ], + "description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.", + "keywords": [ + "stream", + "zip" + ], + "support": { + "issues": "https://github.com/maennchen/ZipStream-PHP/issues", + "source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.2" + }, + "funding": [ + { + "url": "https://github.com/maennchen", + "type": "github" + } + ], + "time": "2026-04-11T18:38:28+00:00" + }, + { + "name": "markbaker/complex", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/MarkBaker/PHPComplex.git", + "reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/MarkBaker/PHPComplex/zipball/95c56caa1cf5c766ad6d65b6344b807c1e8405b9", + "reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "dev-master", + "phpcompatibility/php-compatibility": "^9.3", + "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0", + "squizlabs/php_codesniffer": "^3.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "Complex\\": "classes/src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mark Baker", + "email": "mark@lange.demon.co.uk" + } + ], + "description": "PHP Class for working with complex numbers", + "homepage": "https://github.com/MarkBaker/PHPComplex", + "keywords": [ + "complex", + "mathematics" + ], + "support": { + "issues": "https://github.com/MarkBaker/PHPComplex/issues", + "source": "https://github.com/MarkBaker/PHPComplex/tree/3.0.2" + }, + "time": "2022-12-06T16:21:08+00:00" + }, + { + "name": "markbaker/matrix", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/MarkBaker/PHPMatrix.git", + "reference": "728434227fe21be27ff6d86621a1b13107a2562c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/MarkBaker/PHPMatrix/zipball/728434227fe21be27ff6d86621a1b13107a2562c", + "reference": "728434227fe21be27ff6d86621a1b13107a2562c", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "dev-master", + "phpcompatibility/php-compatibility": "^9.3", + "phpdocumentor/phpdocumentor": "2.*", + "phploc/phploc": "^4.0", + "phpmd/phpmd": "2.*", + "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0", + "sebastian/phpcpd": "^4.0", + "squizlabs/php_codesniffer": "^3.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "Matrix\\": "classes/src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mark Baker", + "email": "mark@demon-angel.eu" + } + ], + "description": "PHP Class for working with matrices", + "homepage": "https://github.com/MarkBaker/PHPMatrix", + "keywords": [ + "mathematics", + "matrix", + "vector" + ], + "support": { + "issues": "https://github.com/MarkBaker/PHPMatrix/issues", + "source": "https://github.com/MarkBaker/PHPMatrix/tree/3.0.1" + }, + "time": "2022-12-02T22:17:43+00:00" + }, + { + "name": "masterminds/html5", + "version": "2.10.0", + "source": { + "type": "git", + "url": "https://github.com/Masterminds/html5-php.git", + "reference": "fcf91eb64359852f00d921887b219479b4f21251" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fcf91eb64359852f00d921887b219479b4f21251", + "reference": "fcf91eb64359852f00d921887b219479b4f21251", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Masterminds\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Matt Butcher", + "email": "technosophos@gmail.com" + }, + { + "name": "Matt Farina", + "email": "matt@mattfarina.com" + }, + { + "name": "Asmir Mustafic", + "email": "goetas@gmail.com" + } + ], + "description": "An HTML5 parser and serializer.", + "homepage": "http://masterminds.github.io/html5-php", + "keywords": [ + "HTML5", + "dom", + "html", + "parser", + "querypath", + "serializer", + "xml" + ], + "support": { + "issues": "https://github.com/Masterminds/html5-php/issues", + "source": "https://github.com/Masterminds/html5-php/tree/2.10.0" + }, + "time": "2025-07-25T09:04:22+00:00" + }, + { + "name": "nesbot/carbon", + "version": "3.13.0", + "source": { + "type": "git", + "url": "https://github.com/CarbonPHP/carbon.git", + "reference": "40f6618f052df16b545f626fbf9a878e6497d16a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/40f6618f052df16b545f626fbf9a878e6497d16a", + "reference": "40f6618f052df16b545f626fbf9a878e6497d16a", + "shasum": "" + }, + "require": { + "carbonphp/carbon-doctrine-types": "<100.0", + "ext-json": "*", + "php": "^8.1", + "psr/clock": "^1.0", + "symfony/clock": "^6.3.12 || ^7.0 || ^8.0", + "symfony/polyfill-mbstring": "^1.0", + "symfony/translation": "^4.4.18 || ^5.2.1 || ^6.0 || ^7.0 || ^8.0" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "require-dev": { + "doctrine/dbal": "^3.6.3 || ^4.0", + "doctrine/orm": "^2.15.2 || ^3.0", + "friendsofphp/php-cs-fixer": "^v3.87.1", + "kylekatarnls/multi-tester": "^2.5.3", + "phpmd/phpmd": "^2.15.0", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.22", + "phpunit/phpunit": "^10.5.53", + "squizlabs/php_codesniffer": "^3.13.4 || ^4.0.0" + }, + "bin": [ + "bin/carbon" + ], + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Carbon\\Laravel\\ServiceProvider" + ] + }, + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-2.x": "2.x-dev", + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Carbon\\": "src/Carbon/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Brian Nesbitt", + "email": "brian@nesbot.com", + "homepage": "https://markido.com" + }, + { + "name": "kylekatarnls", + "homepage": "https://github.com/kylekatarnls" + } + ], + "description": "An API extension for DateTime that supports 281 different languages.", + "homepage": "https://carbonphp.github.io/carbon/", + "keywords": [ + "date", + "datetime", + "time" + ], + "support": { + "docs": "https://carbonphp.github.io/carbon/guide/getting-started/introduction.html", + "issues": "https://github.com/CarbonPHP/carbon/issues", + "source": "https://github.com/CarbonPHP/carbon" + }, + "funding": [ + { + "url": "https://github.com/sponsors/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon#sponsor", + "type": "opencollective" + }, + { + "url": "https://tidelift.com/subscription/pkg/packagist-nesbot-carbon?utm_source=packagist-nesbot-carbon&utm_medium=referral&utm_campaign=readme", + "type": "tidelift" + } + ], + "time": "2026-06-18T13:49:15+00:00" + }, + { + "name": "phpoffice/phpspreadsheet", + "version": "1.30.5", + "source": { + "type": "git", + "url": "https://github.com/PHPOffice/PhpSpreadsheet.git", + "reference": "97bcabd32a64924688487dcd64aceaf158affb5c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/97bcabd32a64924688487dcd64aceaf158affb5c", + "reference": "97bcabd32a64924688487dcd64aceaf158affb5c", + "shasum": "" + }, + "require": { + "composer/pcre": "^1||^2||^3", + "ext-ctype": "*", + "ext-dom": "*", + "ext-fileinfo": "*", + "ext-gd": "*", + "ext-iconv": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-simplexml": "*", + "ext-xml": "*", + "ext-xmlreader": "*", + "ext-xmlwriter": "*", + "ext-zip": "*", + "ext-zlib": "*", + "ezyang/htmlpurifier": "^4.15", + "maennchen/zipstream-php": "^2.1 || ^3.0", + "markbaker/complex": "^3.0", + "markbaker/matrix": "^3.0", + "php": ">=7.4.0 <8.5.0", + "psr/simple-cache": "^1.0 || ^2.0 || ^3.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "dev-main", + "doctrine/instantiator": "^1.5", + "dompdf/dompdf": "^1.0 || ^2.0 || ^3.0", + "friendsofphp/php-cs-fixer": "^3.2", + "mitoteam/jpgraph": "^10.3", + "mpdf/mpdf": "^8.1.1", + "phpcompatibility/php-compatibility": "^9.3", + "phpstan/phpstan": "^1.1", + "phpstan/phpstan-phpunit": "^1.0", + "phpunit/phpunit": "^8.5 || ^9.0", + "squizlabs/php_codesniffer": "^3.7", + "tecnickcom/tcpdf": "^6.5" + }, + "suggest": { + "dompdf/dompdf": "Option for rendering PDF with PDF Writer", + "ext-intl": "PHP Internationalization Functions", + "mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers", + "mpdf/mpdf": "Option for rendering PDF with PDF Writer", + "tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer" + }, + "type": "library", + "autoload": { + "psr-4": { + "PhpOffice\\PhpSpreadsheet\\": "src/PhpSpreadsheet" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Maarten Balliauw", + "homepage": "https://blog.maartenballiauw.be" + }, + { + "name": "Mark Baker", + "homepage": "https://markbakeruk.net" + }, + { + "name": "Franck Lefevre", + "homepage": "https://rootslabs.net" + }, + { + "name": "Erik Tilt" + }, + { + "name": "Adrien Crivelli" + }, + { + "name": "Owen Leibman" + } + ], + "description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine", + "homepage": "https://github.com/PHPOffice/PhpSpreadsheet", + "keywords": [ + "OpenXML", + "excel", + "gnumeric", + "ods", + "php", + "spreadsheet", + "xls", + "xlsx" + ], + "support": { + "issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues", + "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/1.30.5" + }, + "time": "2026-05-31T05:13:11+00:00" + }, + { + "name": "psr/clock", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/clock.git", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Psr\\Clock\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for reading the clock.", + "homepage": "https://github.com/php-fig/clock", + "keywords": [ + "clock", + "now", + "psr", + "psr-20", + "time" + ], + "support": { + "issues": "https://github.com/php-fig/clock/issues", + "source": "https://github.com/php-fig/clock/tree/1.0.0" + }, + "time": "2022-11-25T14:36:26+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/simple-cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/simple-cache.git", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\SimpleCache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interfaces for simple caching", + "keywords": [ + "cache", + "caching", + "psr", + "psr-16", + "simple-cache" + ], + "support": { + "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" + }, + "time": "2021-10-29T13:26:27+00:00" + }, + { + "name": "sabberworm/php-css-parser", + "version": "v9.4.0", + "source": { + "type": "git", + "url": "https://github.com/MyIntervals/PHP-CSS-Parser.git", + "reference": "fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f", + "reference": "fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": "^7.2.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "thecodingmachine/safe": "^1.3 || ^2.5 || ^3.4" + }, + "require-dev": { + "php-parallel-lint/php-parallel-lint": "1.4.0", + "phpstan/extension-installer": "1.4.3", + "phpstan/phpstan": "1.12.33 || 2.2.2", + "phpstan/phpstan-phpunit": "1.4.2 || 2.0.16", + "phpstan/phpstan-strict-rules": "1.6.2 || 2.0.11", + "phpunit/phpunit": "8.5.52", + "rawr/phpunit-data-provider": "3.3.1", + "rector/rector": "1.2.10 || 2.4.6", + "rector/type-perfect": "1.0.0 || 2.1.3", + "squizlabs/php_codesniffer": "4.0.1", + "thecodingmachine/phpstan-safe-rule": "1.2.0 || 1.4.3" + }, + "suggest": { + "ext-mbstring": "for parsing UTF-8 CSS" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.5.x-dev" + } + }, + "autoload": { + "files": [ + "src/Rule/Rule.php", + "src/RuleSet/RuleContainer.php" + ], + "psr-4": { + "Sabberworm\\CSS\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Raphael Schweikert" + }, + { + "name": "Oliver Klee", + "email": "github@oliverklee.de" + }, + { + "name": "Jake Hotson", + "email": "jake.github@qzdesign.co.uk" + } + ], + "description": "Parser for CSS Files written in PHP", + "homepage": "https://www.sabberworm.com/blog/2010/6/10/php-css-parser", + "keywords": [ + "css", + "parser", + "stylesheet" + ], + "support": { + "issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues", + "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.4.0" + }, + "time": "2026-06-18T15:10:53+00:00" + }, + { + "name": "symfony/clock", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/clock.git", + "reference": "701ef4de9705d6c32292ebee5e8044094a09fbf6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/clock/zipball/701ef4de9705d6c32292ebee5e8044094a09fbf6", + "reference": "701ef4de9705d6c32292ebee5e8044094a09fbf6", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "psr/clock": "^1.0" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/now.php" + ], + "psr-4": { + "Symfony\\Component\\Clock\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Decouples applications from the system clock", + "homepage": "https://symfony.com", + "keywords": [ + "clock", + "psr20", + "time" + ], + "support": { + "source": "https://github.com/symfony/clock/tree/v8.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-29T05:06:50+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.38.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-27T06:59:30+00:00" + }, + { + "name": "symfony/polyfill-php84", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php84\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php84/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-26T12:51:13+00:00" + }, + { + "name": "symfony/polyfill-php85", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", + "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php85\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php85/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-26T02:25:22+00:00" + }, + { + "name": "symfony/polyfill-php86", + "version": "v1.38.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php86.git", + "reference": "fcec68d64f46dc84e1f6ffcf2c6dda40ff3143ad" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php86/zipball/fcec68d64f46dc84e1f6ffcf2c6dda40ff3143ad", + "reference": "fcec68d64f46dc84e1f6ffcf2c6dda40ff3143ad", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php86\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.6+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php86/tree/v1.38.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-25T11:52:35+00:00" + }, + { + "name": "symfony/translation", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation.git", + "reference": "b2bd012ca28c4acae830ee1206a5b6e35dd99693" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation/zipball/b2bd012ca28c4acae830ee1206a5b6e35dd99693", + "reference": "b2bd012ca28c4acae830ee1206a5b6e35dd99693", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/polyfill-mbstring": "^1.0", + "symfony/translation-contracts": "^3.6.1" + }, + "conflict": { + "nikic/php-parser": "<5.0", + "symfony/http-client-contracts": "<2.5", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "symfony/translation-implementation": "2.3|3.0" + }, + "require-dev": { + "nikic/php-parser": "^5.0", + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/finder": "^7.4|^8.0", + "symfony/http-client-contracts": "^2.5|^3.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", + "symfony/polyfill-intl-icu": "^1.21", + "symfony/routing": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to internationalize your application", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/translation/tree/v8.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-29T05:06:50+00:00" + }, + { + "name": "symfony/translation-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation-contracts.git", + "reference": "0ab302977a952b42fd51475c4ebac81f8da0a95d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/0ab302977a952b42fd51475c4ebac81f8da0a95d", + "reference": "0ab302977a952b42fd51475c4ebac81f8da0a95d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to translation", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/translation-contracts/tree/v3.7.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-05T13:30:16+00:00" + }, + { + "name": "thecodingmachine/safe", + "version": "v3.4.0", + "source": { + "type": "git", + "url": "https://github.com/thecodingmachine/safe.git", + "reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thecodingmachine/safe/zipball/705683a25bacf0d4860c7dea4d7947bfd09eea19", + "reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpstan/phpstan": "^2", + "phpunit/phpunit": "^10", + "squizlabs/php_codesniffer": "^3.2" + }, + "type": "library", + "autoload": { + "files": [ + "lib/special_cases.php", + "generated/apache.php", + "generated/apcu.php", + "generated/array.php", + "generated/bzip2.php", + "generated/calendar.php", + "generated/classobj.php", + "generated/com.php", + "generated/cubrid.php", + "generated/curl.php", + "generated/datetime.php", + "generated/dir.php", + "generated/eio.php", + "generated/errorfunc.php", + "generated/exec.php", + "generated/fileinfo.php", + "generated/filesystem.php", + "generated/filter.php", + "generated/fpm.php", + "generated/ftp.php", + "generated/funchand.php", + "generated/gettext.php", + "generated/gmp.php", + "generated/gnupg.php", + "generated/hash.php", + "generated/ibase.php", + "generated/ibmDb2.php", + "generated/iconv.php", + "generated/image.php", + "generated/imap.php", + "generated/info.php", + "generated/inotify.php", + "generated/json.php", + "generated/ldap.php", + "generated/libxml.php", + "generated/lzf.php", + "generated/mailparse.php", + "generated/mbstring.php", + "generated/misc.php", + "generated/mysql.php", + "generated/mysqli.php", + "generated/network.php", + "generated/oci8.php", + "generated/opcache.php", + "generated/openssl.php", + "generated/outcontrol.php", + "generated/pcntl.php", + "generated/pcre.php", + "generated/pgsql.php", + "generated/posix.php", + "generated/ps.php", + "generated/pspell.php", + "generated/readline.php", + "generated/rnp.php", + "generated/rpminfo.php", + "generated/rrd.php", + "generated/sem.php", + "generated/session.php", + "generated/shmop.php", + "generated/sockets.php", + "generated/sodium.php", + "generated/solr.php", + "generated/spl.php", + "generated/sqlsrv.php", + "generated/ssdeep.php", + "generated/ssh2.php", + "generated/stream.php", + "generated/strings.php", + "generated/swoole.php", + "generated/uodbc.php", + "generated/uopz.php", + "generated/url.php", + "generated/var.php", + "generated/xdiff.php", + "generated/xml.php", + "generated/xmlrpc.php", + "generated/yaml.php", + "generated/yaz.php", + "generated/zip.php", + "generated/zlib.php" + ], + "classmap": [ + "lib/DateTime.php", + "lib/DateTimeImmutable.php", + "lib/Exceptions/", + "generated/Exceptions/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHP core functions that throw exceptions instead of returning FALSE on error", + "support": { + "issues": "https://github.com/thecodingmachine/safe/issues", + "source": "https://github.com/thecodingmachine/safe/tree/v3.4.0" + }, + "funding": [ + { + "url": "https://github.com/OskarStark", + "type": "github" + }, + { + "url": "https://github.com/shish", + "type": "github" + }, + { + "url": "https://github.com/silasjoisten", + "type": "github" + }, + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2026-02-04T18:08:13+00:00" + }, + { + "name": "voku/portable-ascii", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/voku/portable-ascii.git", + "reference": "8e1051fe39379367aecf014f41744ce7539a856f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/voku/portable-ascii/zipball/8e1051fe39379367aecf014f41744ce7539a856f", + "reference": "8e1051fe39379367aecf014f41744ce7539a856f", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpunit/phpunit": "~8.5 || ~9.6 || ~10.5 || ~11.5" + }, + "suggest": { + "ext-intl": "Use Intl for transliterator_transliterate() support" + }, + "type": "library", + "autoload": { + "psr-4": { + "voku\\": "src/voku/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Lars Moelleken", + "homepage": "https://www.moelleken.org/" + } + ], + "description": "Portable ASCII library - performance optimized (ascii) string functions for php.", + "homepage": "https://github.com/voku/portable-ascii", + "keywords": [ + "ascii", + "clean", + "php" + ], + "support": { + "issues": "https://github.com/voku/portable-ascii/issues", + "source": "https://github.com/voku/portable-ascii/tree/2.1.1" + }, + "funding": [ + { + "url": "https://www.paypal.me/moelleken", + "type": "custom" + }, + { + "url": "https://github.com/voku", + "type": "github" + }, + { + "url": "https://opencollective.com/portable-ascii", + "type": "open_collective" + }, + { + "url": "https://www.patreon.com/voku", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/voku/portable-ascii", + "type": "tidelift" + } + ], + "time": "2026-04-26T05:33:54+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": {}, + "platform-dev": {}, + "plugin-api-version": "2.6.0" +} diff --git a/erp/.editorconfig b/erp/.editorconfig new file mode 100644 index 00000000000..6df84280f0e --- /dev/null +++ b/erp/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.{yml,yaml}] +indent_size = 2 + +[{compose,docker-compose}.{yml,yaml}] +indent_size = 4 diff --git a/erp/.env.example b/erp/.env.example new file mode 100644 index 00000000000..c0660ea143a --- /dev/null +++ b/erp/.env.example @@ -0,0 +1,65 @@ +APP_NAME=Laravel +APP_ENV=local +APP_KEY= +APP_DEBUG=true +APP_URL=http://localhost + +APP_LOCALE=en +APP_FALLBACK_LOCALE=en +APP_FAKER_LOCALE=en_US + +APP_MAINTENANCE_DRIVER=file +# APP_MAINTENANCE_STORE=database + +# PHP_CLI_SERVER_WORKERS=4 + +BCRYPT_ROUNDS=12 + +LOG_CHANNEL=stack +LOG_STACK=single +LOG_DEPRECATIONS_CHANNEL=null +LOG_LEVEL=debug + +DB_CONNECTION=sqlite +# DB_HOST=127.0.0.1 +# DB_PORT=3306 +# DB_DATABASE=laravel +# DB_USERNAME=root +# DB_PASSWORD= + +SESSION_DRIVER=database +SESSION_LIFETIME=120 +SESSION_ENCRYPT=false +SESSION_PATH=/ +SESSION_DOMAIN=null + +BROADCAST_CONNECTION=log +FILESYSTEM_DISK=local +QUEUE_CONNECTION=database + +CACHE_STORE=database +# CACHE_PREFIX= + +MEMCACHED_HOST=127.0.0.1 + +REDIS_CLIENT=phpredis +REDIS_HOST=127.0.0.1 +REDIS_PASSWORD=null +REDIS_PORT=6379 + +MAIL_MAILER=log +MAIL_SCHEME=null +MAIL_HOST=127.0.0.1 +MAIL_PORT=2525 +MAIL_USERNAME=null +MAIL_PASSWORD=null +MAIL_FROM_ADDRESS="hello@example.com" +MAIL_FROM_NAME="${APP_NAME}" + +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_DEFAULT_REGION=us-east-1 +AWS_BUCKET= +AWS_USE_PATH_STYLE_ENDPOINT=false + +VITE_APP_NAME="${APP_NAME}" diff --git a/erp/.gitattributes b/erp/.gitattributes new file mode 100644 index 00000000000..fcb21d396d6 --- /dev/null +++ b/erp/.gitattributes @@ -0,0 +1,11 @@ +* text=auto eol=lf + +*.blade.php diff=html +*.css diff=css +*.html diff=html +*.md diff=markdown +*.php diff=php + +/.github export-ignore +CHANGELOG.md export-ignore +.styleci.yml export-ignore diff --git a/erp/.gitignore b/erp/.gitignore new file mode 100644 index 00000000000..ac7ff5e2801 --- /dev/null +++ b/erp/.gitignore @@ -0,0 +1,28 @@ +*.log +.DS_Store +.env +.env.backup +.env.production +.phpactor.json +.phpunit.result.cache +/.codex +/.cursor/ +/.idea +/.nova +/.phpunit.cache +/.vscode +/.zed +/auth.json +/node_modules +/public/build +/public/fonts-manifest.dev.json +/public/hot +/public/storage +/storage/*.key +/storage/pail +/vendor +_ide_helper.php +Homestead.json +Homestead.yaml +Thumbs.db +erp/.env.bak diff --git a/erp/.npmrc b/erp/.npmrc new file mode 100644 index 00000000000..495a6af9983 --- /dev/null +++ b/erp/.npmrc @@ -0,0 +1,2 @@ +ignore-scripts=true +audit=true diff --git a/erp/README.md b/erp/README.md new file mode 100644 index 00000000000..5ad13779e0d --- /dev/null +++ b/erp/README.md @@ -0,0 +1,58 @@ +

Laravel Logo

+ +

+Build Status +Total Downloads +Latest Stable Version +License +

+ +## About Laravel + +Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as: + +- [Simple, fast routing engine](https://laravel.com/docs/routing). +- [Powerful dependency injection container](https://laravel.com/docs/container). +- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage. +- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent). +- Database agnostic [schema migrations](https://laravel.com/docs/migrations). +- [Robust background job processing](https://laravel.com/docs/queues). +- [Real-time event broadcasting](https://laravel.com/docs/broadcasting). + +Laravel is accessible, powerful, and provides tools required for large, robust applications. + +## Learning Laravel + +Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework. + +In addition, [Laracasts](https://laracasts.com) contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library. + +You can also watch bite-sized lessons with real-world projects on [Laravel Learn](https://laravel.com/learn), where you will be guided through building a Laravel application from scratch while learning PHP fundamentals. + +## Agentic Development + +Laravel's predictable structure and conventions make it ideal for AI coding agents like Claude Code, Cursor, and GitHub Copilot. Install [Laravel Boost](https://laravel.com/docs/ai) to supercharge your AI workflow: + +```bash +composer require laravel/boost --dev + +php artisan boost:install +``` + +Boost provides your agent 15+ tools and skills that help agents build Laravel applications while following best practices. + +## Contributing + +Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions). + +## Code of Conduct + +In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct). + +## Security Vulnerabilities + +If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed. + +## License + +The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT). diff --git a/erp/app/Console/Commands/EvaluateAlerts.php b/erp/app/Console/Commands/EvaluateAlerts.php new file mode 100644 index 00000000000..18696d94554 --- /dev/null +++ b/erp/app/Console/Commands/EvaluateAlerts.php @@ -0,0 +1,35 @@ +get(); + $fired = 0; + $checked = 0; + + foreach ($rules as $rule) { + $triggered = $evaluator->evaluate($rule); + $checked++; + + if (! empty($triggered)) { + $evaluator->fire($rule, $triggered); + $fired++; + $this->info("Triggered: {$rule->name} — {$triggered[0]['message']}"); + } + } + + $this->info("Checked {$checked} rule(s), triggered {$fired}."); + + return self::SUCCESS; + } +} diff --git a/erp/app/Console/Commands/SendScheduledReports.php b/erp/app/Console/Commands/SendScheduledReports.php new file mode 100644 index 00000000000..fe86efb04e7 --- /dev/null +++ b/erp/app/Console/Commands/SendScheduledReports.php @@ -0,0 +1,32 @@ +where(function ($q) { + $q->whereNull('next_run_at') + ->orWhere('next_run_at', '<=', now()); + }) + ->get(); + + foreach ($due as $schedule) { + SendScheduledReportJob::dispatch($schedule); + $this->info("Queued report: {$schedule->name} ({$schedule->report_type})"); + } + + $this->info("Dispatched {$due->count()} scheduled report job(s)."); + + return self::SUCCESS; + } +} diff --git a/erp/app/Events/CRM/CrmDealWon.php b/erp/app/Events/CRM/CrmDealWon.php new file mode 100644 index 00000000000..1ae2aed0c8d --- /dev/null +++ b/erp/app/Events/CRM/CrmDealWon.php @@ -0,0 +1,10 @@ +message->channel_id), + ]; + } + + public function broadcastWith(): array + { + return [ + 'id' => $this->message->id, + 'channel_id' => $this->message->channel_id, + 'user_id' => $this->message->user_id, + 'body' => $this->message->body, + 'parent_id' => $this->message->parent_id, + 'is_edited' => (bool) $this->message->is_edited, + 'created_at' => $this->message->created_at?->toIso8601String(), + 'user' => $this->message->relationLoaded('user') + ? ['id' => $this->message->user->id, 'name' => $this->message->user->name] + : null, + ]; + } +} diff --git a/erp/app/Events/HR/PayrollRunApproved.php b/erp/app/Events/HR/PayrollRunApproved.php new file mode 100644 index 00000000000..dc90d721a73 --- /dev/null +++ b/erp/app/Events/HR/PayrollRunApproved.php @@ -0,0 +1,10 @@ +message->session_id), + ]; + } + + public function broadcastWith(): array + { + return [ + 'id' => $this->message->id, + 'session_id' => $this->message->session_id, + 'sender_type' => $this->message->sender_type, + 'agent_id' => $this->message->agent_id, + 'message' => $this->message->message, + 'created_at' => $this->message->created_at?->toIso8601String(), + ]; + } +} diff --git a/erp/app/Events/Manufacturing/ManufacturingOrderCompleted.php b/erp/app/Events/Manufacturing/ManufacturingOrderCompleted.php new file mode 100644 index 00000000000..6155c501007 --- /dev/null +++ b/erp/app/Events/Manufacturing/ManufacturingOrderCompleted.php @@ -0,0 +1,10 @@ +tenantId), + ]; + } + + public function broadcastWith(): array + { + return [ + 'type' => $this->type, + 'title' => $this->title, + 'message' => $this->message, + 'data' => $this->data, + ]; + } +} diff --git a/erp/app/Events/Purchase/PurchaseOrderConfirmed.php b/erp/app/Events/Purchase/PurchaseOrderConfirmed.php new file mode 100644 index 00000000000..3633795331a --- /dev/null +++ b/erp/app/Events/Purchase/PurchaseOrderConfirmed.php @@ -0,0 +1,10 @@ +tenantId); + } + + public function headings(): array + { + return ['ID', 'Name', 'Email', 'Type']; + } + + public function map($contact): array + { + return [ + $contact->id, + $contact->name, + $contact->email, + $contact->type, + ]; + } +} diff --git a/erp/app/Exports/InvoicesExport.php b/erp/app/Exports/InvoicesExport.php new file mode 100644 index 00000000000..a3ce671fd3e --- /dev/null +++ b/erp/app/Exports/InvoicesExport.php @@ -0,0 +1,34 @@ +tenantId)->with('items'); + } + + public function headings(): array + { + return ['ID', 'Number', 'Status', 'Issue Date', 'Total']; + } + + public function map($invoice): array + { + return [ + $invoice->id, + $invoice->number, + $invoice->status, + $invoice->issue_date?->toDateString(), + $invoice->total, + ]; + } +} diff --git a/erp/app/Exports/ProductsExport.php b/erp/app/Exports/ProductsExport.php new file mode 100644 index 00000000000..94738bfe413 --- /dev/null +++ b/erp/app/Exports/ProductsExport.php @@ -0,0 +1,35 @@ +tenantId); + } + + public function headings(): array + { + return ['ID', 'SKU', 'Name', 'Cost Price', 'Sale Price', 'Type']; + } + + public function map($product): array + { + return [ + $product->id, + $product->sku, + $product->name, + $product->cost_price, + $product->sale_price, + $product->type ?? '', + ]; + } +} diff --git a/erp/app/Http/Controllers/Admin/AuditLogController.php b/erp/app/Http/Controllers/Admin/AuditLogController.php new file mode 100644 index 00000000000..4d5569bd386 --- /dev/null +++ b/erp/app/Http/Controllers/Admin/AuditLogController.php @@ -0,0 +1,87 @@ +authorize('viewAny', User::class); + + $query = AuditLog::with('user') + ->where('audit_logs.tenant_id', auth()->user()->tenant_id) + ->when($request->event, fn ($q) => $q->where('event', $request->event)) + ->when($request->user_id, fn ($q) => $q->where('user_id', $request->user_id)) + ->when($request->model, fn ($q) => $q->where('auditable_type', 'like', "%{$request->model}%")) + ->when($request->date_from, fn ($q) => $q->whereDate('created_at', '>=', $request->date_from)) + ->when($request->date_to, fn ($q) => $q->whereDate('created_at', '<=', $request->date_to)); + + $logs = $query + ->latest('created_at') + ->paginate(50) + ->withQueryString() + ->through(fn ($log) => [ + 'id' => $log->id, + 'event' => $log->event, + 'action' => $log->action ?? $log->event, + 'model' => class_basename($log->auditable_type), + 'model_id' => $log->auditable_id, + 'auditable_label'=> $log->auditable_label, + 'user' => $log->user ? ['name' => $log->user->name, 'email' => $log->user->email] : null, + 'user_name' => $log->user?->name ?? 'System', + 'old_values' => $log->old_values, + 'new_values' => $log->new_values, + 'ip_address' => $log->ip_address, + 'user_agent' => $log->user_agent, + 'module' => $log->module, + 'created_at' => $log->created_at->diffForHumans(), + 'created_at_raw' => $log->created_at->toDateTimeString(), + ]); + + $users = User::where('tenant_id', auth()->user()->tenant_id) + ->orderBy('name') + ->get(['id', 'name']); + + return Inertia::render('Admin/AuditLog/Index', [ + 'logs' => $logs, + 'filters' => $request->only(['event', 'model', 'user_id', 'date_from', 'date_to']), + 'users' => $users, + ]); + } + + public function show(AuditLog $log): Response + { + $this->authorize('viewAny', User::class); + $log->load('user'); + + return Inertia::render('Admin/AuditLog/Show', [ + 'log' => [ + 'id' => $log->id, + 'event' => $log->event, + 'action' => $log->action ?? $log->event, + 'auditable_type' => $log->auditable_type, + 'auditable_id' => $log->auditable_id, + 'auditable_label'=> $log->auditable_label, + 'old_values' => $log->old_values, + 'new_values' => $log->new_values, + 'ip_address' => $log->ip_address, + 'user_agent' => $log->user_agent, + 'url' => $log->url, + 'module' => $log->module, + 'user' => $log->user ? [ + 'id' => $log->user->id, + 'name' => $log->user->name, + 'email' => $log->user->email, + ] : null, + 'created_at' => $log->created_at->toDateTimeString(), + ], + ]); + } +} diff --git a/erp/app/Http/Controllers/Admin/UserController.php b/erp/app/Http/Controllers/Admin/UserController.php new file mode 100644 index 00000000000..61972cda6e8 --- /dev/null +++ b/erp/app/Http/Controllers/Admin/UserController.php @@ -0,0 +1,139 @@ +authorize('viewAny', User::class); + + $users = User::with('roles') + ->when($request->search, fn ($q) => $q->where('name', 'like', "%{$request->search}%") + ->orWhere('email', 'like', "%{$request->search}%")) + ->where('tenant_id', auth()->user()->tenant_id) + ->orderBy('name') + ->paginate(25) + ->withQueryString(); + + return Inertia::render('Admin/Users/Index', [ + 'users' => $users->through(fn ($u) => [ + 'id' => $u->id, + 'name' => $u->name, + 'email' => $u->email, + 'roles' => $u->roles->pluck('name'), + 'created_at' => $u->created_at?->toDateString(), + ]), + 'filters' => $request->only(['search']), + 'breadcrumbs' => [ + ['label' => 'Administration'], + ['label' => 'Users', 'href' => route('admin.users.index')], + ], + ]); + } + + public function create(): Response + { + $this->authorize('create', User::class); + + return Inertia::render('Admin/Users/Create', [ + 'roles' => Role::orderBy('name')->pluck('name'), + 'breadcrumbs' => [ + ['label' => 'Administration'], + ['label' => 'Users', 'href' => route('admin.users.index')], + ['label' => 'New User'], + ], + ]); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', User::class); + + $data = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'email' => ['required', 'email', 'max:255', 'unique:users,email'], + 'password' => ['required', Password::defaults()], + 'role' => ['required', 'string', 'exists:roles,name'], + ]); + + $user = User::create([ + 'name' => $data['name'], + 'email' => $data['email'], + 'password' => Hash::make($data['password']), + 'tenant_id' => auth()->user()->tenant_id, + ]); + + $user->assignRole($data['role']); + + return redirect()->route('admin.users.index') + ->with('success', 'User created.'); + } + + public function edit(User $user): Response + { + $this->authorize('update', $user); + + return Inertia::render('Admin/Users/Edit', [ + 'user' => [ + 'id' => $user->id, + 'name' => $user->name, + 'email' => $user->email, + 'role' => $user->roles->first()?->name, + ], + 'roles' => Role::orderBy('name')->pluck('name'), + 'breadcrumbs' => [ + ['label' => 'Administration'], + ['label' => 'Users', 'href' => route('admin.users.index')], + ['label' => $user->name . ' — Edit'], + ], + ]); + } + + public function update(Request $request, User $user): RedirectResponse + { + $this->authorize('update', $user); + + $data = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'email' => ['required', 'email', 'max:255', "unique:users,email,{$user->id}"], + 'password' => ['nullable', Password::defaults()], + 'role' => ['required', 'string', 'exists:roles,name'], + ]); + + $user->update([ + 'name' => $data['name'], + 'email' => $data['email'], + ...(($data['password'] ?? null) ? ['password' => Hash::make($data['password'])] : []), + ]); + + $user->syncRoles([$data['role']]); + + return redirect()->route('admin.users.index') + ->with('success', 'User updated.'); + } + + public function destroy(User $user): RedirectResponse + { + $this->authorize('delete', $user); + + if ($user->id === auth()->id()) { + return back()->withErrors(['user' => 'You cannot delete your own account.']); + } + + $user->delete(); + + return redirect()->route('admin.users.index') + ->with('success', 'User deleted.'); + } +} diff --git a/erp/app/Http/Controllers/AnalyticsController.php b/erp/app/Http/Controllers/AnalyticsController.php new file mode 100644 index 00000000000..8fc987896ff --- /dev/null +++ b/erp/app/Http/Controllers/AnalyticsController.php @@ -0,0 +1,103 @@ +user(); + + return Inertia::render('Analytics/Index', [ + 'revenue_by_month' => $user->can('finance.view') ? $this->revenueByMonth() : [], + 'invoice_by_status' => $user->can('finance.view') ? $this->invoiceByStatus() : [], + 'headcount_by_dept' => $user->can('hr.view') ? $this->headcountByDept() : [], + 'payroll_summary' => $user->can('hr.view') ? $this->payrollSummary() : [], + 'inventory_value' => $user->can('inventory.view') ? $this->inventoryValue() : null, + 'breadcrumbs' => [['label' => 'Analytics', 'href' => route('analytics')]], + ]); + } + + private function revenueByMonth(): array + { + $paid = Invoice::where('status', 'paid') + ->where('updated_at', '>=', now()->subMonths(12)->startOfMonth()) + ->with('items') + ->get(); + + return collect(range(11, 0))->map(function ($i) use ($paid) { + $month = now()->subMonths($i)->startOfMonth(); + $monthKey = $month->format('Y-m'); + + $total = $paid + ->filter(fn ($inv) => $inv->updated_at->format('Y-m') === $monthKey) + ->sum(fn ($inv) => $inv->total); + + return ['label' => $month->format('M y'), 'value' => round((float) $total, 2)]; + })->values()->all(); + } + + private function invoiceByStatus(): array + { + $counts = Invoice::selectRaw('status, count(*) as total') + ->groupBy('status') + ->pluck('total', 'status'); + + return collect(['draft', 'sent', 'paid', 'cancelled'])->map(fn ($s) => [ + 'label' => ucfirst($s), + 'value' => (int) ($counts[$s] ?? 0), + ])->all(); + } + + private function headcountByDept(): array + { + return Department::withCount(['employees' => fn ($q) => $q->where('status', 'active')]) + ->get() + ->map(fn ($d) => ['label' => $d->name, 'value' => $d->employees_count]) + ->sortByDesc('value') + ->values() + ->all(); + } + + private function payrollSummary(): array + { + return PayrollRun::with('items') + ->latest('period_start') + ->limit(6) + ->get() + ->map(fn ($run) => [ + 'label' => $run->period_start->format('M Y'), + 'value' => round($run->total_net, 2), + ]) + ->sortBy(fn ($r) => $r['label']) + ->values() + ->all(); + } + + private function inventoryValue(): array + { + $total = StockLevel::join('products', 'products.id', '=', 'stock_levels.product_id') + ->selectRaw('SUM(stock_levels.quantity * products.cost_price) as total_value, COUNT(DISTINCT products.id) as product_count') + ->first(); + + $lowStock = StockLevel::join('products', 'products.id', '=', 'stock_levels.product_id') + ->whereColumn('stock_levels.quantity', '<=', 'products.reorder_point') + ->distinct('products.id') + ->count('products.id'); + + return [ + 'total_value' => round((float) ($total->total_value ?? 0), 2), + 'product_count' => (int) ($total->product_count ?? 0), + 'low_stock' => $lowStock, + ]; + } +} diff --git a/erp/app/Http/Controllers/Api/ApiDocsController.php b/erp/app/Http/Controllers/Api/ApiDocsController.php new file mode 100644 index 00000000000..b92272fc7d0 --- /dev/null +++ b/erp/app/Http/Controllers/Api/ApiDocsController.php @@ -0,0 +1,60 @@ + + + + ERP API Documentation + + + + + +
+ + + + +HTML; + + return response($html, 200, ['Content-Type' => 'text/html']); + } + + /** + * Serve the raw OpenAPI YAML spec. + */ + public function spec(): Response + { + $path = public_path('api-docs/openapi.yaml'); + + if (! File::exists($path)) { + abort(404, 'OpenAPI spec not found.'); + } + + return response(File::get($path), 200, [ + 'Content-Type' => 'application/yaml', + 'Access-Control-Allow-Origin' => '*', + ]); + } +} diff --git a/erp/app/Http/Controllers/Api/V1/AccountingApiController.php b/erp/app/Http/Controllers/Api/V1/AccountingApiController.php new file mode 100644 index 00000000000..eff3733a965 --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/AccountingApiController.php @@ -0,0 +1,109 @@ +query('status')) { + $query->where('status', $status); + } + + if ($dateFrom = $request->query('date_from')) { + $query->whereDate('entry_date', '>=', $dateFrom); + } + + if ($dateTo = $request->query('date_to')) { + $query->whereDate('entry_date', '<=', $dateTo); + } + + $paginator = $query->latest('entry_date')->paginate(20); + + return $this->paginated($paginator); + } + + /** + * GET /api/v1/accounting/journal-entries/{id} + */ + public function showJournalEntry(int $id): JsonResponse + { + $entry = JournalEntry::with('lines')->findOrFail($id); + + return $this->success($entry); + } + + /** + * POST /api/v1/accounting/journal-entries + */ + public function storeJournalEntry(Request $request): JsonResponse + { + $validated = $request->validate([ + 'reference' => 'nullable|string|max:255', + 'description' => 'required|string', + 'entry_date' => 'required|date', + ]); + + $tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $validated['tenant_id'] = $tenantId; + $validated['status'] = 'draft'; + + $entry = JournalEntry::create($validated); + + return $this->success($entry, 201); + } + + /** + * GET /api/v1/accounting/accounts + */ + public function accounts(Request $request): JsonResponse + { + $query = Account::select('id', 'code', 'name', 'type', 'normal_balance', 'is_active'); + + if ($type = $request->query('type')) { + $query->where('type', $type); + } + + if ($request->has('is_active')) { + $query->where('is_active', filter_var($request->query('is_active'), FILTER_VALIDATE_BOOLEAN)); + } + + $paginator = $query->orderBy('code')->paginate(20); + + return $this->paginated($paginator); + } + + /** + * POST /api/v1/accounting/accounts + */ + public function storeAccount(Request $request): JsonResponse + { + $validated = $request->validate([ + 'code' => 'required|string|max:50', + 'name' => 'required|string|max:255', + 'type' => 'required|string|in:asset,liability,equity,revenue,expense', + 'normal_balance' => 'required|string|in:debit,credit', + 'sub_type' => 'nullable|string|max:100', + 'is_active' => 'nullable|boolean', + ]); + + $tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $validated['tenant_id'] = $tenantId; + + $account = Account::create($validated); + + return $this->success($account, 201); + } +} diff --git a/erp/app/Http/Controllers/Api/V1/ActivityFeedController.php b/erp/app/Http/Controllers/Api/V1/ActivityFeedController.php new file mode 100644 index 00000000000..41820427357 --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/ActivityFeedController.php @@ -0,0 +1,127 @@ +has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $query = AuditLog::where('tenant_id', $tenantId) + ->with('user:id,name') + ->latest('created_at'); + + // Filter by action/event + if ($action = $request->action) { + $query->where(function ($q) use ($action) { + $q->where('action', $action)->orWhere('event', $action); + }); + } + + // Filter by module/model type + if ($module = $request->module) { + $query->where('auditable_type', 'like', "%{$module}%"); + } + + // Filter by user + if ($userId = $request->user_id) { + $query->where('user_id', $userId); + } + + // Filter by date range + if ($from = $request->from) { + $query->where('created_at', '>=', $from); + } + if ($to = $request->to) { + $query->where('created_at', '<=', $to); + } + + $logs = $query->paginate($request->integer('per_page', 20)); + + // Enrich each log with human-readable info + $logs->getCollection()->transform(function (AuditLog $log) { + return [ + 'id' => $log->id, + 'action' => $log->action ?? $log->event, + 'model_type' => $log->auditable_type ? class_basename($log->auditable_type) : null, + 'model_id' => $log->auditable_id, + 'model_label' => $log->auditable_label, + 'description' => $this->buildDescription($log), + 'old_values' => $log->old_values, + 'new_values' => $log->new_values, + 'changed_fields' => $this->extractChangedFields($log), + 'performed_by' => $log->user ? ['id' => $log->user->id, 'name' => $log->user->name] : null, + 'ip_address' => $log->ip_address, + 'created_at' => $log->created_at, + ]; + }); + + return $this->paginated($logs); + } + + public function stats(Request $request): JsonResponse + { + $tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $actionCounts = AuditLog::where('tenant_id', $tenantId) + ->selectRaw('COALESCE(action, event) as act, COUNT(*) as count') + ->groupBy('act') + ->orderByDesc('count') + ->limit(10) + ->get() + ->map(fn ($r) => ['action' => $r->act, 'count' => $r->count]); + + $activeUsers = AuditLog::where('tenant_id', $tenantId) + ->whereNotNull('user_id') + ->selectRaw('user_id, COUNT(*) as count') + ->groupBy('user_id') + ->orderByDesc('count') + ->with('user:id,name') + ->limit(5) + ->get() + ->map(fn ($r) => [ + 'user_id' => $r->user_id, + 'name' => $r->user?->name ?? 'Unknown', + 'count' => $r->count, + ]); + + $recentActivity = AuditLog::where('tenant_id', $tenantId) + ->whereRaw("created_at >= datetime('now', '-7 days')") + ->count(); + + return $this->success([ + 'total_events' => AuditLog::where('tenant_id', $tenantId)->count(), + 'recent_7_days' => $recentActivity, + 'by_action' => $actionCounts, + 'most_active_users' => $activeUsers, + ]); + } + + private function buildDescription(AuditLog $log): string + { + $action = $log->action ?? $log->event ?? 'acted on'; + $modelType = $log->auditable_type ? class_basename($log->auditable_type) : 'record'; + $label = $log->auditable_label ?? "#{$log->auditable_id}"; + $user = $log->user?->name ?? 'System'; + + return "{$user} {$action} {$modelType} {$label}"; + } + + private function extractChangedFields(AuditLog $log): array + { + if (! $log->old_values || ! $log->new_values) { + return []; + } + + return array_keys(array_diff_assoc( + (array) $log->new_values, + (array) $log->old_values + )); + } +} diff --git a/erp/app/Http/Controllers/Api/V1/AlertRuleController.php b/erp/app/Http/Controllers/Api/V1/AlertRuleController.php new file mode 100644 index 00000000000..0c21ce8fb8c --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/AlertRuleController.php @@ -0,0 +1,93 @@ +tenantId($request); + $rules = AlertRule::where('tenant_id', $tenantId) + ->withCount('events') + ->latest() + ->get(); + + return $this->success($rules); + } + + public function store(Request $request): JsonResponse + { + $data = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'type' => ['required', Rule::in(array_keys(AlertRule::$supportedTypes))], + 'conditions' => ['required', 'array'], + 'notification_targets' => ['required', 'array', 'min:1'], + 'is_active' => ['boolean'], + ]); + + $rule = AlertRule::create([ + ...$data, + 'tenant_id' => $this->tenantId($request), + 'is_active' => $data['is_active'] ?? true, + ]); + + return $this->success($rule, 201); + } + + public function show(Request $request, AlertRule $alertRule): JsonResponse + { + return $this->success($alertRule->load('events')); + } + + public function update(Request $request, AlertRule $alertRule): JsonResponse + { + $data = $request->validate([ + 'name' => ['sometimes', 'string', 'max:255'], + 'conditions' => ['sometimes', 'array'], + 'notification_targets' => ['sometimes', 'array', 'min:1'], + 'is_active' => ['boolean'], + ]); + + $alertRule->update($data); + + return $this->success($alertRule->fresh()); + } + + public function destroy(AlertRule $alertRule): JsonResponse + { + $alertRule->delete(); + return $this->success(['message' => 'Alert rule deleted.']); + } + + public function run(AlertRule $alertRule, AlertEvaluatorService $evaluator): JsonResponse + { + $triggered = $evaluator->evaluate($alertRule); + + if (! empty($triggered)) { + $evaluator->fire($alertRule, $triggered); + } + + return $this->success([ + 'triggered' => ! empty($triggered), + 'events' => $triggered, + ]); + } + + public function events(AlertRule $alertRule): JsonResponse + { + $events = $alertRule->events()->latest('triggered_at')->limit(50)->get(); + return $this->success($events); + } + + private function tenantId(Request $request): int + { + return app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + } +} diff --git a/erp/app/Http/Controllers/Api/V1/ApiController.php b/erp/app/Http/Controllers/Api/V1/ApiController.php new file mode 100644 index 00000000000..f66055e1613 --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/ApiController.php @@ -0,0 +1,34 @@ +json(['success' => true, 'data' => $data], $status); + } + + protected function error(string $message, int $status = 400): JsonResponse + { + return response()->json(['success' => false, 'message' => $message], $status); + } + + protected function paginated(LengthAwarePaginator $paginator): JsonResponse + { + return response()->json([ + 'success' => true, + 'data' => $paginator->items(), + 'meta' => [ + 'total' => $paginator->total(), + 'per_page' => $paginator->perPage(), + 'current_page' => $paginator->currentPage(), + 'last_page' => $paginator->lastPage(), + ], + ]); + } +} diff --git a/erp/app/Http/Controllers/Api/V1/ApiTokenController.php b/erp/app/Http/Controllers/Api/V1/ApiTokenController.php new file mode 100644 index 00000000000..e0212b37d47 --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/ApiTokenController.php @@ -0,0 +1,84 @@ +user() + ->tokens() + ->orderByDesc('created_at') + ->get() + ->map(fn ($t) => [ + 'id' => $t->id, + 'name' => $t->name, + 'abilities' => $t->abilities, + 'last_used_at' => $t->last_used_at, + 'expires_at' => $t->expires_at, + 'created_at' => $t->created_at, + ]); + + return $this->success($tokens); + } + + public function store(Request $request): JsonResponse + { + $data = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'abilities' => ['nullable', 'array'], + 'abilities.*'=> ['string', 'in:' . implode(',', self::$validAbilities)], + 'expires_in' => ['nullable', 'integer', 'min:1', 'max:365'], + ]); + + $abilities = $data['abilities'] ?? ['*']; + $expiresAt = isset($data['expires_in']) + ? now()->addDays($data['expires_in']) + : null; + + $token = $request->user()->createToken( + $data['name'], + $abilities, + $expiresAt, + ); + + return $this->success([ + 'token' => $token->plainTextToken, + 'id' => $token->accessToken->id, + 'name' => $token->accessToken->name, + 'abilities' => $token->accessToken->abilities, + 'expires_at' => $token->accessToken->expires_at, + ], 201); + } + + public function destroy(Request $request, int $tokenId): JsonResponse + { + $deleted = $request->user()->tokens()->where('id', $tokenId)->delete(); + + if (! $deleted) { + return $this->error('Token not found.', 404); + } + + return $this->success(['message' => 'Token revoked.']); + } + + public function destroyAll(Request $request): JsonResponse + { + $request->user()->tokens()->delete(); + return $this->success(['message' => 'All tokens revoked.']); + } +} diff --git a/erp/app/Http/Controllers/Api/V1/AppointmentsApiController.php b/erp/app/Http/Controllers/Api/V1/AppointmentsApiController.php new file mode 100644 index 00000000000..d59aad1f49b --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/AppointmentsApiController.php @@ -0,0 +1,114 @@ +boolean('active')) { + $query->where('is_active', true); + } + + $paginator = $query->latest()->paginate(20); + + return $this->paginated($paginator); + } + + /** + * GET /api/v1/appointments/slots + */ + public function slots(Request $request): JsonResponse + { + $query = AppointmentSlot::with(['type:id,name']); + + if ($typeId = $request->query('appointment_type_id')) { + $query->where('appointment_type_id', $typeId); + } + + if ($date = $request->query('date')) { + $query->whereDate('start_at', $date); + } + + $paginator = $query->orderBy('start_at')->paginate(20); + + return $this->paginated($paginator); + } + + /** + * GET /api/v1/appointments + */ + public function appointments(Request $request): JsonResponse + { + $query = Appointment::with(['type:id,name']); + + if ($status = $request->query('status')) { + $query->where('status', $status); + } + + $paginator = $query->latest()->paginate(20); + + return $this->paginated($paginator); + } + + /** + * GET /api/v1/appointments/{id} + */ + public function showAppointment(int $id): JsonResponse + { + $appointment = Appointment::with('type')->findOrFail($id); + + return $this->success($appointment); + } + + /** + * POST /api/v1/appointments + */ + public function storeAppointment(Request $request): JsonResponse + { + $validated = $request->validate([ + 'appointment_slot_id' => 'required|integer', + 'appointment_type_id' => 'required|integer', + 'customer_name' => 'required|string|max:255', + 'customer_email' => 'nullable|email|max:255', + 'customer_phone' => 'nullable|string|max:50', + 'notes' => 'nullable|string', + 'status' => 'nullable|string|max:50', + ]); + + $tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $appointment = Appointment::create(array_merge($validated, [ + 'tenant_id' => $tenantId, + 'status' => $validated['status'] ?? 'pending', + ])); + + return $this->success($appointment->load('type'), 201); + } + + /** + * POST /api/v1/appointments/{id}/cancel + */ + public function cancelAppointment(int $id): JsonResponse + { + $appointment = Appointment::findOrFail($id); + + $appointment->update([ + 'status' => 'cancelled', + 'cancelled_at' => now(), + ]); + + return $this->success($appointment->fresh()); + } +} diff --git a/erp/app/Http/Controllers/Api/V1/ApprovalsApiController.php b/erp/app/Http/Controllers/Api/V1/ApprovalsApiController.php new file mode 100644 index 00000000000..0d14511bedd --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/ApprovalsApiController.php @@ -0,0 +1,88 @@ +has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $query = ApprovalRequest::where('tenant_id', $tenantId); + + if ($request->filled('status')) { + $query->where('status', $request->status); + } + + if ($request->filled('requestor_id')) { + $query->where('requested_by', $request->requestor_id); + } + + return $this->paginated($query->latest()->paginate(15)); + } + + public function show(int $id): JsonResponse + { + $approvalRequest = ApprovalRequest::with([ + 'requestedBy', + 'actions.actor', + 'workflow.steps.approver', + ])->findOrFail($id); + + return $this->success($approvalRequest); + } + + public function store(Request $request): JsonResponse + { + $tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $validated = $request->validate([ + 'workflow_id' => 'required|integer|exists:approval_workflows,id', + 'entity_type' => 'required|string|max:100', + 'entity_id' => 'required|integer', + 'entity_title' => 'required|string|max:255', + ]); + + $validated['tenant_id'] = $tenantId; + $validated['requested_by'] = $request->user()->id; + $validated['status'] = 'pending'; + $validated['current_step'] = 1; + + $approvalRequest = ApprovalRequest::create($validated); + + return $this->success($approvalRequest->load('workflow'), 201); + } + + public function approve(Request $request, int $id): JsonResponse + { + $approvalRequest = ApprovalRequest::findOrFail($id); + + $approvalRequest->update([ + 'status' => 'approved', + 'approved_at' => now(), + ]); + + return $this->success($approvalRequest->fresh()); + } + + public function reject(Request $request, int $id): JsonResponse + { + $validated = $request->validate([ + 'reason' => 'required|string', + ]); + + $approvalRequest = ApprovalRequest::findOrFail($id); + + $approvalRequest->update([ + 'status' => 'rejected', + 'rejected_at' => now(), + 'rejection_reason' => $validated['reason'], + ]); + + return $this->success($approvalRequest->fresh()); + } +} diff --git a/erp/app/Http/Controllers/Api/V1/AuditLogController.php b/erp/app/Http/Controllers/Api/V1/AuditLogController.php new file mode 100644 index 00000000000..f21ca8a34da --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/AuditLogController.php @@ -0,0 +1,23 @@ +has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $logs = AuditLog::where('tenant_id', $tenantId) + ->with('user:id,name') + ->when($request->action, fn($q) => $q->where('action', $request->action)) + ->when($request->type, fn($q) => $q->where('auditable_type', 'like', "%{$request->type}%")) + ->latest() + ->paginate(50); + + return $this->paginated($logs); + } +} diff --git a/erp/app/Http/Controllers/Api/V1/AuthController.php b/erp/app/Http/Controllers/Api/V1/AuthController.php new file mode 100644 index 00000000000..5555c1f6331 --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/AuthController.php @@ -0,0 +1,72 @@ +validate([ + 'email' => 'required|email', + 'password' => 'required', + ]); + + if (! Auth::attempt($request->only('email', 'password'))) { + return $this->error('Invalid credentials', 401); + } + + /** @var \App\Models\User $user */ + $user = Auth::user(); + $token = $user->createToken('api-token')->plainTextToken; + + return $this->success([ + 'token' => $token, + 'user' => [ + 'id' => $user->id, + 'name' => $user->name, + 'email' => $user->email, + 'tenant_id' => $user->tenant_id, + ], + ]); + } + + /** + * POST /api/v1/auth/logout + * Revoke the current access token. + */ + public function logout(Request $request): JsonResponse + { + $request->user()->currentAccessToken()->delete(); + + return $this->success(['message' => 'Logged out successfully']); + } + + /** + * GET /api/v1/auth/me + * Return authenticated user with tenant info. + */ + public function me(Request $request): JsonResponse + { + $user = $request->user()->load('tenant'); + + return $this->success([ + 'id' => $user->id, + 'name' => $user->name, + 'email' => $user->email, + 'tenant_id' => $user->tenant_id, + 'tenant' => $user->tenant ? [ + 'id' => $user->tenant->id, + 'name' => $user->tenant->name, + 'slug' => $user->tenant->slug, + ] : null, + ]); + } +} diff --git a/erp/app/Http/Controllers/Api/V1/BatchPaymentApiController.php b/erp/app/Http/Controllers/Api/V1/BatchPaymentApiController.php new file mode 100644 index 00000000000..044e0a466eb --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/BatchPaymentApiController.php @@ -0,0 +1,113 @@ +tenantId($request); + $batches = BatchPayment::where('tenant_id', $tenantId) + ->withCount('payments') + ->when($request->input('type'), fn ($q, $t) => $q->where('type', $t)) + ->orderByDesc('payment_date') + ->get(); + + return $this->success($batches); + } + + public function store(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + + $data = $request->validate([ + 'payment_date' => ['required', 'date'], + 'payment_method' => ['required', 'in:bank_transfer,cheque,cash,card,other'], + 'type' => ['required', 'in:received,made'], + 'notes' => ['nullable', 'string'], + 'payments' => ['required', 'array', 'min:1'], + 'payments.*.invoice_id' => ['required', 'integer', 'exists:invoices,id'], + 'payments.*.amount' => ['required', 'numeric', 'min:0.01'], + 'payments.*.reference' => ['nullable', 'string', 'max:100'], + ]); + + $totalAmount = collect($data['payments'])->sum('amount'); + $ref = 'BATCH-' . strtoupper(uniqid()); + + $batch = DB::transaction(function () use ($tenantId, $data, $totalAmount, $ref) { + $batch = BatchPayment::create([ + 'tenant_id' => $tenantId, + 'reference' => $ref, + 'payment_date' => $data['payment_date'], + 'payment_method' => $data['payment_method'], + 'type' => $data['type'], + 'total_amount' => $totalAmount, + 'notes' => $data['notes'] ?? null, + ]); + + foreach ($data['payments'] as $paymentData) { + Payment::create([ + 'tenant_id' => $tenantId, + 'invoice_id' => $paymentData['invoice_id'], + 'batch_payment_id' => $batch->id, + 'amount' => $paymentData['amount'], + 'payment_date' => $data['payment_date'], + 'method' => $data['payment_method'], + 'reference' => $paymentData['reference'] ?? $ref, + 'notes' => $data['notes'] ?? null, + ]); + } + + return $batch; + }); + + return $this->success($batch->load('payments.invoice:id,number,contact_id'), 201); + } + + public function show(BatchPayment $batchPayment): JsonResponse + { + $batchPayment->load('payments.invoice:id,number,contact_id'); + + return $this->success($batchPayment); + } + + public function destroy(BatchPayment $batchPayment): JsonResponse + { + DB::transaction(function () use ($batchPayment) { + $batchPayment->payments()->delete(); + $batchPayment->delete(); + }); + + return $this->success(['message' => 'Batch payment deleted.']); + } + + public function summary(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + + $stats = BatchPayment::where('tenant_id', $tenantId) + ->selectRaw('type, COUNT(*) as batch_count, SUM(total_amount) as total') + ->groupBy('type') + ->get() + ->keyBy('type'); + + return $this->success([ + 'total_received' => (float) ($stats['received']->total ?? 0), + 'total_made' => (float) ($stats['made']->total ?? 0), + 'batch_count_received' => (int) ($stats['received']->batch_count ?? 0), + 'batch_count_made' => (int) ($stats['made']->batch_count ?? 0), + ]); + } + + private function tenantId(Request $request): int + { + return app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + } +} diff --git a/erp/app/Http/Controllers/Api/V1/BudgetApiController.php b/erp/app/Http/Controllers/Api/V1/BudgetApiController.php new file mode 100644 index 00000000000..a00614ee025 --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/BudgetApiController.php @@ -0,0 +1,94 @@ +tenantId($request); + + $budgets = Budget::where('tenant_id', $tenantId) + ->when($request->status, fn ($q) => $q->where('status', $request->status)) + ->when($request->year, fn ($q) => $q->where('fiscal_year', $request->year)) + ->withCount('lines') + ->orderByDesc('fiscal_year') + ->paginate(20); + + return $this->paginated($budgets); + } + + public function store(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + + $data = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'fiscal_year' => ['required', 'integer', 'min:2000'], + 'total_amount' => ['required', 'numeric', 'min:0'], + 'department' => ['nullable', 'string', 'max:255'], + 'notes' => ['nullable', 'string'], + 'start_date' => ['nullable', 'date'], + 'end_date' => ['nullable', 'date'], + ]); + + $budget = Budget::create([ + ...$data, + 'tenant_id' => $tenantId, + 'created_by' => $request->user()->id, + 'year' => $data['fiscal_year'], + 'status' => 'draft', + 'budget_type' => 'annual', + 'period_type' => 'annual', + ]); + + return $this->success($budget, 201); + } + + public function show(Request $request, Budget $budget): JsonResponse + { + return $this->success($budget->load('lines')); + } + + public function activate(Request $request, Budget $budget): JsonResponse + { + $budget->activate($request->user()->id); + return $this->success($budget->fresh()); + } + + public function close(Budget $budget): JsonResponse + { + $budget->close(); + return $this->success($budget->fresh()); + } + + public function destroy(Budget $budget): JsonResponse + { + $budget->delete(); + return $this->success(['message' => 'Budget deleted.']); + } + + public function variance(Request $request, Budget $budget): JsonResponse + { + $budget->recalculate(); + + return $this->success([ + 'budget' => $budget->fresh(), + 'total_budgeted' => $budget->total_amount, + 'total_actual' => $budget->spent_amount, + 'variance' => $budget->remaining_amount, + 'utilization_percent' => $budget->utilization_percent, + 'is_exceeded' => $budget->is_exceeded, + ]); + } + + private function tenantId(Request $request): int + { + return app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + } +} diff --git a/erp/app/Http/Controllers/Api/V1/BulkOperationController.php b/erp/app/Http/Controllers/Api/V1/BulkOperationController.php new file mode 100644 index 00000000000..7235503c509 --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/BulkOperationController.php @@ -0,0 +1,122 @@ + ['draft', 'sent', 'cancelled'], + 'contact' => [], + 'product' => [], + 'employee' => ['active', 'inactive', 'terminated'], + ]; + + public function updateStatus(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + + $data = $request->validate([ + 'model' => ['required', 'string', Rule::in(self::ALLOWED_MODELS)], + 'ids' => ['required', 'array', 'min:1', 'max:200'], + 'ids.*' => ['integer'], + 'status' => ['required', 'string'], + ]); + + $allowedStatuses = self::STATUS_MAP[$data['model']] ?? []; + if (! empty($allowedStatuses) && ! in_array($data['status'], $allowedStatuses)) { + return $this->error("Invalid status '{$data['status']}' for {$data['model']}.", 422); + } + + $model = $this->resolveModel($data['model']); + $updated = $model::where('tenant_id', $tenantId) + ->whereIn('id', $data['ids']) + ->update(['status' => $data['status']]); + + return $this->success(['updated' => $updated, 'model' => $data['model'], 'status' => $data['status']]); + } + + public function delete(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + + $data = $request->validate([ + 'model' => ['required', 'string', Rule::in(self::ALLOWED_MODELS)], + 'ids' => ['required', 'array', 'min:1', 'max:200'], + 'ids.*' => ['integer'], + ]); + + $model = $this->resolveModel($data['model']); + $deleted = $model::where('tenant_id', $tenantId) + ->whereIn('id', $data['ids']) + ->delete(); + + return $this->success(['deleted' => $deleted, 'model' => $data['model']]); + } + + public function assign(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + + $data = $request->validate([ + 'model' => ['required', 'string', Rule::in(['lead'])], + 'ids' => ['required', 'array', 'min:1', 'max:200'], + 'ids.*' => ['integer'], + 'assigned_to' => ['required', 'integer', 'exists:users,id'], + ]); + + $updated = \App\Modules\Finance\Models\Lead::where('tenant_id', $tenantId) + ->whereIn('id', $data['ids']) + ->update(['assigned_to' => $data['assigned_to']]); + + return $this->success(['updated' => $updated, 'assigned_to' => $data['assigned_to']]); + } + + public function export(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + + $data = $request->validate([ + 'model' => ['required', 'string', Rule::in(self::ALLOWED_MODELS)], + 'ids' => ['required', 'array', 'min:1', 'max:1000'], + 'ids.*' => ['integer'], + 'columns' => ['nullable', 'array'], + ]); + + $model = $this->resolveModel($data['model']); + $records = $model::where('tenant_id', $tenantId) + ->whereIn('id', $data['ids']) + ->get(); + + return $this->success([ + 'model' => $data['model'], + 'count' => $records->count(), + 'records' => $records->toArray(), + ]); + } + + private function resolveModel(string $model): string + { + return match ($model) { + 'invoice' => Invoice::class, + 'contact' => Contact::class, + 'product' => Product::class, + 'employee' => Employee::class, + default => throw new \InvalidArgumentException("Unknown model: {$model}"), + }; + } + + private function tenantId(Request $request): int + { + return app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + } +} diff --git a/erp/app/Http/Controllers/Api/V1/CalendarController.php b/erp/app/Http/Controllers/Api/V1/CalendarController.php new file mode 100644 index 00000000000..3f71be9ecfe --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/CalendarController.php @@ -0,0 +1,145 @@ +has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + $from = Carbon::parse($request->input('from', now()->startOfMonth())); + $to = Carbon::parse($request->input('to', now()->endOfMonth())); + $types = $request->input('types', ['tasks', 'leaves', 'events', 'invoices']); + + $events = collect(); + + if (in_array('tasks', $types)) { + $events = $events->merge($this->getTasks($tenantId, $from, $to)); + } + if (in_array('leaves', $types)) { + $events = $events->merge($this->getLeaves($tenantId, $from, $to)); + } + if (in_array('events', $types)) { + $events = $events->merge($this->getEvents($tenantId, $from, $to)); + } + if (in_array('invoices', $types)) { + $events = $events->merge($this->getInvoiceDueDates($tenantId, $from, $to)); + } + + $sorted = $events->sortBy('start')->values(); + + return $this->success([ + 'from' => $from->toDateString(), + 'to' => $to->toDateString(), + 'total' => $sorted->count(), + 'events' => $sorted, + ]); + } + + private function getTasks(int $tenantId, Carbon $from, Carbon $to): Collection + { + return Task::where('tenant_id', $tenantId) + ->where(function ($q) use ($from, $to) { + $q->whereBetween('due_date', [$from, $to]) + ->orWhereBetween('start_date', [$from, $to]); + }) + ->get() + ->map(fn ($task) => [ + 'id' => "task-{$task->id}", + 'type' => 'task', + 'title' => $task->title, + 'start' => $task->start_date?->toDateString() ?? $task->due_date?->toDateString(), + 'end' => $task->due_date?->toDateString(), + 'color' => $this->taskColor($task->status), + 'status' => $task->status, + 'priority' => $task->priority, + 'source_id' => $task->id, + 'source_url' => "/pm/tasks/{$task->id}", + ]); + } + + private function getLeaves(int $tenantId, Carbon $from, Carbon $to): Collection + { + return LeaveRequest::where('tenant_id', $tenantId) + ->whereIn('status', ['approved', 'pending']) + ->where(function ($q) use ($from, $to) { + $q->whereBetween('start_date', [$from, $to]) + ->orWhereBetween('end_date', [$from, $to]); + }) + ->with('employee:id,first_name,last_name') + ->get() + ->map(fn ($leave) => [ + 'id' => "leave-{$leave->id}", + 'type' => 'leave', + 'title' => ($leave->employee ? "{$leave->employee->first_name} {$leave->employee->last_name}" : 'Employee') . ' – Leave', + 'start' => $leave->start_date?->toDateString(), + 'end' => $leave->end_date?->toDateString(), + 'color' => $leave->status === 'approved' ? '#f59e0b' : '#6b7280', + 'status' => $leave->status, + 'source_id' => $leave->id, + 'source_url' => "/hr/leaves/{$leave->id}", + ]); + } + + private function getEvents(int $tenantId, Carbon $from, Carbon $to): Collection + { + return Event::where('tenant_id', $tenantId) + ->where(function ($q) use ($from, $to) { + $q->whereBetween('starts_at', [$from, $to]) + ->orWhereBetween('ends_at', [$from, $to]); + }) + ->get() + ->map(fn ($event) => [ + 'id' => "event-{$event->id}", + 'type' => 'event', + 'title' => $event->title, + 'start' => $event->starts_at?->toIso8601String(), + 'end' => $event->ends_at?->toIso8601String(), + 'color' => '#6366f1', + 'source_id' => $event->id, + 'source_url' => "/events/{$event->id}", + ]); + } + + private function getInvoiceDueDates(int $tenantId, Carbon $from, Carbon $to): Collection + { + return Invoice::where('tenant_id', $tenantId) + ->whereNotIn('status', ['paid', 'cancelled']) + ->whereBetween('due_date', [$from, $to]) + ->with('contact:id,name') + ->get() + ->map(fn ($inv) => [ + 'id' => "invoice-{$inv->id}", + 'type' => 'invoice_due', + 'title' => "Invoice {$inv->number} due", + 'start' => $inv->due_date?->toDateString(), + 'end' => $inv->due_date?->toDateString(), + 'color' => '#dc2626', + 'status' => $inv->status, + 'amount' => $inv->total, + 'source_id' => $inv->id, + 'source_url' => "/finance/invoices/{$inv->id}", + ]); + } + + private function taskColor(string $status): string + { + return match ($status) { + 'done', 'completed' => '#16a34a', + 'in_progress' => '#2563eb', + 'review' => '#9333ea', + default => '#6b7280', + }; + } +} diff --git a/erp/app/Http/Controllers/Api/V1/CommissionApiController.php b/erp/app/Http/Controllers/Api/V1/CommissionApiController.php new file mode 100644 index 00000000000..b3201a4fd28 --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/CommissionApiController.php @@ -0,0 +1,146 @@ +tenantId($request); + + $rules = CommissionRule::where('tenant_id', $tenantId) + ->with('user:id,name') + ->get(); + + return $this->success($rules); + } + + public function storeRule(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + + $data = $request->validate([ + 'user_id' => ['required', 'integer', 'exists:users,id'], + 'name' => ['required', 'string', 'max:100'], + 'type' => ['required', 'string', 'in:percentage,fixed'], + 'rate' => ['required_if:type,percentage', 'nullable', 'numeric', 'min:0', 'max:1'], + 'fixed_amount' => ['required_if:type,fixed', 'nullable', 'numeric', 'min:0'], + ]); + + $rule = CommissionRule::create([...$data, 'tenant_id' => $tenantId, 'is_active' => true]); + + return $this->success($rule->load('user:id,name'), 201); + } + + public function updateRule(Request $request, CommissionRule $commissionRule): JsonResponse + { + $data = $request->validate([ + 'name' => ['sometimes', 'string', 'max:100'], + 'rate' => ['nullable', 'numeric', 'min:0', 'max:1'], + 'fixed_amount' => ['nullable', 'numeric', 'min:0'], + 'is_active' => ['boolean'], + ]); + + $commissionRule->update($data); + + return $this->success($commissionRule->fresh()->load('user:id,name')); + } + + // ── Commissions ─────────────────────────────────────────────────────────── + + public function index(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + + $commissions = Commission::where('tenant_id', $tenantId) + ->when($request->user_id, fn ($q) => $q->where('user_id', $request->user_id)) + ->when($request->status, fn ($q) => $q->where('status', $request->status)) + ->with(['user:id,name', 'invoice:id,contact_id', 'rule:id,name']) + ->orderByDesc('created_at') + ->paginate(20); + + return $this->paginated($commissions); + } + + public function calculate(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + + $data = $request->validate([ + 'invoice_id' => ['required', 'integer', 'exists:invoices,id'], + 'commission_rule_id' => ['required', 'integer', 'exists:commission_rules,id'], + ]); + + $invoice = Invoice::where('tenant_id', $tenantId)->findOrFail($data['invoice_id']); + $rule = CommissionRule::where('tenant_id', $tenantId)->findOrFail($data['commission_rule_id']); + + $invoiceAmount = $invoice->total ?? 0; + $commissionAmount = $rule->calculateCommission((float) $invoiceAmount); + + $commission = Commission::create([ + 'tenant_id' => $tenantId, + 'commission_rule_id' => $rule->id, + 'user_id' => $rule->user_id, + 'invoice_id' => $invoice->id, + 'invoice_amount' => $invoiceAmount, + 'commission_amount' => $commissionAmount, + 'status' => 'pending', + ]); + + return $this->success($commission->load(['user:id,name', 'rule:id,name']), 201); + } + + public function approve(Commission $commission): JsonResponse + { + $commission->approve(); + return $this->success($commission->fresh()); + } + + public function markPaid(Commission $commission): JsonResponse + { + $commission->markPaid(); + return $this->success($commission->fresh()); + } + + public function summary(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + $from = $request->get('from', now()->startOfMonth()->toDateString()); + $to = $request->get('to', now()->toDateString()); + + $commissions = Commission::where('tenant_id', $tenantId) + ->whereBetween('created_at', [$from . ' 00:00:00', $to . ' 23:59:59']) + ->with('user:id,name') + ->get(); + + $byUser = $commissions->groupBy('user_id')->map(fn ($group) => [ + 'user_id' => $group->first()->user_id, + 'name' => $group->first()->user?->name, + 'total_commissions' => $group->count(), + 'pending_amount' => (float) $group->where('status', 'pending')->sum('commission_amount'), + 'approved_amount' => (float) $group->where('status', 'approved')->sum('commission_amount'), + 'paid_amount' => (float) $group->where('status', 'paid')->sum('commission_amount'), + 'total_amount' => (float) $group->sum('commission_amount'), + ])->sortByDesc('total_amount')->values(); + + return $this->success([ + 'period' => ['from' => $from, 'to' => $to], + 'total_pending' => round($commissions->where('status', 'pending')->sum('commission_amount'), 2), + 'total_paid' => round($commissions->where('status', 'paid')->sum('commission_amount'), 2), + 'by_user' => $byUser, + ]); + } + + private function tenantId(Request $request): int + { + return app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + } +} diff --git a/erp/app/Http/Controllers/Api/V1/ContractApiController.php b/erp/app/Http/Controllers/Api/V1/ContractApiController.php new file mode 100644 index 00000000000..12b0bfec536 --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/ContractApiController.php @@ -0,0 +1,153 @@ +tenantId($request); + + $contracts = Contract::where('tenant_id', $tenantId) + ->when($request->status, fn ($q) => $q->where('status', $request->status)) + ->when($request->type, fn ($q) => $q->where('type', $request->type)) + ->when($request->contact_id, fn ($q) => $q->where('contact_id', $request->contact_id)) + ->when($request->boolean('expiring_soon'), fn ($q) => $q->expiringSoon()) + ->with('contact:id,name') + ->orderByDesc('created_at') + ->paginate(20); + + return $this->paginated($contracts); + } + + public function store(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + + $data = $request->validate([ + 'contact_id' => ['nullable', 'integer', 'exists:contacts,id'], + 'title' => ['required', 'string', 'max:255'], + 'type' => ['required', 'string', 'in:client,vendor,employment,nda,other'], + 'value' => ['nullable', 'numeric', 'min:0'], + 'currency_code' => ['nullable', 'string', 'max:3'], + 'start_date' => ['nullable', 'date'], + 'end_date' => ['nullable', 'date', 'after_or_equal:start_date'], + 'auto_renew' => ['boolean'], + 'renewal_notice_days' => ['nullable', 'integer', 'min:1'], + 'description' => ['nullable', 'string'], + 'terms' => ['nullable', 'string'], + 'notes' => ['nullable', 'string'], + 'party_name' => ['nullable', 'string', 'max:255'], + 'party_email' => ['nullable', 'email'], + ]); + + $contract = Contract::create([ + ...$data, + 'tenant_id' => $tenantId, + 'contract_number' => Contract::generateContractNumber(), + 'status' => 'draft', + 'created_by' => $request->user()->id, + ]); + + return $this->success($contract, 201); + } + + public function show(Contract $contract): JsonResponse + { + return $this->success($contract->load(['contact:id,name', 'renewals', 'createdBy:id,name'])); + } + + public function update(Request $request, Contract $contract): JsonResponse + { + $data = $request->validate([ + 'title' => ['sometimes', 'string', 'max:255'], + 'type' => ['sometimes', 'string', 'in:client,vendor,employment,nda,other'], + 'value' => ['nullable', 'numeric', 'min:0'], + 'start_date' => ['nullable', 'date'], + 'end_date' => ['nullable', 'date'], + 'auto_renew' => ['boolean'], + 'renewal_notice_days' => ['nullable', 'integer', 'min:1'], + 'description' => ['nullable', 'string'], + 'terms' => ['nullable', 'string'], + 'notes' => ['nullable', 'string'], + ]); + + $contract->update($data); + + return $this->success($contract->fresh()); + } + + public function activate(Contract $contract): JsonResponse + { + $contract->activate(); + return $this->success($contract->fresh()); + } + + public function terminate(Request $request, Contract $contract): JsonResponse + { + $data = $request->validate([ + 'notes' => ['nullable', 'string', 'max:500'], + ]); + + $contract->terminate($data['notes'] ?? ''); + + return $this->success($contract->fresh()); + } + + public function renew(Request $request, Contract $contract): JsonResponse + { + $data = $request->validate([ + 'new_end_date' => ['required', 'date', 'after:today'], + 'new_value' => ['nullable', 'numeric', 'min:0'], + 'notes' => ['nullable', 'string', 'max:500'], + ]); + + $contract->renew( + $data['new_end_date'], + $data['new_value'] ?? null, + $data['notes'] ?? '', + $request->user()->id, + ); + + return $this->success($contract->fresh()->load('renewals')); + } + + public function expiringSoon(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + $days = (int) $request->get('days', 30); + + $contracts = Contract::where('tenant_id', $tenantId) + ->where('status', 'active') + ->whereBetween('end_date', [now(), now()->addDays($days)]) + ->with('contact:id,name') + ->orderBy('end_date') + ->get() + ->map(fn ($c) => [ + 'id' => $c->id, + 'contract_number' => $c->contract_number, + 'title' => $c->title, + 'contact_name' => $c->contact?->name, + 'end_date' => $c->end_date?->toDateString(), + 'days_remaining' => $c->days_remaining, + 'value' => $c->value, + ]); + + return $this->success(['days' => $days, 'contracts' => $contracts]); + } + + public function destroy(Contract $contract): JsonResponse + { + $contract->delete(); + return $this->success(['message' => 'Contract deleted.']); + } + + private function tenantId(Request $request): int + { + return app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + } +} diff --git a/erp/app/Http/Controllers/Api/V1/CreditLimitController.php b/erp/app/Http/Controllers/Api/V1/CreditLimitController.php new file mode 100644 index 00000000000..fc14185025f --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/CreditLimitController.php @@ -0,0 +1,61 @@ +success($this->service->getCreditStatus($contact)); + } + + public function update(Request $request, Contact $contact): JsonResponse + { + $data = $request->validate([ + 'credit_limit' => ['sometimes', 'numeric', 'min:0'], + 'credit_terms_days' => ['sometimes', 'integer', 'min:0', 'max:365'], + 'credit_hold' => ['sometimes', 'boolean'], + ]); + + $contact->update($data); + + return $this->success($this->service->getCreditStatus($contact->fresh())); + } + + public function check(Request $request, Contact $contact): JsonResponse + { + $data = $request->validate([ + 'amount' => ['required', 'numeric', 'min:0'], + ]); + + $would = $this->service->wouldExceedLimit($contact, $data['amount']); + $status = $this->service->getCreditStatus($contact); + $onHold = $contact->credit_hold; + + return $this->success([ + 'contact_id' => $contact->id, + 'amount_requested' => $data['amount'], + 'would_exceed' => $would, + 'on_hold' => $onHold, + 'approved' => ! $would && ! $onHold, + 'credit_status' => $status, + ]); + } + + public function alerts(Request $request): JsonResponse + { + $tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + $threshold = (float) $request->get('threshold', 80); + + $alerts = $this->service->getContactsNearLimit($tenantId, $threshold); + + return $this->success($alerts); + } +} diff --git a/erp/app/Http/Controllers/Api/V1/CreditNoteApiController.php b/erp/app/Http/Controllers/Api/V1/CreditNoteApiController.php new file mode 100644 index 00000000000..be3cd0eb67b --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/CreditNoteApiController.php @@ -0,0 +1,172 @@ +tenantId($request); + $notes = CreditNote::where('tenant_id', $tenantId) + ->with('contact:id,name') + ->when($request->input('status'), fn ($q, $s) => $q->where('status', $s)) + ->when($request->input('contact_id'), fn ($q, $c) => $q->where('contact_id', $c)) + ->orderByDesc('issue_date') + ->get(); + + return $this->success($notes); + } + + public function store(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + + $data = $request->validate([ + 'contact_id' => ['required', 'integer', 'exists:contacts,id'], + 'original_invoice_id' => ['nullable', 'integer', 'exists:invoices,id'], + 'reason' => ['required', 'string'], + 'issue_date' => ['required', 'date'], + 'currency_code' => ['nullable', 'string', 'max:3'], + 'notes' => ['nullable', 'string'], + 'items' => ['required', 'array', 'min:1'], + 'items.*.description' => ['required', 'string'], + 'items.*.quantity' => ['required', 'numeric', 'min:0.001'], + 'items.*.unit_price' => ['required', 'numeric', 'min:0'], + ]); + + $ref = CreditNote::generateCreditNoteNumber(); + $creditNote = CreditNote::create([ + 'tenant_id' => $tenantId, + 'reference' => $ref, + 'credit_note_number' => $ref, + 'contact_id' => $data['contact_id'], + 'original_invoice_id' => $data['original_invoice_id'] ?? null, + 'status' => 'draft', + 'issue_date' => $data['issue_date'], + 'reason' => $data['reason'], + 'notes' => $data['notes'] ?? null, + 'currency_code' => $data['currency_code'] ?? 'USD', + 'created_by' => $request->user()->id, + ]); + + foreach ($data['items'] as $item) { + CreditNoteItem::create([ + 'tenant_id' => $tenantId, + 'credit_note_id' => $creditNote->id, + 'description' => $item['description'], + 'quantity' => $item['quantity'], + 'unit_price' => $item['unit_price'], + ]); + } + + $creditNote->recalculateTotals(); + + return $this->success($creditNote->fresh()->load('items', 'contact:id,name'), 201); + } + + public function show(CreditNote $creditNote): JsonResponse + { + return $this->success($creditNote->load('items', 'contact:id,name', 'invoice')); + } + + public function update(Request $request, CreditNote $creditNote): JsonResponse + { + if (! $creditNote->is_open) { + return $this->error('Only draft or issued credit notes can be updated.', 422); + } + + $data = $request->validate([ + 'reason' => ['sometimes', 'string'], + 'notes' => ['nullable', 'string'], + 'issue_date' => ['sometimes', 'date'], + ]); + + $creditNote->update($data); + + return $this->success($creditNote->fresh()->load('items')); + } + + public function destroy(CreditNote $creditNote): JsonResponse + { + if ($creditNote->status === 'applied') { + return $this->error('Applied credit notes cannot be deleted.', 422); + } + + $creditNote->items()->delete(); + $creditNote->delete(); + + return $this->success(['message' => 'Credit note deleted.']); + } + + public function issue(CreditNote $creditNote): JsonResponse + { + if ($creditNote->status !== 'draft') { + return $this->error('Only draft credit notes can be issued.', 422); + } + + $creditNote->issue(); + + return $this->success($creditNote->fresh()); + } + + public function apply(CreditNote $creditNote): JsonResponse + { + if ($creditNote->status !== 'issued') { + return $this->error('Only issued credit notes can be applied.', 422); + } + + $creditNote->apply(); + + return $this->success($creditNote->fresh()); + } + + public function void(CreditNote $creditNote): JsonResponse + { + if ($creditNote->status === 'void') { + return $this->error('Credit note is already void.', 422); + } + + if ($creditNote->status === 'applied') { + return $this->error('Applied credit notes cannot be voided.', 422); + } + + $creditNote->void(); + + return $this->success($creditNote->fresh()); + } + + public function addItem(Request $request, CreditNote $creditNote): JsonResponse + { + $tenantId = $this->tenantId($request); + + if (! $creditNote->is_open) { + return $this->error('Cannot add items to a non-open credit note.', 422); + } + + $data = $request->validate([ + 'description' => ['required', 'string'], + 'quantity' => ['required', 'numeric', 'min:0.001'], + 'unit_price' => ['required', 'numeric', 'min:0'], + ]); + + $item = CreditNoteItem::create([ + 'tenant_id' => $tenantId, + 'credit_note_id' => $creditNote->id, + ...$data, + ]); + + $creditNote->recalculateTotals(); + + return $this->success($item, 201); + } + + private function tenantId(Request $request): int + { + return app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + } +} diff --git a/erp/app/Http/Controllers/Api/V1/CrmApiController.php b/erp/app/Http/Controllers/Api/V1/CrmApiController.php new file mode 100644 index 00000000000..973e51ec0dc --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/CrmApiController.php @@ -0,0 +1,141 @@ +query('type')) { + $query->where('type', $type); + } + + if ($status = $request->query('status')) { + $query->where('status', $status); + } + + if ($assignedTo = $request->query('assigned_to')) { + $query->where('assigned_to', $assignedTo); + } + + $paginator = $query->latest()->paginate(20); + + return $this->paginated($paginator); + } + + /** + * GET /api/v1/crm/leads/{id} + */ + public function show(int $id): JsonResponse + { + $lead = CrmLead::with(['stage', 'activities', 'assignee:id,name'])->findOrFail($id); + + return $this->success($lead); + } + + /** + * POST /api/v1/crm/leads + */ + public function store(Request $request): JsonResponse + { + $validated = $request->validate([ + 'title' => 'required|string|max:255', + 'type' => 'nullable|string|in:lead,opportunity', + 'contact_name' => 'nullable|string|max:255', + 'company_name' => 'nullable|string|max:255', + 'email' => 'nullable|email|max:255', + 'phone' => 'nullable|string|max:50', + 'source' => 'nullable|string|max:100', + 'expected_revenue' => 'nullable|numeric|min:0', + 'probability' => 'nullable|numeric|min:0|max:100', + 'priority' => 'nullable|string', + 'stage_id' => 'nullable|integer|exists:crm_stages,id', + 'assigned_to' => 'nullable|integer|exists:users,id', + 'description' => 'nullable|string', + ]); + + $tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $validated['tenant_id'] = $tenantId; + $validated['created_by'] = $request->user()->id; + + $lead = CrmLead::create($validated); + + return $this->success($lead, 201); + } + + /** + * PUT /api/v1/crm/leads/{id} + */ + public function update(Request $request, int $id): JsonResponse + { + $lead = CrmLead::findOrFail($id); + + $validated = $request->validate([ + 'title' => 'sometimes|string|max:255', + 'type' => 'nullable|string|in:lead,opportunity', + 'contact_name' => 'nullable|string|max:255', + 'company_name' => 'nullable|string|max:255', + 'email' => 'nullable|email|max:255', + 'phone' => 'nullable|string|max:50', + 'source' => 'nullable|string|max:100', + 'expected_revenue' => 'nullable|numeric|min:0', + 'probability' => 'nullable|numeric|min:0|max:100', + 'priority' => 'nullable|string', + 'stage_id' => 'nullable|integer|exists:crm_stages,id', + 'assigned_to' => 'nullable|integer|exists:users,id', + 'description' => 'nullable|string', + ]); + + $lead->update($validated); + + return $this->success($lead); + } + + /** + * DELETE /api/v1/crm/leads/{id} + */ + public function destroy(int $id): JsonResponse + { + $lead = CrmLead::findOrFail($id); + $lead->delete(); + + return $this->success(['message' => 'Lead deleted']); + } + + /** + * POST /api/v1/crm/leads/{lead}/won + */ + public function markWon(int $lead): JsonResponse + { + $crmLead = CrmLead::findOrFail($lead); + $crmLead->markWon(); + + return $this->success($crmLead); + } + + /** + * POST /api/v1/crm/leads/{lead}/lost + */ + public function markLost(Request $request, int $lead): JsonResponse + { + $crmLead = CrmLead::findOrFail($lead); + + $validated = $request->validate([ + 'reason' => 'nullable|string', + ]); + + $crmLead->markLost($validated['reason'] ?? ''); + + return $this->success($crmLead); + } +} diff --git a/erp/app/Http/Controllers/Api/V1/CrmPipelineController.php b/erp/app/Http/Controllers/Api/V1/CrmPipelineController.php new file mode 100644 index 00000000000..c844b3e1be3 --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/CrmPipelineController.php @@ -0,0 +1,133 @@ +tenantId($request); + + $stages = CrmStage::where('tenant_id', $tenantId) + ->where('is_active', true) + ->orderBy('sequence') + ->withCount(['leads as open_count' => fn ($q) => $q->where('status', 'open')]) + ->withCount(['leads as total_count']) + ->get() + ->map(function ($stage) use ($tenantId) { + $revenue = CrmLead::where('tenant_id', $tenantId) + ->where('stage_id', $stage->id) + ->where('status', 'open') + ->sum('expected_revenue'); + + return [ + 'stage_id' => $stage->id, + 'stage_name' => $stage->name, + 'sequence' => $stage->sequence, + 'open_deals' => $stage->open_count, + 'total_deals' => $stage->total_count, + 'expected_revenue' => round($revenue, 2), + 'probability' => $stage->probability, + 'weighted_value' => round($revenue * ($stage->probability / 100), 2), + ]; + }); + + $total = [ + 'pipeline_value' => $stages->sum('expected_revenue'), + 'weighted_value' => $stages->sum('weighted_value'), + 'open_deals' => $stages->sum('open_deals'), + ]; + + return $this->success(['stages' => $stages, 'total' => $total]); + } + + public function winRate(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + $from = $request->get('from', now()->subMonths(12)->toDateString()); + $to = $request->get('to', now()->toDateString()); + + $won = CrmLead::where('tenant_id', $tenantId)->where('status', 'won') + ->whereBetween('won_at', [$from . ' 00:00:00', $to . ' 23:59:59']) + ->count(); + $lost = CrmLead::where('tenant_id', $tenantId)->where('status', 'lost') + ->whereBetween('lost_at', [$from . ' 00:00:00', $to . ' 23:59:59']) + ->count(); + + $total = $won + $lost; + $rate = $total > 0 ? round($won / $total * 100, 2) : 0; + + $wonRevenue = CrmLead::where('tenant_id', $tenantId)->where('status', 'won') + ->whereBetween('won_at', [$from . ' 00:00:00', $to . ' 23:59:59']) + ->sum('expected_revenue'); + + return $this->success([ + 'won' => $won, + 'lost' => $lost, + 'total' => $total, + 'win_rate' => $rate, + 'won_revenue' => round($wonRevenue, 2), + 'period' => ['from' => $from, 'to' => $to], + ]); + } + + public function velocity(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + + $won = CrmLead::where('tenant_id', $tenantId) + ->where('status', 'won') + ->whereNotNull('won_at') + ->select(['created_at', 'won_at', 'expected_revenue']) + ->get(); + + $avgDays = $won->isNotEmpty() + ? $won->avg(fn ($l) => $l->created_at->diffInDays($l->won_at)) + : 0; + + $avgRevenue = $won->avg('expected_revenue') ?? 0; + + return $this->success([ + 'deals_analyzed' => $won->count(), + 'avg_days_to_close' => round($avgDays, 1), + 'avg_deal_value' => round($avgRevenue, 2), + 'deals_closed_per_month' => $won->count() > 0 + ? round($won->count() / max(1, now()->diffInMonths($won->min('won_at') ?: now())), 1) + : 0, + ]); + } + + public function leaderboard(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + + $leaders = CrmLead::where('tenant_id', $tenantId) + ->where('status', 'won') + ->whereNotNull('assigned_to') + ->select('assigned_to', DB::raw('COUNT(*) as deals_won'), DB::raw('SUM(expected_revenue) as revenue')) + ->groupBy('assigned_to') + ->orderByDesc('revenue') + ->with('assignee:id,name') + ->limit(10) + ->get() + ->map(fn ($row) => [ + 'user_id' => $row->assigned_to, + 'name' => $row->assignee?->name ?? 'Unknown', + 'deals_won' => $row->deals_won, + 'revenue' => round($row->revenue, 2), + ]); + + return $this->success($leaders); + } + + private function tenantId(Request $request): int + { + return app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + } +} diff --git a/erp/app/Http/Controllers/Api/V1/CurrencyApiController.php b/erp/app/Http/Controllers/Api/V1/CurrencyApiController.php new file mode 100644 index 00000000000..9f06242bd0f --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/CurrencyApiController.php @@ -0,0 +1,43 @@ +user()->tenant_id; + $currencies = Currency::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where('is_active', true) + ->orderBy('code') + ->get(); + return $this->success($currencies); + } + + public function convert(Request $request): JsonResponse { + $validated = $request->validate([ + 'amount' => 'required|numeric|min:0', + 'from' => 'required|string|size:3', + 'to' => 'required|string|size:3', + 'date' => 'nullable|date', + ]); + + $tenantId = auth()->user()->tenant_id; + $service = new CurrencyConversionService($tenantId); + $date = isset($validated['date']) ? \Carbon\Carbon::parse($validated['date']) : null; + $result = $service->convert((float) $validated['amount'], $validated['from'], $validated['to'], $date); + $rate = $service->getRate($validated['from'], $validated['to'], $date); + + return $this->success([ + 'from' => $validated['from'], + 'to' => $validated['to'], + 'amount' => $validated['amount'], + 'result' => $result, + 'rate' => $rate, + ]); + } +} diff --git a/erp/app/Http/Controllers/Api/V1/CustomFieldController.php b/erp/app/Http/Controllers/Api/V1/CustomFieldController.php new file mode 100644 index 00000000000..84f6278b8d8 --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/CustomFieldController.php @@ -0,0 +1,153 @@ +tenantId($request); + + $defs = CustomFieldDefinition::where('tenant_id', $tenantId) + ->when($request->model_type, fn ($q) => $q->where('model_type', $request->model_type)) + ->when($request->boolean('active_only', true), fn ($q) => $q->where('is_active', true)) + ->orderBy('model_type') + ->orderBy('sort_order') + ->get(); + + return $this->success($defs); + } + + public function storeDefinition(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + + $data = $request->validate([ + 'model_type' => ['required', 'string', Rule::in(self::ALLOWED_MODELS)], + 'field_name' => ['required', 'string', 'max:100'], + 'field_key' => ['required', 'string', 'max:100', 'regex:/^[a-z0-9_]+$/'], + 'field_type' => ['required', 'string', Rule::in(self::ALLOWED_TYPES)], + 'options' => ['nullable', 'array'], + 'options.*' => ['string', 'max:100'], + 'required' => ['boolean'], + 'sort_order' => ['integer', 'min:0'], + ]); + + $def = CustomFieldDefinition::create([ + ...$data, + 'tenant_id' => $tenantId, + ]); + + return $this->success($def, 201); + } + + public function updateDefinition(Request $request, CustomFieldDefinition $definition): JsonResponse + { + $data = $request->validate([ + 'field_name' => ['sometimes', 'string', 'max:100'], + 'field_type' => ['sometimes', 'string', Rule::in(self::ALLOWED_TYPES)], + 'options' => ['nullable', 'array'], + 'options.*' => ['string', 'max:100'], + 'required' => ['boolean'], + 'is_active' => ['boolean'], + 'sort_order' => ['integer', 'min:0'], + ]); + + $definition->update($data); + + return $this->success($definition->fresh()); + } + + public function destroyDefinition(CustomFieldDefinition $definition): JsonResponse + { + $definition->values()->delete(); + $definition->delete(); + + return $this->success(['message' => 'Custom field deleted.']); + } + + // ── Values ──────────────────────────────────────────────────────────────── + + public function getValues(Request $request, string $modelType, int $modelId): JsonResponse + { + $tenantId = $this->tenantId($request); + + $definitions = CustomFieldDefinition::where('tenant_id', $tenantId) + ->where('model_type', $modelType) + ->where('is_active', true) + ->orderBy('sort_order') + ->get(); + + $values = CustomFieldValue::where('tenant_id', $tenantId) + ->where('model_type', $modelType) + ->where('model_id', $modelId) + ->pluck('value', 'definition_id'); + + $result = $definitions->map(fn ($def) => [ + 'definition_id' => $def->id, + 'field_name' => $def->field_name, + 'field_key' => $def->field_key, + 'field_type' => $def->field_type, + 'options' => $def->options, + 'required' => $def->required, + 'value' => $values[$def->id] ?? null, + ]); + + return $this->success($result); + } + + public function setValues(Request $request, string $modelType, int $modelId): JsonResponse + { + $tenantId = $this->tenantId($request); + + $data = $request->validate([ + 'values' => ['required', 'array'], + 'values.*' => ['nullable', 'string', 'max:5000'], + ]); + + $definitions = CustomFieldDefinition::where('tenant_id', $tenantId) + ->where('model_type', $modelType) + ->where('is_active', true) + ->pluck('id') + ->flip(); + + $saved = []; + foreach ($data['values'] as $definitionId => $value) { + if (! $definitions->has((string) $definitionId)) { + continue; + } + + $record = CustomFieldValue::updateOrCreate( + [ + 'definition_id' => $definitionId, + 'model_type' => $modelType, + 'model_id' => $modelId, + ], + [ + 'tenant_id' => $tenantId, + 'value' => $value, + ] + ); + + $saved[] = $record; + } + + return $this->success(['saved' => count($saved), 'values' => $saved]); + } + + private function tenantId(Request $request): int + { + return app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + } +} diff --git a/erp/app/Http/Controllers/Api/V1/CustomerApiController.php b/erp/app/Http/Controllers/Api/V1/CustomerApiController.php new file mode 100644 index 00000000000..8ad797fc4b5 --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/CustomerApiController.php @@ -0,0 +1,97 @@ +query('search')) { + $query->where(function ($q) use ($search) { + $q->where('name', 'like', "%{$search}%") + ->orWhere('email', 'like', "%{$search}%"); + }); + } + + $paginator = $query->latest()->paginate(20); + + return $this->paginated($paginator); + } + + /** + * GET /api/v1/customers/{id} + */ + public function show(int $id): JsonResponse + { + $customer = Contact::customers()->with([ + 'invoices' => fn ($q) => $q->select('id', 'contact_id', 'number', 'status', 'issue_date', 'due_date')->latest()->limit(10), + ])->findOrFail($id); + + return $this->success($customer); + } + + /** + * POST /api/v1/customers + */ + public function store(Request $request): JsonResponse + { + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'email' => 'nullable|email|max:255', + 'phone' => 'nullable|string|max:50', + 'address' => 'nullable|string', + 'notes' => 'nullable|string', + ]); + + $tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $validated['tenant_id'] = $tenantId; + $validated['type'] = 'customer'; + $validated['is_active'] = true; + + $customer = Contact::create($validated); + + return $this->success($customer, 201); + } + + /** + * PUT /api/v1/customers/{id} + */ + public function update(Request $request, int $id): JsonResponse + { + $customer = Contact::customers()->findOrFail($id); + + $validated = $request->validate([ + 'name' => 'sometimes|string|max:255', + 'email' => 'nullable|email|max:255', + 'phone' => 'nullable|string|max:50', + 'address' => 'nullable|string', + 'notes' => 'nullable|string', + 'is_active' => 'nullable|boolean', + ]); + + $customer->update($validated); + + return $this->success($customer); + } + + /** + * DELETE /api/v1/customers/{id} + */ + public function destroy(int $id): JsonResponse + { + $customer = Contact::customers()->findOrFail($id); + $customer->delete(); + + return $this->success(['message' => 'Customer deleted']); + } +} diff --git a/erp/app/Http/Controllers/Api/V1/DashboardApiController.php b/erp/app/Http/Controllers/Api/V1/DashboardApiController.php new file mode 100644 index 00000000000..6f1c928e1ca --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/DashboardApiController.php @@ -0,0 +1,72 @@ +copy()->startOfMonth(); + $monthEnd = $now->copy()->endOfMonth(); + + // Total revenue from paid invoices this month + // total is a computed attribute (subtotal + tax), so sum via items join + $paidInvoiceIds = Invoice::where('status', 'paid') + ->whereBetween('issue_date', [$monthStart, $monthEnd]) + ->pluck('id'); + + $totalRevenue = 0.0; + if ($paidInvoiceIds->isNotEmpty()) { + $totalRevenue = \App\Modules\Finance\Models\InvoiceItem::whereIn('invoice_id', $paidInvoiceIds) + ->selectRaw('SUM(quantity * unit_price * (1 + COALESCE(tax_rate,0)/100)) as grand_total') + ->value('grand_total') ?? 0.0; + } + + // Open invoices + $openInvoiceIds = Invoice::whereIn('status', ['draft', 'sent', 'partial'])->pluck('id'); + $openInvoicesCount = $openInvoiceIds->count(); + $openInvoicesTotal = 0.0; + if ($openInvoiceIds->isNotEmpty()) { + $openInvoicesTotal = \App\Modules\Finance\Models\InvoiceItem::whereIn('invoice_id', $openInvoiceIds) + ->selectRaw('SUM(quantity * unit_price * (1 + COALESCE(tax_rate,0)/100)) as grand_total') + ->value('grand_total') ?? 0.0; + } + + // Open CRM leads + $openLeadsCount = CrmLead::where('status', 'open')->count(); + + // Open helpdesk tickets + $openTicketsCount = HelpdeskTicket::whereIn('status', ['open', 'in_progress'])->count(); + + // Low stock products (stock < reorder_point) + $lowStockCount = Product::where('is_active', true) + ->where('reorder_point', '>', 0) + ->whereColumn('stock_quantity', '<', 'reorder_point') + ->count(); + + // Active manufacturing orders + $activeMoCount = ManufacturingOrder::whereIn('status', ['confirmed', 'in_progress'])->count(); + + return $this->success([ + 'total_revenue' => (float) $totalRevenue, + 'open_invoices_count' => $openInvoicesCount, + 'open_invoices_total' => (float) $openInvoicesTotal, + 'open_leads_count' => $openLeadsCount, + 'open_tickets_count' => $openTicketsCount, + 'low_stock_products_count' => $lowStockCount, + 'active_manufacturing_orders_count' => $activeMoCount, + ]); + } +} diff --git a/erp/app/Http/Controllers/Api/V1/DashboardWidgetController.php b/erp/app/Http/Controllers/Api/V1/DashboardWidgetController.php new file mode 100644 index 00000000000..dab3427bdf0 --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/DashboardWidgetController.php @@ -0,0 +1,94 @@ +tenantId($request)) + ->where('user_id', $request->user()->id) + ->orderBy('position') + ->get(); + + return $this->success($widgets); + } + + public function store(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + + $data = $request->validate([ + 'widget_type' => ['required', Rule::in(DashboardWidget::$validTypes)], + 'title' => ['required', 'string', 'max:100'], + 'config' => ['nullable', 'array'], + 'position' => ['nullable', 'integer', 'min:0'], + 'size' => ['nullable', Rule::in(DashboardWidget::$validSizes)], + ]); + + // Shift existing positions if inserting at a specific spot + $position = $data['position'] ?? DashboardWidget::where('tenant_id', $tenantId) + ->where('user_id', $request->user()->id) + ->max('position') + 1 ?? 0; + + $widget = DashboardWidget::create([ + ...$data, + 'tenant_id' => $tenantId, + 'user_id' => $request->user()->id, + 'position' => $position, + 'size' => $data['size'] ?? 'md', + ]); + + return $this->success($widget, 201); + } + + public function update(Request $request, DashboardWidget $dashboardWidget): JsonResponse + { + $data = $request->validate([ + 'title' => ['sometimes', 'string', 'max:100'], + 'config' => ['nullable', 'array'], + 'position' => ['nullable', 'integer', 'min:0'], + 'size' => ['nullable', Rule::in(DashboardWidget::$validSizes)], + 'is_visible' => ['boolean'], + ]); + + $dashboardWidget->update($data); + + return $this->success($dashboardWidget->fresh()); + } + + public function reorder(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + + $data = $request->validate([ + 'order' => ['required', 'array'], + 'order.*' => ['integer'], + ]); + + foreach ($data['order'] as $pos => $id) { + DashboardWidget::where('id', $id) + ->where('tenant_id', $tenantId) + ->where('user_id', $request->user()->id) + ->update(['position' => $pos]); + } + + return $this->success(['message' => 'Widget order updated.']); + } + + public function destroy(DashboardWidget $dashboardWidget): JsonResponse + { + $dashboardWidget->delete(); + return $this->success(['message' => 'Widget removed.']); + } + + private function tenantId(Request $request): int + { + return app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + } +} diff --git a/erp/app/Http/Controllers/Api/V1/DebitNoteApiController.php b/erp/app/Http/Controllers/Api/V1/DebitNoteApiController.php new file mode 100644 index 00000000000..d310f3ebbe8 --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/DebitNoteApiController.php @@ -0,0 +1,148 @@ +tenantId($request); + $notes = DebitNote::where('tenant_id', $tenantId) + ->with('vendor:id,name') + ->when($request->input('status'), fn ($q, $s) => $q->where('status', $s)) + ->when($request->input('vendor_id'), fn ($q, $v) => $q->where('vendor_id', $v)) + ->orderByDesc('issue_date') + ->get(); + + return $this->success($notes); + } + + public function store(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + + $data = $request->validate([ + 'vendor_id' => ['required', 'integer', 'exists:contacts,id'], + 'vendor_bill_id' => ['nullable', 'integer', 'exists:bills,id'], + 'issue_date' => ['required', 'date'], + 'currency' => ['nullable', 'string', 'max:3'], + 'reason' => ['required', 'string'], + 'items' => ['required', 'array', 'min:1'], + 'items.*.description' => ['required', 'string'], + 'items.*.quantity' => ['required', 'numeric', 'min:0.001'], + 'items.*.unit_price' => ['required', 'numeric', 'min:0'], + 'items.*.tax_rate' => ['nullable', 'numeric', 'min:0', 'max:100'], + ]); + + $debitNote = DebitNote::create([ + 'tenant_id' => $tenantId, + 'vendor_id' => $data['vendor_id'], + 'vendor_bill_id' => $data['vendor_bill_id'] ?? null, + 'issue_date' => $data['issue_date'], + 'currency' => $data['currency'] ?? 'USD', + 'reason' => $data['reason'], + 'status' => 'draft', + 'created_by' => $request->user()->id, + ]); + + foreach ($data['items'] as $item) { + $qty = (float) $item['quantity']; + $price = (float) $item['unit_price']; + $tax = (float) ($item['tax_rate'] ?? 0); + + DebitNoteItem::create([ + 'tenant_id' => $tenantId, + 'debit_note_id' => $debitNote->id, + 'description' => $item['description'], + 'quantity' => $qty, + 'unit_price' => $price, + 'tax_rate' => $tax, + 'line_total' => round($qty * $price, 2), + ]); + } + + $debitNote->recalculateTotals(); + + return $this->success($debitNote->fresh()->load('items', 'vendor:id,name'), 201); + } + + public function show(DebitNote $debitNote): JsonResponse + { + return $this->success($debitNote->load('items', 'vendor:id,name')); + } + + public function update(Request $request, DebitNote $debitNote): JsonResponse + { + if (! $debitNote->is_open) { + return $this->error('Only draft or issued debit notes can be updated.', 422); + } + + $data = $request->validate([ + 'reason' => ['sometimes', 'string'], + 'issue_date' => ['sometimes', 'date'], + ]); + + $debitNote->update($data); + + return $this->success($debitNote->fresh()->load('items')); + } + + public function destroy(DebitNote $debitNote): JsonResponse + { + if ($debitNote->status === 'applied') { + return $this->error('Applied debit notes cannot be deleted.', 422); + } + + $debitNote->items()->delete(); + $debitNote->delete(); + + return $this->success(['message' => 'Debit note deleted.']); + } + + public function issue(DebitNote $debitNote): JsonResponse + { + if ($debitNote->status !== 'draft') { + return $this->error('Only draft debit notes can be issued.', 422); + } + + $debitNote->issue(); + + return $this->success($debitNote->fresh()); + } + + public function apply(DebitNote $debitNote): JsonResponse + { + if ($debitNote->status !== 'issued') { + return $this->error('Only issued debit notes can be applied.', 422); + } + + $debitNote->apply(); + + return $this->success($debitNote->fresh()); + } + + public function void(DebitNote $debitNote): JsonResponse + { + if ($debitNote->status === 'void') { + return $this->error('Debit note is already void.', 422); + } + + if ($debitNote->status === 'applied') { + return $this->error('Applied debit notes cannot be voided.', 422); + } + + $debitNote->void(); + + return $this->success($debitNote->fresh()); + } + + private function tenantId(Request $request): int + { + return app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + } +} diff --git a/erp/app/Http/Controllers/Api/V1/DiscussApiController.php b/erp/app/Http/Controllers/Api/V1/DiscussApiController.php new file mode 100644 index 00000000000..02c0bbdf971 --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/DiscussApiController.php @@ -0,0 +1,55 @@ +has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $paginator = DiscussChannel::where('tenant_id', $tenantId) + ->where('is_archived', false) + ->latest() + ->paginate(15); + + return $this->paginated($paginator); + } + + public function messages(Request $request): JsonResponse + { + $tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $query = DiscussMessage::where('tenant_id', $tenantId) + ->whereNull('parent_id'); + + if ($request->filled('channel_id')) { + $query->where('channel_id', $request->channel_id); + } + + return $this->paginated($query->latest()->paginate(25)); + } + + public function storeMessage(Request $request): JsonResponse + { + $tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $validated = $request->validate([ + 'channel_id' => 'required|integer|exists:discuss_channels,id', + 'body' => 'required|string', + 'parent_id' => 'nullable|integer|exists:discuss_messages,id', + ]); + + $validated['tenant_id'] = $tenantId; + $validated['user_id'] = $request->user()->id; + + $message = DiscussMessage::create($validated); + + return $this->success($message->load('user'), 201); + } +} diff --git a/erp/app/Http/Controllers/Api/V1/DocumentsApiController.php b/erp/app/Http/Controllers/Api/V1/DocumentsApiController.php new file mode 100644 index 00000000000..bef1c263bb3 --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/DocumentsApiController.php @@ -0,0 +1,67 @@ +has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $query = Document::where('tenant_id', $tenantId)->with('folder'); + + if ($request->filled('folder_id')) { + $query->where('folder_id', $request->folder_id); + } + + if ($request->filled('category')) { + $query->where('tags', 'like', '%' . $request->category . '%'); + } + + return $this->paginated($query->latest()->paginate(15)); + } + + public function show(int $id): JsonResponse + { + $document = Document::with(['folder', 'uploader', 'versions'])->findOrFail($id); + + return $this->success($document); + } + + public function store(Request $request): JsonResponse + { + $tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $validated = $request->validate([ + 'title' => 'required|string|max:255', + 'description' => 'nullable|string', + 'folder_id' => 'nullable|integer|exists:document_folders,id', + 'file_path' => 'required|string|max:500', + 'file_name' => 'required|string|max:255', + 'file_size' => 'nullable|integer', + 'mime_type' => 'nullable|string|max:100', + 'tags' => 'nullable|array', + 'tags.*' => 'string', + ]); + + $validated['tenant_id'] = $tenantId; + $validated['uploaded_by'] = $request->user()->id; + $validated['version'] = 1; + + $document = Document::create($validated); + + return $this->success($document, 201); + } + + public function destroy(int $id): JsonResponse + { + $document = Document::findOrFail($id); + $document->delete(); + + return $this->success(['deleted' => true]); + } +} diff --git a/erp/app/Http/Controllers/Api/V1/EcommerceApiController.php b/erp/app/Http/Controllers/Api/V1/EcommerceApiController.php new file mode 100644 index 00000000000..8583dc646e3 --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/EcommerceApiController.php @@ -0,0 +1,67 @@ +has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $query = StoreProduct::where('tenant_id', $tenantId); + + if ($request->filled('category_id')) { + $query->where('category_id', $request->category_id); + } + + if ($request->has('is_active')) { + $query->where('is_visible', filter_var($request->is_active, FILTER_VALIDATE_BOOLEAN)); + } + + return $this->paginated($query->latest()->paginate(15)); + } + + public function showStoreProduct(int $id): JsonResponse + { + $product = StoreProduct::with('category')->findOrFail($id); + + return $this->success($product); + } + + public function storeOrders(Request $request): JsonResponse + { + $tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $query = StoreOrder::where('tenant_id', $tenantId); + + if ($request->filled('status')) { + $query->where('status', $request->status); + } + + return $this->paginated($query->latest()->paginate(15)); + } + + public function showStoreOrder(int $id): JsonResponse + { + $order = StoreOrder::with('items')->findOrFail($id); + + return $this->success($order); + } + + public function categories(Request $request): JsonResponse + { + $tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $categories = StoreCategory::where('tenant_id', $tenantId) + ->orderBy('sort_order') + ->get(); + + return $this->success($categories); + } +} diff --git a/erp/app/Http/Controllers/Api/V1/EmailTemplateController.php b/erp/app/Http/Controllers/Api/V1/EmailTemplateController.php new file mode 100644 index 00000000000..de9b75658c0 --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/EmailTemplateController.php @@ -0,0 +1,92 @@ +tenantId($request); + + $templates = EmailTemplate::where('tenant_id', $tenantId)->get(); + + // Augment with defaults for any missing template keys + $existing = $templates->pluck('key')->all(); + $defaults = collect(EmailTemplate::$defaultTemplates) + ->filter(fn ($t, $key) => ! in_array($key, $existing)) + ->map(fn ($t, $key) => array_merge($t, ['key' => $key, 'is_active' => true, 'id' => null])); + + return $this->success([ + 'templates' => $templates, + 'defaults' => $defaults->values(), + ]); + } + + public function store(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + + $data = $request->validate([ + 'key' => [ + 'required', 'string', 'max:100', + Rule::in(array_keys(EmailTemplate::$defaultTemplates)), + Rule::unique('email_templates')->where('tenant_id', $tenantId), + ], + 'name' => ['required', 'string', 'max:255'], + 'subject' => ['required', 'string', 'max:500'], + 'body_html' => ['required', 'string'], + 'is_active' => ['boolean'], + ]); + + $template = EmailTemplate::create([ + ...$data, + 'tenant_id' => $tenantId, + 'variables' => EmailTemplate::$defaultTemplates[$data['key']]['variables'] ?? [], + ]); + + return $this->success($template, 201); + } + + public function show(Request $request, EmailTemplate $emailTemplate): JsonResponse + { + return $this->success($emailTemplate); + } + + public function update(Request $request, EmailTemplate $emailTemplate): JsonResponse + { + $data = $request->validate([ + 'name' => ['sometimes', 'string', 'max:255'], + 'subject' => ['sometimes', 'string', 'max:500'], + 'body_html' => ['sometimes', 'string'], + 'is_active' => ['boolean'], + ]); + + $emailTemplate->update($data); + + return $this->success($emailTemplate->fresh()); + } + + public function destroy(EmailTemplate $emailTemplate): JsonResponse + { + $emailTemplate->delete(); + return $this->success(['message' => 'Template deleted. Default will be used.']); + } + + public function preview(Request $request, EmailTemplate $emailTemplate): JsonResponse + { + $vars = $request->input('variables', []); + $rendered = $emailTemplate->render($vars); + + return $this->success($rendered); + } + + private function tenantId(Request $request): int + { + return app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + } +} diff --git a/erp/app/Http/Controllers/Api/V1/EmployeeLifecycleApiController.php b/erp/app/Http/Controllers/Api/V1/EmployeeLifecycleApiController.php new file mode 100644 index 00000000000..6f8c0526086 --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/EmployeeLifecycleApiController.php @@ -0,0 +1,222 @@ +has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + } + + // ── Onboarding Checklists ───────────────────────────────────────────────── + + public function indexChecklists(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + $checklists = OnboardingChecklist::where('tenant_id', $tenantId) + ->withCount('tasks') + ->when($request->boolean('active_only'), fn ($q) => $q->where('is_active', true)) + ->when($request->input('department'), fn ($q, $d) => $q->where('department', $d)) + ->orderBy('name') + ->get(); + + return $this->success($checklists); + } + + public function storeChecklist(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + + $data = $request->validate([ + 'name' => ['required', 'string', 'max:100'], + 'department' => ['nullable', 'string', 'max:100'], + 'description' => ['nullable', 'string'], + 'tasks' => ['nullable', 'array'], + 'tasks.*.title' => ['required_with:tasks', 'string'], + 'tasks.*.category' => ['nullable', 'string', 'max:50'], + 'tasks.*.due_day_offset' => ['nullable', 'integer', 'min:0'], + 'tasks.*.is_required' => ['boolean'], + 'tasks.*.sort_order' => ['nullable', 'integer', 'min:1'], + ]); + + $checklist = OnboardingChecklist::create([ + 'tenant_id' => $tenantId, + 'name' => $data['name'], + 'department' => $data['department'] ?? null, + 'description' => $data['description'] ?? null, + 'is_active' => true, + ]); + + foreach ($data['tasks'] ?? [] as $i => $task) { + OnboardingTask::create([ + 'tenant_id' => $tenantId, + 'onboarding_checklist_id'=> $checklist->id, + 'title' => $task['title'], + 'category' => $task['category'] ?? null, + 'due_day_offset' => $task['due_day_offset'] ?? 0, + 'is_required' => $task['is_required'] ?? true, + 'sort_order' => $task['sort_order'] ?? ($i + 1), + ]); + } + + return $this->success($checklist->load('tasks'), 201); + } + + public function showChecklist(OnboardingChecklist $onboardingChecklist): JsonResponse + { + return $this->success($onboardingChecklist->load('tasks')); + } + + public function destroyChecklist(OnboardingChecklist $onboardingChecklist): JsonResponse + { + $onboardingChecklist->delete(); + + return $this->success(['message' => 'Checklist deleted.']); + } + + // ── Position Changes ────────────────────────────────────────────────────── + + public function indexPositionChanges(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + $changes = EmployeePositionChange::where('tenant_id', $tenantId) + ->with('employee:id,first_name,last_name') + ->when($request->input('employee_id'), fn ($q, $e) => $q->where('employee_id', $e)) + ->when($request->input('change_type'), fn ($q, $t) => $q->where('change_type', $t)) + ->orderByDesc('effective_date') + ->get(); + + return $this->success($changes); + } + + public function storePositionChange(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + + $data = $request->validate([ + 'employee_id' => ['required', 'integer', 'exists:employees,id'], + 'change_type' => ['required', 'in:promotion,demotion,transfer,salary_change,title_change,department_change'], + 'from_title' => ['nullable', 'string', 'max:150'], + 'to_title' => ['nullable', 'string', 'max:150'], + 'from_department_id' => ['nullable', 'integer', 'exists:departments,id'], + 'to_department_id' => ['nullable', 'integer', 'exists:departments,id'], + 'from_salary' => ['nullable', 'numeric', 'min:0'], + 'to_salary' => ['nullable', 'numeric', 'min:0'], + 'effective_date' => ['required', 'date'], + 'reason' => ['nullable', 'string'], + 'notes' => ['nullable', 'string'], + ]); + + $change = EmployeePositionChange::create([ + 'tenant_id' => $tenantId, + 'employee_id' => $data['employee_id'], + 'change_type' => $data['change_type'], + 'from_title' => $data['from_title'] ?? null, + 'to_title' => $data['to_title'] ?? null, + 'from_department_id' => $data['from_department_id'] ?? null, + 'to_department_id' => $data['to_department_id'] ?? null, + 'from_salary' => $data['from_salary'] ?? null, + 'to_salary' => $data['to_salary'] ?? null, + 'effective_date' => $data['effective_date'], + 'reason' => $data['reason'] ?? null, + 'notes' => $data['notes'] ?? null, + ]); + + return $this->success($change->load('employee:id,first_name,last_name'), 201); + } + + public function approvePositionChange(Request $request, EmployeePositionChange $employeePositionChange): JsonResponse + { + if ($employeePositionChange->approved_by !== null) { + return $this->error('This position change has already been approved.', 422); + } + + $employeePositionChange->approve($request->user()->id); + + return $this->success($employeePositionChange->fresh()->load('approvedBy:id,name')); + } + + // ── Employee Exits ──────────────────────────────────────────────────────── + + public function indexExits(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + $exits = EmployeeExit::where('tenant_id', $tenantId) + ->with('employee:id,first_name,last_name') + ->when($request->input('status'), fn ($q, $s) => $q->where('status', $s)) + ->when($request->input('exit_type'), fn ($q, $t) => $q->where('exit_type', $t)) + ->orderByDesc('exit_date') + ->get(); + + return $this->success($exits); + } + + public function storeExit(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + + $data = $request->validate([ + 'employee_id' => ['required', 'integer', 'exists:employees,id'], + 'exit_date' => ['required', 'date'], + 'exit_type' => ['required', 'in:resignation,termination,retirement,redundancy,contract_end'], + 'reason' => ['nullable', 'string'], + 'exit_interview_notes' => ['nullable', 'string'], + ]); + + $exit = EmployeeExit::create([ + 'tenant_id' => $tenantId, + 'employee_id' => $data['employee_id'], + 'exit_date' => $data['exit_date'], + 'exit_type' => $data['exit_type'], + 'reason' => $data['reason'] ?? null, + 'exit_interview_notes' => $data['exit_interview_notes'] ?? null, + 'status' => 'pending', + ]); + + return $this->success($exit->load('employee:id,first_name,last_name'), 201); + } + + public function markExitInProgress(EmployeeExit $employeeExit): JsonResponse + { + if ($employeeExit->status !== 'pending') { + return $this->error('Only pending exits can be marked in progress.', 422); + } + + $employeeExit->markInProgress(); + + return $this->success($employeeExit->fresh()); + } + + public function completeExit(Request $request, EmployeeExit $employeeExit): JsonResponse + { + if ($employeeExit->status !== 'in_progress') { + return $this->error('Only in-progress exits can be completed.', 422); + } + + $data = $request->validate([ + 'equipment_returned' => ['boolean'], + 'access_revoked' => ['boolean'], + ]); + + if (isset($data['equipment_returned'])) { + $employeeExit->equipment_returned = $data['equipment_returned']; + } + if (isset($data['access_revoked'])) { + $employeeExit->access_revoked = $data['access_revoked']; + } + $employeeExit->save(); + + $employeeExit->complete($request->user()->id); + + return $this->success($employeeExit->fresh()->load('processedBy:id,name')); + } +} diff --git a/erp/app/Http/Controllers/Api/V1/EmployeeLoanBenefitApiController.php b/erp/app/Http/Controllers/Api/V1/EmployeeLoanBenefitApiController.php new file mode 100644 index 00000000000..38a0c36ee22 --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/EmployeeLoanBenefitApiController.php @@ -0,0 +1,199 @@ +has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + } + + // ── Employee Loans ──────────────────────────────────────────────────────── + + public function indexLoans(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + $loans = EmployeeLoan::where('tenant_id', $tenantId) + ->with('employee:id,first_name,last_name') + ->withCount('repayments') + ->when($request->input('status'), fn ($q, $s) => $q->where('status', $s)) + ->when($request->input('employee_id'), fn ($q, $e) => $q->where('employee_id', $e)) + ->orderByDesc('created_at') + ->get(); + + return $this->success($loans); + } + + public function storeLoan(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + + $data = $request->validate([ + 'employee_id' => ['required', 'integer', 'exists:employees,id'], + 'type' => ['required', 'in:loan,advance'], + 'amount' => ['required', 'numeric', 'min:1'], + 'interest_rate' => ['nullable', 'numeric', 'min:0'], + 'purpose' => ['nullable', 'string', 'max:200'], + 'repayment_start_date' => ['nullable', 'date'], + 'notes' => ['nullable', 'string'], + ]); + + $loan = EmployeeLoan::create([ + 'tenant_id' => $tenantId, + 'employee_id' => $data['employee_id'], + 'type' => $data['type'], + 'amount' => $data['amount'], + 'outstanding_balance' => $data['amount'], + 'interest_rate' => $data['interest_rate'] ?? 0, + 'status' => 'pending', + 'purpose' => $data['purpose'] ?? null, + 'repayment_start_date' => $data['repayment_start_date'] ?? null, + 'notes' => $data['notes'] ?? null, + ]); + + return $this->success($loan->load('employee:id,first_name,last_name'), 201); + } + + public function showLoan(EmployeeLoan $employeeLoan): JsonResponse + { + $employeeLoan->load('employee:id,first_name,last_name', 'repayments', 'approver:id,name'); + + return $this->success($employeeLoan); + } + + public function approveLoan(Request $request, EmployeeLoan $employeeLoan): JsonResponse + { + if ($employeeLoan->status !== 'pending') { + return $this->error('Only pending loans can be approved.', 422); + } + + $employeeLoan->approve($request->user()); + + return $this->success($employeeLoan->fresh()->load('approver:id,name')); + } + + public function cancelLoan(EmployeeLoan $employeeLoan): JsonResponse + { + if (!in_array($employeeLoan->status, ['pending'])) { + return $this->error('Only pending loans can be cancelled.', 422); + } + + $employeeLoan->cancel(); + + return $this->success($employeeLoan->fresh()); + } + + public function recordRepayment(Request $request, EmployeeLoan $employeeLoan): JsonResponse + { + $tenantId = $this->tenantId($request); + + if ($employeeLoan->status !== 'active') { + return $this->error('Repayments can only be recorded for active loans.', 422); + } + + $data = $request->validate([ + 'amount' => ['required', 'numeric', 'min:0.01'], + 'payment_date' => ['required', 'date'], + 'notes' => ['nullable', 'string'], + ]); + + $repayment = LoanRepayment::create([ + 'tenant_id' => $tenantId, + 'employee_loan_id'=> $employeeLoan->id, + 'amount' => $data['amount'], + 'payment_date' => $data['payment_date'], + 'notes' => $data['notes'] ?? null, + ]); + + $newBalance = max(0, (float) $employeeLoan->outstanding_balance - (float) $data['amount']); + $employeeLoan->outstanding_balance = $newBalance; + if ($newBalance <= 0) { + $employeeLoan->status = 'completed'; + } + $employeeLoan->save(); + + return $this->success([ + 'repayment' => $repayment, + 'outstanding_balance' => $employeeLoan->fresh()->outstanding_balance, + 'status' => $employeeLoan->fresh()->status, + ]); + } + + // ── Benefit Plans ───────────────────────────────────────────────────────── + + public function indexBenefitPlans(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + $plans = BenefitPlan::where('tenant_id', $tenantId) + ->withCount('enrollments') + ->when($request->input('type'), fn ($q, $t) => $q->where('type', $t)) + ->when($request->boolean('active_only'), fn ($q) => $q->where('is_active', true)) + ->orderBy('name') + ->get() + ->map(fn ($p) => array_merge($p->toArray(), ['total_cost' => $p->total_cost])); + + return $this->success($plans); + } + + public function storeBenefitPlan(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + + $data = $request->validate([ + 'name' => ['required', 'string', 'max:100'], + 'type' => ['required', 'in:health,dental,vision,life,retirement,other'], + 'description' => ['nullable', 'string'], + 'employee_cost' => ['nullable', 'numeric', 'min:0'], + 'employer_cost' => ['nullable', 'numeric', 'min:0'], + ]); + + $plan = BenefitPlan::create([ + 'tenant_id' => $tenantId, + 'name' => $data['name'], + 'type' => $data['type'], + 'description' => $data['description'] ?? null, + 'employee_cost' => $data['employee_cost'] ?? 0, + 'employer_cost' => $data['employer_cost'] ?? 0, + 'is_active' => true, + ]); + + return $this->success($plan, 201); + } + + public function enrollEmployee(Request $request, BenefitPlan $benefitPlan): JsonResponse + { + $tenantId = $this->tenantId($request); + + $data = $request->validate([ + 'employee_id' => ['required', 'integer', 'exists:employees,id'], + 'enrolled_at' => ['nullable', 'date'], + 'notes' => ['nullable', 'string'], + ]); + + $enrollment = EmployeeBenefit::create([ + 'tenant_id' => $tenantId, + 'benefit_plan_id' => $benefitPlan->id, + 'employee_id' => $data['employee_id'], + 'enrolled_at' => $data['enrolled_at'] ?? now()->toDateString(), + 'status' => 'active', + 'notes' => $data['notes'] ?? null, + ]); + + return $this->success($enrollment->load('employee:id,first_name,last_name', 'plan:id,name,type'), 201); + } + + public function endEnrollment(BenefitPlan $benefitPlan, EmployeeBenefit $employeeBenefit): JsonResponse + { + $employeeBenefit->end(); + + return $this->success($employeeBenefit->fresh()); + } +} diff --git a/erp/app/Http/Controllers/Api/V1/EventsApiController.php b/erp/app/Http/Controllers/Api/V1/EventsApiController.php new file mode 100644 index 00000000000..dcf8ccc4ee6 --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/EventsApiController.php @@ -0,0 +1,77 @@ +has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $query = Event::where('tenant_id', $tenantId); + + if ($request->filled('status')) { + $query->where('status', $request->status); + } + + if ($request->filled('date_from')) { + $query->where('starts_at', '>=', $request->date_from); + } + + return $this->paginated($query->orderBy('starts_at')->paginate(15)); + } + + public function show(int $id): JsonResponse + { + $event = Event::withCount('registrations')->with('organizer')->findOrFail($id); + + return $this->success($event); + } + + public function store(Request $request): JsonResponse + { + $tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $validated = $request->validate([ + 'title' => 'required|string|max:255', + 'description' => 'nullable|string', + 'location' => 'nullable|string|max:255', + 'starts_at' => 'required|date', + 'ends_at' => 'required|date|after:starts_at', + 'capacity' => 'nullable|integer|min:1', + 'status' => 'nullable|string', + 'organizer_id' => 'nullable|integer|exists:users,id', + ]); + + $validated['tenant_id'] = $tenantId; + + $event = Event::create($validated); + + return $this->success($event, 201); + } + + public function register(Request $request, int $id): JsonResponse + { + $tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $event = Event::findOrFail($id); + + $user = $request->user(); + + $registration = EventRegistration::create([ + 'event_id' => $event->id, + 'tenant_id' => $tenantId, + 'attendee_name' => $user->name, + 'attendee_email' => $user->email, + 'status' => 'confirmed', + 'registered_at' => now(), + ]); + + return $this->success($registration, 201); + } +} diff --git a/erp/app/Http/Controllers/Api/V1/ExpenseClaimApiController.php b/erp/app/Http/Controllers/Api/V1/ExpenseClaimApiController.php new file mode 100644 index 00000000000..b784ae6b431 --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/ExpenseClaimApiController.php @@ -0,0 +1,108 @@ +tenantId($request); + + $claims = ExpenseClaim::where('tenant_id', $tenantId) + ->when($request->status, fn ($q) => $q->where('status', $request->status)) + ->when($request->employee_id, fn ($q) => $q->where('employee_id', $request->employee_id)) + ->with(['employee:id,first_name,last_name']) + ->orderByDesc('created_at') + ->paginate(20); + + return $this->paginated($claims); + } + + public function store(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + + $data = $request->validate([ + 'employee_id' => ['required', 'exists:employees,id'], + 'title' => ['required', 'string', 'max:255'], + 'description' => ['nullable', 'string'], + 'notes' => ['nullable', 'string'], + 'items' => ['required', 'array', 'min:1'], + 'items.*.category' => ['required', 'string', 'max:50'], + 'items.*.description' => ['required', 'string'], + 'items.*.amount' => ['required', 'numeric', 'min:0.01'], + 'items.*.expense_date' => ['required', 'date'], + ]); + + $claim = ExpenseClaim::create([ + 'tenant_id' => $tenantId, + 'employee_id' => $data['employee_id'], + 'title' => $data['title'], + 'description' => $data['description'] ?? null, + 'notes' => $data['notes'] ?? null, + 'status' => 'draft', + 'total_amount' => 0, + ]); + + foreach ($data['items'] as $item) { + ExpenseClaimItem::create([ + 'tenant_id' => $tenantId, + 'expense_claim_id' => $claim->id, + ...$item, + ]); + } + + $claim->recalculateTotal(); + + return $this->success($claim->load('items'), 201); + } + + public function show(ExpenseClaim $expenseClaim): JsonResponse + { + return $this->success($expenseClaim->load(['items', 'employee:id,first_name,last_name', 'approvedBy:id,name'])); + } + + public function submit(ExpenseClaim $expenseClaim): JsonResponse + { + $expenseClaim->submit(); + return $this->success($expenseClaim->fresh()); + } + + public function approve(Request $request, ExpenseClaim $expenseClaim): JsonResponse + { + $expenseClaim->approve($request->user()); + return $this->success($expenseClaim->fresh()); + } + + public function reject(Request $request, ExpenseClaim $expenseClaim): JsonResponse + { + $data = $request->validate([ + 'reason' => ['required', 'string', 'max:500'], + ]); + + $expenseClaim->reject($data['reason']); + return $this->success($expenseClaim->fresh()); + } + + public function markPaid(ExpenseClaim $expenseClaim): JsonResponse + { + $expenseClaim->markPaid(); + return $this->success($expenseClaim->fresh()); + } + + public function destroy(ExpenseClaim $expenseClaim): JsonResponse + { + $expenseClaim->delete(); + return $this->success(['message' => 'Expense claim deleted.']); + } + + private function tenantId(Request $request): int + { + return app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + } +} diff --git a/erp/app/Http/Controllers/Api/V1/FieldServiceApiController.php b/erp/app/Http/Controllers/Api/V1/FieldServiceApiController.php new file mode 100644 index 00000000000..c21380e00f7 --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/FieldServiceApiController.php @@ -0,0 +1,86 @@ +has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $query = ServiceOrder::where('tenant_id', $tenantId); + + if ($request->filled('status')) { + $query->where('status', $request->status); + } + + return $this->paginated($query->latest()->paginate(15)); + } + + public function showTask(int $id): JsonResponse + { + $task = ServiceOrder::with(['technician', 'creator', 'items'])->findOrFail($id); + + return $this->success($task); + } + + public function storeTask(Request $request): JsonResponse + { + $tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $validated = $request->validate([ + 'title' => 'required|string|max:255', + 'description' => 'nullable|string', + 'type' => 'nullable|string|max:100', + 'priority' => 'nullable|string|max:50', + 'status' => 'nullable|string|max:50', + 'customer_name' => 'nullable|string|max:255', + 'customer_email' => 'nullable|email|max:255', + 'customer_phone' => 'nullable|string|max:50', + 'address' => 'nullable|string', + 'scheduled_at' => 'nullable|date', + 'estimated_duration' => 'nullable|integer', + 'assigned_to' => 'nullable|integer|exists:users,id', + 'notes' => 'nullable|string', + ]); + + $validated['tenant_id'] = $tenantId; + $validated['created_by'] = $request->user()->id; + + $task = ServiceOrder::create($validated); + + return $this->success($task, 201); + } + + public function updateTask(Request $request, int $id): JsonResponse + { + $task = ServiceOrder::findOrFail($id); + + $validated = $request->validate([ + 'title' => 'sometimes|required|string|max:255', + 'description' => 'nullable|string', + 'type' => 'nullable|string|max:100', + 'priority' => 'nullable|string|max:50', + 'status' => 'nullable|string|max:50', + 'customer_name' => 'nullable|string|max:255', + 'customer_email' => 'nullable|email|max:255', + 'customer_phone' => 'nullable|string|max:50', + 'address' => 'nullable|string', + 'scheduled_at' => 'nullable|date', + 'started_at' => 'nullable|date', + 'completed_at' => 'nullable|date', + 'estimated_duration' => 'nullable|integer', + 'actual_duration' => 'nullable|integer', + 'assigned_to' => 'nullable|integer|exists:users,id', + 'notes' => 'nullable|string', + ]); + + $task->update($validated); + + return $this->success($task->fresh(['technician', 'creator', 'items'])); + } +} diff --git a/erp/app/Http/Controllers/Api/V1/FinanceApiController.php b/erp/app/Http/Controllers/Api/V1/FinanceApiController.php new file mode 100644 index 00000000000..606e108a44d --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/FinanceApiController.php @@ -0,0 +1,134 @@ +query('status')) { + $query->where('status', $status); + } + + if ($contactId = $request->query('contact_id')) { + $query->where('contact_id', $contactId); + } + + $paginator = $query->latest()->paginate(20); + + return $this->paginated($paginator); + } + + /** + * GET /api/v1/finance/bills/{id} + */ + public function show(int $id): JsonResponse + { + $bill = Bill::with(['items', 'contact'])->findOrFail($id); + + return $this->success($bill); + } + + /** + * POST /api/v1/finance/bills + */ + public function store(Request $request): JsonResponse + { + $validated = $request->validate([ + 'contact_id' => 'required|integer|exists:contacts,id', + 'issue_date' => 'required|date', + 'due_date' => 'nullable|date', + 'notes' => 'nullable|string', + ]); + + $tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $validated['tenant_id'] = $tenantId; + + $bill = Bill::create($validated); + + return $this->success($bill, 201); + } + + /** + * PUT /api/v1/finance/bills/{id} + */ + public function update(Request $request, int $id): JsonResponse + { + $bill = Bill::findOrFail($id); + + $validated = $request->validate([ + 'contact_id' => 'nullable|integer|exists:contacts,id', + 'bill_date' => 'nullable|date', + 'due_date' => 'nullable|date', + 'notes' => 'nullable|string', + ]); + + $bill->update($validated); + + return $this->success($bill); + } + + /** + * DELETE /api/v1/finance/bills/{id} + */ + public function destroy(int $id): JsonResponse + { + $bill = Bill::findOrFail($id); + + if ($bill->status === 'paid') { + return $this->error('Cannot delete a paid bill.', 422); + } + + $bill->delete(); + + return $this->success(['message' => 'Bill deleted']); + } + + /** + * GET /api/v1/finance/contacts + */ + public function contacts(Request $request): JsonResponse + { + $query = Contact::query(); + + if ($type = $request->query('type')) { + $query->where('type', $type); + } + + $paginator = $query->latest()->paginate(20); + + return $this->paginated($paginator); + } + + /** + * POST /api/v1/finance/contacts + */ + public function storeContact(Request $request): JsonResponse + { + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'email' => 'nullable|email|max:255', + 'phone' => 'nullable|string|max:50', + 'type' => 'nullable|string|in:customer,supplier,both', + ]); + + $tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $validated['tenant_id'] = $tenantId; + + $contact = Contact::create($validated); + + return $this->success($contact, 201); + } +} diff --git a/erp/app/Http/Controllers/Api/V1/FixedAssetApiController.php b/erp/app/Http/Controllers/Api/V1/FixedAssetApiController.php new file mode 100644 index 00000000000..15adf213be5 --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/FixedAssetApiController.php @@ -0,0 +1,185 @@ +tenantId($request); + $assets = FixedAsset::where('tenant_id', $tenantId) + ->when($request->input('status'), fn ($q, $s) => $q->where('status', $s)) + ->when($request->input('category'), fn ($q, $c) => $q->where('category', $c)) + ->orderByDesc('purchase_date') + ->get() + ->map(fn ($a) => array_merge($a->toArray(), [ + 'net_book_value' => $a->net_book_value, + 'annual_depreciation' => $a->annual_depreciation, + ])); + + return $this->success($assets); + } + + public function store(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + + $data = $request->validate([ + 'name' => ['required', 'string', 'max:100'], + 'code' => ['required', 'string', 'max:50'], + 'category' => ['required', 'string', 'max:50'], + 'description' => ['nullable', 'string'], + 'purchase_date' => ['required', 'date'], + 'purchase_cost' => ['required', 'numeric', 'min:0.01'], + 'salvage_value' => ['nullable', 'numeric', 'min:0'], + 'useful_life_years' => ['required', 'integer', 'min:1'], + ]); + + $asset = FixedAsset::create([ + ...$data, + 'tenant_id' => $tenantId, + 'salvage_value' => $data['salvage_value'] ?? 0, + 'accumulated_depreciation' => 0, + 'status' => 'active', + 'created_by' => $request->user()->id, + ]); + + return $this->success(array_merge($asset->toArray(), [ + 'net_book_value' => $asset->net_book_value, + 'annual_depreciation' => $asset->annual_depreciation, + ]), 201); + } + + public function show(FixedAsset $fixedAsset): JsonResponse + { + $fixedAsset->load('depreciationEntries'); + + return $this->success(array_merge($fixedAsset->toArray(), [ + 'net_book_value' => $fixedAsset->net_book_value, + 'annual_depreciation' => $fixedAsset->annual_depreciation, + 'depreciable_amount' => $fixedAsset->depreciable_amount, + ])); + } + + public function update(Request $request, FixedAsset $fixedAsset): JsonResponse + { + $data = $request->validate([ + 'name' => ['sometimes', 'string', 'max:100'], + 'description' => ['nullable', 'string'], + 'category' => ['sometimes', 'string', 'max:50'], + ]); + + $fixedAsset->update($data); + + return $this->success(array_merge($fixedAsset->fresh()->toArray(), [ + 'net_book_value' => $fixedAsset->fresh()->net_book_value, + ])); + } + + public function destroy(FixedAsset $fixedAsset): JsonResponse + { + if ($fixedAsset->status === 'active') { + return $this->error('Dispose the asset before deleting it.', 422); + } + + $fixedAsset->depreciationEntries()->delete(); + $fixedAsset->delete(); + + return $this->success(['message' => 'Asset deleted.']); + } + + public function depreciate(Request $request, FixedAsset $fixedAsset): JsonResponse + { + $data = $request->validate([ + 'period_date' => ['required', 'date'], + ]); + + try { + $entry = $fixedAsset->runDepreciation($data['period_date']); + } catch (\DomainException $e) { + return $this->error($e->getMessage(), 422); + } + + return $this->success([ + 'entry' => $entry, + 'accumulated_depreciation' => $fixedAsset->fresh()->accumulated_depreciation, + 'net_book_value' => $fixedAsset->fresh()->net_book_value, + 'status' => $fixedAsset->fresh()->status, + ]); + } + + public function dispose(Request $request, FixedAsset $fixedAsset): JsonResponse + { + if ($fixedAsset->status === 'disposed') { + return $this->error('Asset is already disposed.', 422); + } + + $data = $request->validate([ + 'disposal_date' => ['required', 'date'], + 'disposal_proceeds' => ['nullable', 'numeric', 'min:0'], + ]); + + $fixedAsset->update([ + 'status' => 'disposed', + 'disposal_date' => $data['disposal_date'], + 'disposal_proceeds' => $data['disposal_proceeds'] ?? 0, + ]); + + return $this->success($fixedAsset->fresh()); + } + + public function schedule(FixedAsset $fixedAsset): JsonResponse + { + $remainingAmount = $fixedAsset->depreciable_amount - $fixedAsset->accumulated_depreciation; + $annualAmount = $fixedAsset->annual_depreciation; + $yearsRemaining = $annualAmount > 0 ? ceil($remainingAmount / $annualAmount) : 0; + $purchaseYear = (int) $fixedAsset->purchase_date->format('Y'); + $schedule = []; + + for ($i = 0; $i < $fixedAsset->useful_life_years; $i++) { + $year = $purchaseYear + $i; + $accumulated = min($fixedAsset->depreciable_amount, round($annualAmount * ($i + 1), 2)); + $schedule[] = [ + 'year' => $year, + 'depreciation' => round($annualAmount, 2), + 'accumulated' => $accumulated, + 'net_book_value' => max(0, round($fixedAsset->purchase_cost - $accumulated, 2)), + ]; + } + + return $this->success([ + 'asset_id' => $fixedAsset->id, + 'purchase_cost' => $fixedAsset->purchase_cost, + 'salvage_value' => $fixedAsset->salvage_value, + 'useful_life_years' => $fixedAsset->useful_life_years, + 'annual_depreciation' => $fixedAsset->annual_depreciation, + 'current_nbv' => $fixedAsset->net_book_value, + 'schedule' => $schedule, + ]); + } + + public function summary(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + $assets = FixedAsset::where('tenant_id', $tenantId)->get(); + + return $this->success([ + 'total_assets' => $assets->count(), + 'total_cost' => round($assets->sum('purchase_cost'), 2), + 'total_accumulated_dep' => round($assets->sum('accumulated_depreciation'), 2), + 'total_net_book_value' => round($assets->sum(fn ($a) => $a->net_book_value), 2), + 'by_status' => $assets->groupBy('status')->map->count(), + 'by_category' => $assets->groupBy('category')->map->count(), + ]); + } + + private function tenantId(Request $request): int + { + return app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + } +} diff --git a/erp/app/Http/Controllers/Api/V1/FleetApiController.php b/erp/app/Http/Controllers/Api/V1/FleetApiController.php new file mode 100644 index 00000000000..1bfe6e0e727 --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/FleetApiController.php @@ -0,0 +1,130 @@ +query('status')) { + $query->where('status', $status); + } + + $paginator = $query->latest()->paginate(20); + + return $this->paginated($paginator); + } + + /** + * GET /api/v1/fleet/vehicles/{id} + */ + public function showVehicle(int $id): JsonResponse + { + $vehicle = Vehicle::with([ + 'assignments' => fn ($q) => $q->latest()->limit(10), + ])->findOrFail($id); + + return $this->success($vehicle); + } + + /** + * POST /api/v1/fleet/vehicles + */ + public function storeVehicle(Request $request): JsonResponse + { + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'plate_number' => 'required|string|max:50', + 'make' => 'nullable|string|max:100', + 'model' => 'nullable|string|max:100', + 'year' => 'nullable|integer|min:1900|max:2100', + 'color' => 'nullable|string|max:50', + 'vin' => 'nullable|string|max:100', + 'type' => 'nullable|string|max:50', + 'status' => 'nullable|string|max:50', + 'odometer_km' => 'nullable|numeric|min:0', + 'fuel_type' => 'nullable|string|max:50', + 'assigned_to' => 'nullable|integer', + 'insurance_expiry' => 'nullable|date', + 'registration_expiry' => 'nullable|date', + 'notes' => 'nullable|string', + ]); + + $tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $vehicle = Vehicle::create(array_merge($validated, [ + 'tenant_id' => $tenantId, + ])); + + return $this->success($vehicle, 201); + } + + /** + * GET /api/v1/fleet/assignments + */ + public function assignments(Request $request): JsonResponse + { + $query = VehicleAssignment::with(['vehicle:id,name,plate_number']); + + if ($vehicleId = $request->query('vehicle_id')) { + $query->where('vehicle_id', $vehicleId); + } + + $paginator = $query->latest()->paginate(20); + + return $this->paginated($paginator); + } + + /** + * POST /api/v1/fleet/assignments + */ + public function storeAssignment(Request $request): JsonResponse + { + $validated = $request->validate([ + 'vehicle_id' => 'required|integer', + 'driver_id' => 'required|integer', + 'purpose' => 'nullable|string|max:255', + 'assigned_at' => 'nullable|date', + 'returned_at' => 'nullable|date', + 'start_odometer' => 'nullable|numeric|min:0', + 'end_odometer' => 'nullable|numeric|min:0', + 'notes' => 'nullable|string', + ]); + + $tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $assignment = VehicleAssignment::create(array_merge($validated, [ + 'tenant_id' => $tenantId, + 'assigned_at' => $validated['assigned_at'] ?? now(), + ])); + + return $this->success($assignment, 201); + } + + /** + * GET /api/v1/fleet/fuel-logs + */ + public function fuelLogs(Request $request): JsonResponse + { + $query = FuelLog::with(['vehicle:id,name,plate_number']); + + if ($vehicleId = $request->query('vehicle_id')) { + $query->where('vehicle_id', $vehicleId); + } + + $paginator = $query->latest()->paginate(20); + + return $this->paginated($paginator); + } +} diff --git a/erp/app/Http/Controllers/Api/V1/ForecastController.php b/erp/app/Http/Controllers/Api/V1/ForecastController.php new file mode 100644 index 00000000000..77b2c66e49c --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/ForecastController.php @@ -0,0 +1,128 @@ +has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + $months = $request->integer('months', 6); + $months = min(max($months, 1), 24); + + // Historical monthly revenue from the past 12 months + $historical = DB::table('invoices') + ->join('invoice_items', 'invoices.id', '=', 'invoice_items.invoice_id') + ->where('invoices.tenant_id', $tenantId) + ->where('invoices.status', 'paid') + ->where('invoices.created_at', '>=', now()->subYear()) + ->selectRaw("strftime('%Y-%m', invoices.created_at) as month, SUM(invoice_items.quantity * invoice_items.unit_price) as revenue") + ->groupBy('month') + ->orderBy('month') + ->get(); + + $avgMonthly = $historical->isNotEmpty() ? $historical->avg('revenue') : 0; + $trend = $this->calculateTrend($historical->pluck('revenue')->toArray()); + + $forecast = []; + for ($i = 1; $i <= $months; $i++) { + $date = now()->addMonths($i); + $projected = max(0, $avgMonthly + ($trend * $i)); + $forecast[] = [ + 'month' => $date->format('Y-m'), + 'label' => $date->format('M Y'), + 'projected' => round($projected, 2), + 'lower' => round($projected * 0.85, 2), + 'upper' => round($projected * 1.15, 2), + ]; + } + + return $this->success([ + 'historical' => $historical, + 'avg_monthly' => round($avgMonthly, 2), + 'trend_per_month' => round($trend, 2), + 'forecast' => $forecast, + ]); + } + + public function cashFlow(Request $request): JsonResponse + { + $tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + $months = $request->integer('months', 6); + $months = min(max($months, 1), 24); + + // Historical monthly inflows (paid invoices) + $inflows = DB::table('invoices') + ->join('invoice_items', 'invoices.id', '=', 'invoice_items.invoice_id') + ->where('invoices.tenant_id', $tenantId) + ->where('invoices.status', 'paid') + ->where('invoices.created_at', '>=', now()->subYear()) + ->selectRaw("strftime('%Y-%m', invoices.created_at) as month, SUM(invoice_items.quantity * invoice_items.unit_price) as amount") + ->groupBy('month')->orderBy('month')->get()->keyBy('month'); + + // Historical monthly outflows (bills) + $outflows = DB::table('bills') + ->join('bill_items', 'bills.id', '=', 'bill_items.bill_id') + ->where('bills.tenant_id', $tenantId) + ->where('bills.created_at', '>=', now()->subYear()) + ->whereNull('bills.deleted_at') + ->selectRaw("strftime('%Y-%m', bills.created_at) as month, SUM(bill_items.quantity * bill_items.unit_price) as amount") + ->groupBy('month')->orderBy('month')->get()->keyBy('month'); + + $avgInflow = $inflows->isNotEmpty() ? $inflows->avg('amount') : 0; + $avgOutflow = $outflows->isNotEmpty() ? $outflows->avg('amount') : 0; + $inTrend = $this->calculateTrend($inflows->pluck('amount')->toArray()); + $outTrend = $this->calculateTrend($outflows->pluck('amount')->toArray()); + + $forecast = []; + for ($i = 1; $i <= $months; $i++) { + $date = now()->addMonths($i); + $projectedIn = max(0, $avgInflow + ($inTrend * $i)); + $projectedOut = max(0, $avgOutflow + ($outTrend * $i)); + $forecast[] = [ + 'month' => $date->format('Y-m'), + 'label' => $date->format('M Y'), + 'projected_in' => round($projectedIn, 2), + 'projected_out' => round($projectedOut, 2), + 'projected_net' => round($projectedIn - $projectedOut, 2), + ]; + } + + return $this->success([ + 'avg_monthly_inflow' => round($avgInflow, 2), + 'avg_monthly_outflow' => round($avgOutflow, 2), + 'forecast' => $forecast, + ]); + } + + /** + * Simple linear regression slope: how much does revenue change per month on average. + * + * @param float[] $values + */ + private function calculateTrend(array $values): float + { + $n = count($values); + if ($n < 2) { + return 0; + } + + $xBar = ($n - 1) / 2; + $yBar = array_sum($values) / $n; + + $numerator = 0; + $denominator = 0; + + foreach ($values as $x => $y) { + $numerator += ($x - $xBar) * ($y - $yBar); + $denominator += ($x - $xBar) ** 2; + } + + return $denominator == 0 ? 0 : $numerator / $denominator; + } +} diff --git a/erp/app/Http/Controllers/Api/V1/FrontdeskApiController.php b/erp/app/Http/Controllers/Api/V1/FrontdeskApiController.php new file mode 100644 index 00000000000..fce7a8229ec --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/FrontdeskApiController.php @@ -0,0 +1,87 @@ +has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $paginator = FrontdeskStation::where('tenant_id', $tenantId)->latest()->paginate(20); + + return $this->paginated($paginator); + } + + /** + * POST /api/v1/frontdesk/visitors/check-in + */ + public function checkIn(Request $request): JsonResponse + { + $validated = $request->validate([ + 'visitor_name' => 'required|string|max:255', + 'visitor_email' => 'nullable|email|max:255', + 'visitor_phone' => 'nullable|string|max:50', + 'visitor_company' => 'nullable|string|max:255', + 'visit_purpose' => 'required|string|max:255', + 'station_id' => 'required|integer|exists:frontdesk_stations,id', + 'host_employee_id'=> 'nullable|integer|exists:users,id', + 'badge_number' => 'nullable|string|max:50', + 'notes' => 'nullable|string', + ]); + + $tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $validated['tenant_id'] = $tenantId; + $validated['status'] = 'checked_in'; + $validated['check_in_at'] = now(); + + $visitor = VisitorLog::create($validated); + + return $this->success($visitor, 201); + } + + /** + * POST /api/v1/frontdesk/visitors/{id}/checkout + */ + public function checkOut(int $id): JsonResponse + { + $visitor = VisitorLog::findOrFail($id); + $visitor->update([ + 'status' => 'checked_out', + 'check_out_at' => now(), + ]); + + return $this->success($visitor); + } + + /** + * GET /api/v1/frontdesk/visitors + */ + public function visitors(Request $request): JsonResponse + { + $tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $query = VisitorLog::where('tenant_id', $tenantId); + + if ($stationId = $request->query('station_id')) { + $query->where('station_id', $stationId); + } + + if ($date = $request->query('date')) { + $query->whereDate('check_in_at', $date); + } + + $paginator = $query->latest('check_in_at')->paginate(20); + + return $this->paginated($paginator); + } +} diff --git a/erp/app/Http/Controllers/Api/V1/HealthController.php b/erp/app/Http/Controllers/Api/V1/HealthController.php new file mode 100644 index 00000000000..82f4f5daadf --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/HealthController.php @@ -0,0 +1,113 @@ + 'healthy']; + } catch (\Throwable $e) { + $checks['database'] = ['status' => 'unhealthy', 'error' => $e->getMessage()]; + $overall = 'unhealthy'; + } + + // Cache + try { + Cache::put('health_check', true, 5); + $checks['cache'] = Cache::get('health_check') === true + ? ['status' => 'healthy'] + : ['status' => 'degraded', 'error' => 'Cache write/read mismatch']; + if ($checks['cache']['status'] !== 'healthy') { + $overall = 'degraded'; + } + } catch (\Throwable $e) { + $checks['cache'] = ['status' => 'degraded', 'error' => $e->getMessage()]; + $overall = 'degraded'; + } + + // Queue + try { + $failedJobs = DB::table('failed_jobs')->count(); + $checks['queue'] = [ + 'status' => 'healthy', + 'failed_jobs' => $failedJobs, + ]; + if ($failedJobs > 100) { + $checks['queue']['status'] = 'degraded'; + $overall = 'degraded'; + } + } catch (\Throwable $e) { + $checks['queue'] = ['status' => 'degraded', 'error' => $e->getMessage()]; + } + + // Storage + try { + $diskUsage = disk_free_space(storage_path()); + $checks['storage'] = [ + 'status' => 'healthy', + 'free_bytes' => $diskUsage, + ]; + } catch (\Throwable $e) { + $checks['storage'] = ['status' => 'degraded', 'error' => $e->getMessage()]; + } + + $httpStatus = $overall === 'healthy' ? 200 : 503; + + return response()->json([ + 'status' => $overall, + 'timestamp' => now()->toIso8601String(), + 'version' => config('app.version', '1.0.0'), + 'checks' => $checks, + ], $httpStatus); + } + + public function metrics(): JsonResponse + { + $tenantId = auth()->user()?->tenant_id; + + $stats = [ + 'tenants' => DB::table('tenants')->count(), + 'users' => DB::table('users')->count(), + 'api_requests_today' => null, // would need request logging table + 'queue_jobs' => [ + 'pending' => DB::table('jobs')->count(), + 'failed' => DB::table('failed_jobs')->count(), + ], + 'database' => [ + 'size_mb' => $this->getDatabaseSize(), + ], + 'memory_usage_mb' => round(memory_get_usage(true) / 1024 / 1024, 2), + 'peak_memory_mb' => round(memory_get_peak_usage(true) / 1024 / 1024, 2), + 'php_version' => PHP_VERSION, + 'laravel_version' => app()->version(), + ]; + + return $this->success($stats); + } + + private function getDatabaseSize(): ?float + { + try { + $path = database_path('database.sqlite'); + if (file_exists($path)) { + return round(filesize($path) / 1024 / 1024, 2); + } + return null; + } catch (\Throwable) { + return null; + } + } +} diff --git a/erp/app/Http/Controllers/Api/V1/HelpdeskApiController.php b/erp/app/Http/Controllers/Api/V1/HelpdeskApiController.php new file mode 100644 index 00000000000..f247f939681 --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/HelpdeskApiController.php @@ -0,0 +1,145 @@ +query('status')) { + $query->where('status', $status); + } + + if ($priority = $request->query('priority')) { + $query->where('priority', $priority); + } + + if ($assignedTo = $request->query('assigned_to')) { + $query->where('assigned_to', $assignedTo); + } + + $paginator = $query->latest()->paginate(20); + + return $this->paginated($paginator); + } + + /** + * GET /api/v1/helpdesk/tickets/{id} + */ + public function show(int $id): JsonResponse + { + $ticket = HelpdeskTicket::with(['messages.author:id,name', 'assignee:id,name'])->findOrFail($id); + + return $this->success($ticket); + } + + /** + * POST /api/v1/helpdesk/tickets + */ + public function store(Request $request): JsonResponse + { + $validated = $request->validate([ + 'subject' => 'required|string|max:255', + 'description' => 'nullable|string', + 'type' => 'nullable|string', + 'priority' => 'nullable|string|in:low,medium,high,urgent', + 'team_id' => 'nullable|integer|exists:helpdesk_teams,id', + 'assigned_to' => 'nullable|integer|exists:users,id', + 'customer_name' => 'nullable|string|max:255', + 'customer_email' => 'nullable|email|max:255', + ]); + + $tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $validated['tenant_id'] = $tenantId; + $validated['created_by'] = $request->user()->id; + $validated['status'] = 'open'; + + $ticket = HelpdeskTicket::create($validated); + $ticket->ticket_number = $ticket->generateTicketNumber(); + $ticket->save(); + + return $this->success($ticket, 201); + } + + /** + * PUT /api/v1/helpdesk/tickets/{id} + */ + public function update(Request $request, int $id): JsonResponse + { + $ticket = HelpdeskTicket::findOrFail($id); + + $validated = $request->validate([ + 'subject' => 'sometimes|string|max:255', + 'description' => 'nullable|string', + 'type' => 'nullable|string', + 'priority' => 'nullable|string|in:low,medium,high,urgent', + 'status' => 'nullable|string', + 'team_id' => 'nullable|integer|exists:helpdesk_teams,id', + 'assigned_to' => 'nullable|integer|exists:users,id', + ]); + + $ticket->update($validated); + + return $this->success($ticket); + } + + /** + * DELETE /api/v1/helpdesk/tickets/{id} + */ + public function destroy(int $id): JsonResponse + { + $ticket = HelpdeskTicket::findOrFail($id); + $ticket->delete(); + + return $this->success(['message' => 'Ticket deleted']); + } + + /** + * POST /api/v1/helpdesk/tickets/{ticket}/reply + */ + public function reply(Request $request, int $ticket): JsonResponse + { + $helpdeskTicket = HelpdeskTicket::findOrFail($ticket); + + $validated = $request->validate([ + 'body' => 'required|string', + 'is_internal' => 'nullable|boolean', + ]); + + $message = $helpdeskTicket->messages()->create([ + 'body' => $validated['body'], + 'is_internal' => $validated['is_internal'] ?? false, + 'author_id' => $request->user()->id, + ]); + + // Mark first response time + if (is_null($helpdeskTicket->first_response_at)) { + $helpdeskTicket->first_response_at = now(); + $helpdeskTicket->save(); + } + + return $this->success($message, 201); + } + + /** + * POST /api/v1/helpdesk/tickets/{ticket}/resolve + */ + public function resolve(int $ticket): JsonResponse + { + $helpdeskTicket = HelpdeskTicket::findOrFail($ticket); + $helpdeskTicket->resolve(); + + return $this->success($helpdeskTicket); + } +} diff --git a/erp/app/Http/Controllers/Api/V1/HrApiController.php b/erp/app/Http/Controllers/Api/V1/HrApiController.php new file mode 100644 index 00000000000..0acc77614cc --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/HrApiController.php @@ -0,0 +1,104 @@ +query('department_id')) { + $query->where('department_id', $departmentId); + } + + if ($status = $request->query('status')) { + $query->where('status', $status); + } + + $paginator = $query->select(['id', 'first_name', 'last_name', 'employee_number', 'department_id', 'position', 'status']) + ->latest() + ->paginate(20); + + $items = collect($paginator->items())->map(fn (Employee $emp) => [ + 'id' => $emp->id, + 'name' => $emp->full_name, + 'employee_number' => $emp->employee_number, + 'department' => $emp->department?->name, + 'position' => $emp->position, + 'status' => $emp->status, + ]); + + return response()->json([ + 'success' => true, + 'data' => $items, + 'meta' => [ + 'total' => $paginator->total(), + 'per_page' => $paginator->perPage(), + 'current_page' => $paginator->currentPage(), + 'last_page' => $paginator->lastPage(), + ], + ]); + } + + /** + * GET /api/v1/hr/employees/{id} + */ + public function employee(int $id): JsonResponse + { + $employee = Employee::with('department')->findOrFail($id); + + return $this->success([ + 'id' => $employee->id, + 'name' => $employee->full_name, + 'first_name' => $employee->first_name, + 'last_name' => $employee->last_name, + 'employee_number' => $employee->employee_number, + 'email' => $employee->email, + 'phone' => $employee->phone, + 'position' => $employee->position, + 'status' => $employee->status, + 'start_date' => $employee->start_date?->toDateString(), + 'department' => $employee->department, + ]); + } + + /** + * GET /api/v1/hr/departments + */ + public function departments(): JsonResponse + { + $departments = Department::withCount('employees')->get(); + + return $this->success($departments); + } + + /** + * GET /api/v1/hr/leave-requests + */ + public function leaveRequests(Request $request): JsonResponse + { + $query = LeaveRequest::with(['employee:id,first_name,last_name,employee_number']); + + if ($status = $request->query('status')) { + $query->where('status', $status); + } + + if ($employeeId = $request->query('employee_id')) { + $query->where('employee_id', $employeeId); + } + + $paginator = $query->latest()->paginate(20); + + return $this->paginated($paginator); + } +} diff --git a/erp/app/Http/Controllers/Api/V1/ImportExportController.php b/erp/app/Http/Controllers/Api/V1/ImportExportController.php new file mode 100644 index 00000000000..cd219c09539 --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/ImportExportController.php @@ -0,0 +1,57 @@ +has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + } + + public function exportProducts(Request $request) + { + return Excel::download(new ProductsExport($this->tenantId($request)), 'products.xlsx'); + } + + public function exportContacts(Request $request) + { + return Excel::download(new ContactsExport($this->tenantId($request)), 'contacts.xlsx'); + } + + public function exportInvoices(Request $request) + { + return Excel::download(new InvoicesExport($this->tenantId($request)), 'invoices.xlsx'); + } + + public function importProducts(Request $request): JsonResponse + { + $request->validate([ + 'file' => 'required|file|mimes:csv,xlsx', + ]); + + Excel::import(new ProductsImport($this->tenantId($request)), $request->file('file')); + + return response()->json(['success' => true, 'message' => 'Products imported successfully.']); + } + + public function importContacts(Request $request): JsonResponse + { + $request->validate([ + 'file' => 'required|file|mimes:csv,xlsx', + ]); + + Excel::import(new ContactsImport($this->tenantId($request)), $request->file('file')); + + return response()->json(['success' => true, 'message' => 'Contacts imported successfully.']); + } +} diff --git a/erp/app/Http/Controllers/Api/V1/InventoryApiController.php b/erp/app/Http/Controllers/Api/V1/InventoryApiController.php new file mode 100644 index 00000000000..18e1bf291e1 --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/InventoryApiController.php @@ -0,0 +1,73 @@ +query('warehouse_id')) { + // Filter by warehouse via stock levels + $query->whereHas('stockLevels', fn ($q) => $q->where('warehouse_id', $warehouseId)); + } + + if ($request->boolean('low_stock')) { + $query->whereColumn('stock_quantity', '<', 'reorder_point') + ->where('reorder_point', '>', 0); + } + + $paginator = $query->select(['id', 'name', 'sku', 'stock_quantity', 'reorder_point', 'is_active']) + ->latest() + ->paginate(20); + + return $this->paginated($paginator); + } + + /** + * GET /api/v1/inventory/movements + */ + public function movements(Request $request): JsonResponse + { + $query = StockMovement::with(['product:id,name,sku', 'warehouse:id,name']) + ->latest(); + + $paginator = $query->paginate(20); + + return $this->paginated($paginator); + } + + /** + * POST /api/v1/inventory/adjust + */ + public function adjust(Request $request): JsonResponse + { + $validated = $request->validate([ + 'product_id' => 'required|integer|exists:products,id', + 'warehouse_id' => 'required|integer|exists:warehouses,id', + 'quantity' => 'required|numeric', + 'reason' => 'nullable|string', + ]); + + $type = $validated['quantity'] >= 0 ? 'in' : 'out'; + + $movement = StockMovement::record([ + 'product_id' => $validated['product_id'], + 'warehouse_id' => $validated['warehouse_id'], + 'type' => $type, + 'quantity' => abs($validated['quantity']), + 'notes' => $validated['reason'] ?? null, + ]); + + return $this->success($movement, 201); + } +} diff --git a/erp/app/Http/Controllers/Api/V1/InventoryValuationController.php b/erp/app/Http/Controllers/Api/V1/InventoryValuationController.php new file mode 100644 index 00000000000..94b13a77021 --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/InventoryValuationController.php @@ -0,0 +1,126 @@ +tenantId($request); + + $products = Product::where('tenant_id', $tenantId) + ->where('is_active', true) + ->where('stock_quantity', '>', 0) + ->get(['id', 'name', 'sku', 'stock_quantity', 'cost_price']); + + $totalValue = $products->sum(fn ($p) => (float) $p->stock_quantity * (float) $p->cost_price); + + return $this->success([ + 'total_value' => round($totalValue, 2), + 'product_count' => $products->count(), + 'valuation_method' => 'average_cost', + 'as_of' => now()->toDateString(), + ]); + } + + public function breakdown(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + $minValue = (float) $request->get('min_value', 0); + + $products = Product::where('tenant_id', $tenantId) + ->where('is_active', true) + ->where('stock_quantity', '>', 0) + ->get(['id', 'name', 'sku', 'stock_quantity', 'cost_price', 'sale_price']); + + $rows = $products->map(fn ($p) => [ + 'product_id' => $p->id, + 'name' => $p->name, + 'sku' => $p->sku, + 'stock_quantity' => (float) $p->stock_quantity, + 'cost_price' => (float) $p->cost_price, + 'stock_value' => round((float) $p->stock_quantity * (float) $p->cost_price, 2), + 'retail_value' => round((float) $p->stock_quantity * (float) $p->sale_price, 2), + 'potential_margin' => (float) $p->sale_price > 0 + ? round((((float) $p->sale_price - (float) $p->cost_price) / (float) $p->sale_price) * 100, 1) + : null, + ])->filter(fn ($row) => $row['stock_value'] >= $minValue) + ->sortByDesc('stock_value') + ->values(); + + $totalCostValue = $rows->sum('stock_value'); + $totalRetailValue = $rows->sum('retail_value'); + + return $this->success([ + 'products' => $rows, + 'total_cost_value' => round($totalCostValue, 2), + 'total_retail_value' => round($totalRetailValue, 2), + 'total_potential_margin' => $totalRetailValue > 0 + ? round((($totalRetailValue - $totalCostValue) / $totalRetailValue) * 100, 1) + : null, + 'count' => $rows->count(), + ]); + } + + public function movement(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + $from = $request->get('from', now()->subMonths(3)->toDateString()); + $to = $request->get('to', now()->toDateString()); + + $movements = DB::table('stock_movements') + ->where('tenant_id', $tenantId) + ->whereDate('created_at', '>=', $from) + ->whereDate('created_at', '<=', $to) + ->select([ + 'type as movement_type', + DB::raw('COUNT(*) as transaction_count'), + DB::raw('SUM(quantity) as total_quantity'), + ]) + ->groupBy('type') + ->get(); + + return $this->success([ + 'period' => ['from' => $from, 'to' => $to], + 'movements' => $movements, + ]); + } + + public function lowValueStock(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + $threshold = (float) $request->get('threshold', 100); + + $products = Product::where('tenant_id', $tenantId) + ->where('is_active', true) + ->where('stock_quantity', '>', 0) + ->get(['id', 'name', 'sku', 'stock_quantity', 'cost_price']) + ->map(fn ($p) => [ + 'product_id' => $p->id, + 'name' => $p->name, + 'sku' => $p->sku, + 'stock_quantity' => (float) $p->stock_quantity, + 'cost_price' => (float) $p->cost_price, + 'stock_value' => round((float) $p->stock_quantity * (float) $p->cost_price, 2), + ]) + ->filter(fn ($r) => $r['stock_value'] <= $threshold) + ->sortBy('stock_value') + ->values(); + + return $this->success([ + 'threshold' => $threshold, + 'products' => $products, + 'count' => $products->count(), + ]); + } + + private function tenantId(Request $request): int + { + return app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + } +} diff --git a/erp/app/Http/Controllers/Api/V1/InvoiceApiController.php b/erp/app/Http/Controllers/Api/V1/InvoiceApiController.php new file mode 100644 index 00000000000..aaa6afddfff --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/InvoiceApiController.php @@ -0,0 +1,119 @@ +query('status')) { + $query->where('status', $status); + } + + if ($customerId = $request->query('customer_id')) { + $query->where('contact_id', $customerId); + } + + $paginator = $query->latest()->paginate(20); + + $items = collect($paginator->items())->map(fn (Invoice $inv) => [ + 'id' => $inv->id, + 'invoice_number' => $inv->number, + 'customer_name' => $inv->contact?->name, + 'total' => $inv->total, + 'status' => $inv->status, + 'due_date' => $inv->due_date?->toDateString(), + 'issue_date' => $inv->issue_date?->toDateString(), + ]); + + return response()->json([ + 'success' => true, + 'data' => $items, + 'meta' => [ + 'total' => $paginator->total(), + 'per_page' => $paginator->perPage(), + 'current_page' => $paginator->currentPage(), + 'last_page' => $paginator->lastPage(), + ], + ]); + } + + /** + * GET /api/v1/invoices/{id} + */ + public function show(int $id): JsonResponse + { + $invoice = Invoice::with(['contact', 'items'])->findOrFail($id); + + return $this->success($invoice); + } + + /** + * POST /api/v1/invoices + */ + public function store(Request $request): JsonResponse + { + $validated = $request->validate([ + 'contact_id' => 'required|integer|exists:contacts,id', + 'issue_date' => 'required|date', + 'due_date' => 'nullable|date', + 'status' => 'nullable|string', + 'notes' => 'nullable|string', + 'items' => 'nullable|array', + 'items.*.description' => 'required_with:items|string', + 'items.*.quantity' => 'required_with:items|numeric|min:0', + 'items.*.unit_price' => 'required_with:items|numeric|min:0', + 'items.*.tax_rate' => 'nullable|numeric|min:0', + ]); + + $tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $items = $validated['items'] ?? []; + unset($validated['items']); + + $validated['tenant_id'] = $tenantId; + $validated['created_by'] = $request->user()->id; + + $invoice = Invoice::create($validated); + + foreach ($items as $item) { + $invoice->items()->create([ + 'description' => $item['description'], + 'quantity' => $item['quantity'], + 'unit_price' => $item['unit_price'], + 'tax_rate' => $item['tax_rate'] ?? 0, + ]); + } + + SendInvoiceNotificationJob::dispatch($invoice); + + return $this->success($invoice->load('items'), 201); + } + + /** + * PUT /api/v1/invoices/{invoice}/status + */ + public function updateStatus(Request $request, int $invoice): JsonResponse + { + $inv = Invoice::findOrFail($invoice); + + $validated = $request->validate([ + 'status' => 'required|string|in:draft,sent,partial,paid,cancelled', + ]); + + $inv->update(['status' => $validated['status']]); + + return $this->success($inv); + } +} diff --git a/erp/app/Http/Controllers/Api/V1/KnowledgeBaseApiController.php b/erp/app/Http/Controllers/Api/V1/KnowledgeBaseApiController.php new file mode 100644 index 00000000000..ad07a922fb9 --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/KnowledgeBaseApiController.php @@ -0,0 +1,99 @@ +query('category_id')) { + $query->where('category_id', $categoryId); + } + + if ($search = $request->query('search')) { + $query->where('title', 'LIKE', '%' . $search . '%'); + } + + $paginator = $query->latest()->paginate(20); + + return $this->paginated($paginator); + } + + /** + * GET /api/v1/knowledge-base/articles/{id} + */ + public function showArticle(int $id): JsonResponse + { + $article = KbArticle::findOrFail($id); + + return $this->success($article); + } + + /** + * GET /api/v1/knowledge-base/categories + */ + public function categories(Request $request): JsonResponse + { + $tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $categories = KbCategory::where('tenant_id', $tenantId)->orderBy('sequence')->get(); + + return $this->success($categories); + } + + /** + * POST /api/v1/knowledge-base/articles + */ + public function storeArticle(Request $request): JsonResponse + { + $validated = $request->validate([ + 'title' => 'required|string|max:255', + 'content' => 'required|string', + 'category_id' => 'nullable|integer|exists:kb_categories,id', + 'excerpt' => 'nullable|string', + 'status' => 'nullable|string|in:draft,published,archived', + 'tags' => 'nullable|array', + ]); + + $tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $validated['tenant_id'] = $tenantId; + $validated['author_id'] = $request->user()->id; + $validated['status'] = $validated['status'] ?? 'draft'; + + $article = KbArticle::create($validated); + + return $this->success($article, 201); + } + + /** + * PUT /api/v1/knowledge-base/articles/{id} + */ + public function updateArticle(Request $request, int $id): JsonResponse + { + $article = KbArticle::findOrFail($id); + + $validated = $request->validate([ + 'title' => 'sometimes|string|max:255', + 'content' => 'sometimes|string', + 'category_id' => 'nullable|integer|exists:kb_categories,id', + 'excerpt' => 'nullable|string', + 'status' => 'nullable|string|in:draft,published,archived', + 'tags' => 'nullable|array', + ]); + + $article->update($validated); + + return $this->success($article); + } +} diff --git a/erp/app/Http/Controllers/Api/V1/LeaveBalanceController.php b/erp/app/Http/Controllers/Api/V1/LeaveBalanceController.php new file mode 100644 index 00000000000..a8c176e5a38 --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/LeaveBalanceController.php @@ -0,0 +1,104 @@ +tenantId($request); + $types = LeaveType::where('tenant_id', $tenantId)->where('is_active', true)->get(); + return $this->success($types); + } + + public function employee(Request $request, Employee $employee): JsonResponse + { + $year = (int) $request->get('year', now()->year); + $balances = LeaveBalance::where('employee_id', $employee->id) + ->where('year', $year) + ->with('leaveType') + ->get() + ->map(fn ($b) => [ + 'leave_type_id' => $b->leave_type_id, + 'leave_type' => $b->leaveType?->name, + 'year' => $b->year, + 'allocated_days' => $b->allocated_days, + 'used_days' => $b->used_days, + 'pending_days' => $b->pending_days, + 'remaining_days' => $b->remaining_days, + ]); + + return $this->success([ + 'employee_id' => $employee->id, + 'employee_name' => $employee->full_name, + 'year' => $year, + 'balances' => $balances, + ]); + } + + public function allocate(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + + $data = $request->validate([ + 'employee_id' => ['required', 'exists:employees,id'], + 'leave_type_id' => ['required', 'exists:leave_types,id'], + 'year' => ['required', 'integer', 'min:2000'], + 'allocated_days' => ['required', 'numeric', 'min:0'], + ]); + + $balance = LeaveBalance::updateOrCreate( + [ + 'employee_id' => $data['employee_id'], + 'leave_type_id' => $data['leave_type_id'], + 'year' => $data['year'], + ], + [ + 'tenant_id' => $tenantId, + 'allocated_days' => $data['allocated_days'], + ] + ); + + return $this->success($balance->load('leaveType'), 201); + } + + public function team(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + $year = (int) $request->get('year', now()->year); + $departmentId = $request->get('department_id'); + + $employees = Employee::where('tenant_id', $tenantId) + ->where('status', 'active') + ->when($departmentId, fn ($q) => $q->where('department_id', $departmentId)) + ->with(['leaveBalances' => fn ($q) => $q->where('year', $year)->with('leaveType')]) + ->get() + ->map(fn ($e) => [ + 'employee_id' => $e->id, + 'employee_name' => $e->full_name, + 'balances' => $e->leaveBalances->map(fn ($b) => [ + 'leave_type' => $b->leaveType?->name, + 'allocated_days' => $b->allocated_days, + 'used_days' => $b->used_days, + 'remaining_days' => $b->remaining_days, + ]), + 'total_remaining' => $e->leaveBalances->sum('remaining_days'), + ]); + + return $this->success([ + 'year' => $year, + 'employees' => $employees, + ]); + } + + private function tenantId(Request $request): int + { + return app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + } +} diff --git a/erp/app/Http/Controllers/Api/V1/LiveChatApiController.php b/erp/app/Http/Controllers/Api/V1/LiveChatApiController.php new file mode 100644 index 00000000000..f36f6befef5 --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/LiveChatApiController.php @@ -0,0 +1,86 @@ +has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $paginator = ChatChannel::where('tenant_id', $tenantId)->latest()->paginate(20); + + return $this->paginated($paginator); + } + + /** + * GET /api/v1/live-chat/sessions + */ + public function sessions(Request $request): JsonResponse + { + $tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $query = ChatSession::where('tenant_id', $tenantId); + + if ($channelId = $request->query('channel_id')) { + $query->where('channel_id', $channelId); + } + + if ($status = $request->query('status')) { + $query->where('status', $status); + } + + $paginator = $query->latest()->paginate(20); + + return $this->paginated($paginator); + } + + /** + * GET /api/v1/live-chat/messages + */ + public function messages(Request $request): JsonResponse + { + $tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $query = ChatMessage::where('tenant_id', $tenantId); + + if ($sessionId = $request->query('session_id')) { + $query->where('session_id', $sessionId); + } + + $paginator = $query->latest()->paginate(50); + + return $this->paginated($paginator); + } + + /** + * POST /api/v1/live-chat/messages + */ + public function storeMessage(Request $request): JsonResponse + { + $validated = $request->validate([ + 'session_id' => 'required|integer|exists:chat_sessions,id', + 'message' => 'required|string', + 'sender_type' => 'nullable|string|in:visitor,agent,bot', + ]); + + $tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $validated['tenant_id'] = $tenantId; + $validated['sender_type'] = $validated['sender_type'] ?? 'agent'; + $validated['agent_id'] = $request->user()->id; + + $message = ChatMessage::create($validated); + + return $this->success($message, 201); + } +} diff --git a/erp/app/Http/Controllers/Api/V1/LoyaltyApiController.php b/erp/app/Http/Controllers/Api/V1/LoyaltyApiController.php new file mode 100644 index 00000000000..b3752f2411c --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/LoyaltyApiController.php @@ -0,0 +1,172 @@ +tenantId($request); + $programs = LoyaltyProgram::where('tenant_id', $tenantId) + ->withCount('enrollments') + ->get(); + + return $this->success($programs); + } + + public function storeProgram(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + + $data = $request->validate([ + 'name' => ['required', 'string', 'max:100'], + 'description' => ['nullable', 'string'], + 'points_per_currency_unit' => ['required', 'numeric', 'min:0.0001'], + 'points_to_currency_rate' => ['required', 'numeric', 'min:0.000001'], + 'minimum_redemption_points' => ['nullable', 'integer', 'min:1'], + ]); + + $program = LoyaltyProgram::create([ + ...$data, + 'tenant_id' => $tenantId, + 'is_active' => true, + ]); + + return $this->success($program, 201); + } + + public function updateProgram(Request $request, LoyaltyProgram $loyaltyProgram): JsonResponse + { + $data = $request->validate([ + 'name' => ['sometimes', 'string', 'max:100'], + 'points_per_currency_unit' => ['numeric', 'min:0.0001'], + 'points_to_currency_rate' => ['numeric', 'min:0.000001'], + 'minimum_redemption_points' => ['integer', 'min:1'], + 'is_active' => ['boolean'], + ]); + + $loyaltyProgram->update($data); + + return $this->success($loyaltyProgram->fresh()); + } + + // ── Enrollments ─────────────────────────────────────────────────────────── + + public function enroll(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + + $data = $request->validate([ + 'loyalty_program_id' => ['required', 'integer', 'exists:loyalty_programs,id'], + 'contact_id' => ['required', 'integer', 'exists:contacts,id'], + ]); + + $enrollment = LoyaltyEnrollment::firstOrCreate( + ['loyalty_program_id' => $data['loyalty_program_id'], 'contact_id' => $data['contact_id']], + [ + 'tenant_id' => $tenantId, + 'points_balance' => 0, + 'total_points_earned' => 0, + 'total_points_redeemed' => 0, + 'enrolled_at' => now(), + ] + ); + + return $this->success($enrollment->load('contact:id,name', 'program:id,name'), 201); + } + + public function balance(Request $request, int $contactId): JsonResponse + { + $tenantId = $this->tenantId($request); + $enrollments = LoyaltyEnrollment::where('tenant_id', $tenantId) + ->where('contact_id', $contactId) + ->with('program:id,name,points_to_currency_rate') + ->get(); + + return $this->success($enrollments->map(fn ($e) => [ + 'program_id' => $e->loyalty_program_id, + 'program_name' => $e->program?->name, + 'points_balance' => $e->points_balance, + 'total_points_earned' => $e->total_points_earned, + 'total_points_redeemed' => $e->total_points_redeemed, + 'redemption_value' => $e->program + ? $e->program->calculateRedemptionValue($e->points_balance) + : null, + ])); + } + + // ── Transactions ────────────────────────────────────────────────────────── + + public function earnPoints(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + + $data = $request->validate([ + 'loyalty_program_id' => ['required', 'integer', 'exists:loyalty_programs,id'], + 'contact_id' => ['required', 'integer', 'exists:contacts,id'], + 'amount' => ['required', 'numeric', 'min:0.01'], + 'description' => ['nullable', 'string'], + 'reference_id' => ['nullable', 'integer'], + ]); + + $program = LoyaltyProgram::where('tenant_id', $tenantId)->findOrFail($data['loyalty_program_id']); + $enrollment = LoyaltyEnrollment::where('contact_id', $data['contact_id']) + ->where('loyalty_program_id', $program->id) + ->firstOrFail(); + + $points = $program->calculatePointsEarned((float) $data['amount']); + $tx = $enrollment->earnPoints($points, $data['description'] ?? '', $data['reference_id'] ?? null); + + return $this->success([ + 'transaction' => $tx, + 'points_earned' => $points, + 'new_balance' => $enrollment->fresh()->points_balance, + ]); + } + + public function redeemPoints(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + + $data = $request->validate([ + 'loyalty_program_id' => ['required', 'integer', 'exists:loyalty_programs,id'], + 'contact_id' => ['required', 'integer', 'exists:contacts,id'], + 'points' => ['required', 'integer', 'min:1'], + 'description' => ['nullable', 'string'], + ]); + + $program = LoyaltyProgram::where('tenant_id', $tenantId)->findOrFail($data['loyalty_program_id']); + $enrollment = LoyaltyEnrollment::where('contact_id', $data['contact_id']) + ->where('loyalty_program_id', $program->id) + ->firstOrFail(); + + if ($enrollment->points_balance < $data['points']) { + return $this->error('Insufficient points balance.', 422); + } + + if ($program->minimum_redemption_points && $data['points'] < $program->minimum_redemption_points) { + return $this->error("Minimum redemption is {$program->minimum_redemption_points} points.", 422); + } + + $redemptionValue = $program->calculateRedemptionValue($data['points']); + $enrollment->redeemPoints($data['points'], $data['description'] ?? ''); + + return $this->success([ + 'points_redeemed' => $data['points'], + 'redemption_value' => $redemptionValue, + 'new_balance' => $enrollment->fresh()->points_balance, + ]); + } + + private function tenantId(Request $request): int + { + return app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + } +} diff --git a/erp/app/Http/Controllers/Api/V1/LunchApiController.php b/erp/app/Http/Controllers/Api/V1/LunchApiController.php new file mode 100644 index 00000000000..f70a4921f1c --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/LunchApiController.php @@ -0,0 +1,97 @@ +has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $paginator = LunchSupplier::where('tenant_id', $tenantId)->latest()->paginate(20); + + return $this->paginated($paginator); + } + + /** + * GET /api/v1/lunch/products + */ + public function products(Request $request): JsonResponse + { + $tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $query = LunchProduct::where('tenant_id', $tenantId); + + if ($supplierId = $request->query('supplier_id')) { + $query->where('lunch_supplier_id', $supplierId); + } + + $paginator = $query->latest()->paginate(20); + + return $this->paginated($paginator); + } + + /** + * GET /api/v1/lunch/orders + */ + public function orders(Request $request): JsonResponse + { + $tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $query = LunchOrder::where('tenant_id', $tenantId); + + if ($status = $request->query('status')) { + $query->where('status', $status); + } + + $paginator = $query->latest()->paginate(20); + + return $this->paginated($paginator); + } + + /** + * POST /api/v1/lunch/orders + */ + public function storeOrder(Request $request): JsonResponse + { + $validated = $request->validate([ + 'lunch_product_id' => 'required|integer|exists:lunch_products,id', + 'quantity' => 'required|integer|min:1', + 'order_date' => 'required|date', + 'notes' => 'nullable|string', + ]); + + $tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $product = LunchProduct::findOrFail($validated['lunch_product_id']); + + $validated['tenant_id'] = $tenantId; + $validated['employee_id'] = $request->user()->id; + $validated['status'] = 'pending'; + $validated['total_price'] = $product->price * $validated['quantity']; + + $order = LunchOrder::create($validated); + + return $this->success($order, 201); + } + + /** + * POST /api/v1/lunch/orders/{id}/cancel + */ + public function cancelOrder(int $id): JsonResponse + { + $order = LunchOrder::findOrFail($id); + $order->update(['status' => 'cancelled']); + + return $this->success($order); + } +} diff --git a/erp/app/Http/Controllers/Api/V1/MaintenanceApiController.php b/erp/app/Http/Controllers/Api/V1/MaintenanceApiController.php new file mode 100644 index 00000000000..4c21158cecb --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/MaintenanceApiController.php @@ -0,0 +1,134 @@ +query('status')) { + $query->where('status', $status); + } + + if ($equipmentId = $request->query('equipment_id')) { + $query->where('equipment_id', $equipmentId); + } + + $paginator = $query->latest()->paginate(20); + + return $this->paginated($paginator); + } + + /** + * GET /api/v1/maintenance/orders/{id} + */ + public function showOrder(int $id): JsonResponse + { + $order = MaintenanceOrder::with('equipment')->findOrFail($id); + + return $this->success($order); + } + + /** + * POST /api/v1/maintenance/orders + */ + public function storeOrder(Request $request): JsonResponse + { + $validated = $request->validate([ + 'equipment_id' => 'required|integer', + 'plan_id' => 'nullable|integer', + 'type' => 'nullable|string|max:50', + 'priority' => 'nullable|string|max:50', + 'status' => 'nullable|string|max:50', + 'title' => 'required|string|max:255', + 'description' => 'nullable|string', + 'scheduled_date' => 'nullable|date', + 'estimated_hours' => 'nullable|numeric|min:0', + 'assigned_to' => 'nullable|integer', + 'reported_by' => 'nullable|integer', + 'cost' => 'nullable|numeric|min:0', + 'notes' => 'nullable|string', + ]); + + $tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $order = MaintenanceOrder::create(array_merge($validated, [ + 'tenant_id' => $tenantId, + 'order_number' => MaintenanceOrder::generateOrderNumber($tenantId), + ])); + + return $this->success($order, 201); + } + + /** + * GET /api/v1/maintenance/equipment + */ + public function equipment(Request $request): JsonResponse + { + $query = Equipment::query(); + + if ($status = $request->query('status')) { + $query->where('status', $status); + } + + $paginator = $query->latest()->paginate(20); + + return $this->paginated($paginator); + } + + /** + * POST /api/v1/maintenance/equipment + */ + public function storeEquipment(Request $request): JsonResponse + { + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'code' => 'nullable|string|max:100', + 'category' => 'nullable|string|max:100', + 'location' => 'nullable|string|max:255', + 'serial_number' => 'nullable|string|max:100', + 'manufacturer' => 'nullable|string|max:255', + 'model' => 'nullable|string|max:255', + 'purchase_date' => 'nullable|date', + 'warranty_expiry' => 'nullable|date', + 'status' => 'nullable|string|max:50', + 'notes' => 'nullable|string', + 'assigned_to' => 'nullable|integer', + ]); + + $tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $equipment = Equipment::create(array_merge($validated, [ + 'tenant_id' => $tenantId, + ])); + + return $this->success($equipment, 201); + } + + /** + * GET /api/v1/maintenance/plans + */ + public function plans(Request $request): JsonResponse + { + $query = MaintenancePlan::query(); + + if ($equipmentId = $request->query('equipment_id')) { + $query->where('equipment_id', $equipmentId); + } + + $paginator = $query->latest()->paginate(20); + + return $this->paginated($paginator); + } +} diff --git a/erp/app/Http/Controllers/Api/V1/ManufacturingApiController.php b/erp/app/Http/Controllers/Api/V1/ManufacturingApiController.php new file mode 100644 index 00000000000..d670bfa78c9 --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/ManufacturingApiController.php @@ -0,0 +1,84 @@ +query('status')) { + $query->where('status', $status); + } + + $paginator = $query->latest()->paginate(20); + + $items = collect($paginator->items())->map(fn (ManufacturingOrder $mo) => [ + 'id' => $mo->id, + 'mo_number' => $mo->mo_number, + 'product' => $mo->product?->name, + 'qty_to_produce' => $mo->qty_to_produce, + 'qty_produced' => $mo->qty_produced, + 'status' => $mo->status, + 'scheduled_date' => $mo->scheduled_date?->toDateString(), + ]); + + return response()->json([ + 'success' => true, + 'data' => $items, + 'meta' => [ + 'total' => $paginator->total(), + 'per_page' => $paginator->perPage(), + 'current_page' => $paginator->currentPage(), + 'last_page' => $paginator->lastPage(), + ], + ]); + } + + /** + * GET /api/v1/manufacturing/orders/{order} + */ + public function show(int $order): JsonResponse + { + $mo = ManufacturingOrder::with(['product', 'components.product:id,name,sku', 'bom'])->findOrFail($order); + + return $this->success($mo); + } + + /** + * PUT /api/v1/manufacturing/orders/{order}/status + */ + public function updateStatus(Request $request, int $order): JsonResponse + { + $mo = ManufacturingOrder::findOrFail($order); + + $validated = $request->validate([ + 'status' => 'required|string|in:draft,confirmed,in_progress,done,cancelled', + ]); + + $mo->update(['status' => $validated['status']]); + + return $this->success($mo); + } + + /** + * GET /api/v1/manufacturing/boms + */ + public function boms(Request $request): JsonResponse + { + $query = BillOfMaterials::with('product:id,name,sku'); + + $paginator = $query->latest()->paginate(20); + + return $this->paginated($paginator); + } +} diff --git a/erp/app/Http/Controllers/Api/V1/ManufacturingExtApiController.php b/erp/app/Http/Controllers/Api/V1/ManufacturingExtApiController.php new file mode 100644 index 00000000000..76d55059513 --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/ManufacturingExtApiController.php @@ -0,0 +1,244 @@ +has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + } + + // ── Bills of Materials ──────────────────────────────────────────────────── + + public function indexBoms(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + $boms = BillOfMaterials::where('tenant_id', $tenantId) + ->with('product:id,name,sku') + ->withCount('lines') + ->when($request->input('product_id'), fn ($q, $p) => $q->where('product_id', $p)) + ->when($request->boolean('active_only'), fn ($q) => $q->where('is_active', true)) + ->orderBy('name') + ->get(); + + return $this->success($boms); + } + + public function storeBom(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + + $data = $request->validate([ + 'product_id' => ['required', 'integer', 'exists:products,id'], + 'name' => ['required', 'string', 'max:100'], + 'code' => ['nullable', 'string', 'max:50'], + 'type' => ['nullable', 'in:manufacture,kit,subcontracting'], + 'qty_per_bom' => ['nullable', 'numeric', 'min:0.0001'], + 'uom' => ['nullable', 'string', 'max:20'], + 'notes' => ['nullable', 'string'], + 'lines' => ['nullable', 'array'], + 'lines.*.component_id' => ['required_with:lines', 'integer', 'exists:products,id'], + 'lines.*.quantity' => ['required_with:lines', 'numeric', 'min:0.0001'], + 'lines.*.uom' => ['nullable', 'string', 'max:20'], + 'lines.*.sequence' => ['nullable', 'integer', 'min:1'], + 'lines.*.is_optional' => ['boolean'], + ]); + + $bom = BillOfMaterials::create([ + 'tenant_id' => $tenantId, + 'product_id' => $data['product_id'], + 'name' => $data['name'], + 'code' => $data['code'] ?? null, + 'type' => $data['type'] ?? 'manufacture', + 'qty_per_bom' => $data['qty_per_bom'] ?? 1, + 'uom' => $data['uom'] ?? null, + 'notes' => $data['notes'] ?? null, + 'is_active' => true, + ]); + + foreach ($data['lines'] ?? [] as $i => $line) { + BomLine::create([ + 'bom_id' => $bom->id, + 'component_id' => $line['component_id'], + 'quantity' => $line['quantity'], + 'uom' => $line['uom'] ?? null, + 'sequence' => $line['sequence'] ?? (($i + 1) * 10), + 'is_optional' => $line['is_optional'] ?? false, + ]); + } + + return $this->success($bom->load('product:id,name,sku', 'lines.component:id,name,sku'), 201); + } + + public function showBom(BillOfMaterials $billOfMaterials): JsonResponse + { + $billOfMaterials->load('product:id,name,sku', 'lines.component:id,name,sku'); + + return $this->success($billOfMaterials); + } + + public function addBomLine(Request $request, BillOfMaterials $billOfMaterials): JsonResponse + { + $data = $request->validate([ + 'component_id' => ['required', 'integer', 'exists:products,id'], + 'quantity' => ['required', 'numeric', 'min:0.0001'], + 'uom' => ['nullable', 'string', 'max:20'], + 'sequence' => ['nullable', 'integer', 'min:1'], + 'is_optional' => ['boolean'], + 'notes' => ['nullable', 'string'], + ]); + + $line = BomLine::create([ + 'bom_id' => $billOfMaterials->id, + 'component_id' => $data['component_id'], + 'quantity' => $data['quantity'], + 'uom' => $data['uom'] ?? null, + 'sequence' => $data['sequence'] ?? (($billOfMaterials->lines()->count() + 1) * 10), + 'is_optional' => $data['is_optional'] ?? false, + 'notes' => $data['notes'] ?? null, + ]); + + return $this->success($line->load('component:id,name,sku'), 201); + } + + public function removeBomLine(BillOfMaterials $billOfMaterials, BomLine $bomLine): JsonResponse + { + $bomLine->delete(); + + return $this->success(['message' => 'BOM line removed.']); + } + + // ── Manufacturing Orders ────────────────────────────────────────────────── + + public function indexOrders(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + $orders = ManufacturingOrder::where('tenant_id', $tenantId) + ->with('product:id,name,sku', 'bom:id,name') + ->when($request->input('status'), fn ($q, $s) => $q->where('status', $s)) + ->orderByDesc('scheduled_date') + ->get(); + + return $this->success($orders); + } + + public function storeOrder(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + + $data = $request->validate([ + 'product_id' => ['required', 'integer', 'exists:products,id'], + 'bom_id' => ['nullable', 'integer', 'exists:bills_of_materials,id'], + 'qty_to_produce' => ['required', 'numeric', 'min:0.0001'], + 'scheduled_date' => ['nullable', 'date'], + 'warehouse_id' => ['nullable', 'integer', 'exists:warehouses,id'], + 'notes' => ['nullable', 'string'], + ]); + + $order = ManufacturingOrder::create([ + 'tenant_id' => $tenantId, + 'product_id' => $data['product_id'], + 'bom_id' => $data['bom_id'] ?? null, + 'qty_to_produce' => $data['qty_to_produce'], + 'qty_produced' => 0, + 'status' => 'draft', + 'scheduled_date' => $data['scheduled_date'] ?? null, + 'warehouse_id' => $data['warehouse_id'] ?? null, + 'notes' => $data['notes'] ?? null, + 'created_by' => $request->user()->id, + ]); + + return $this->success($order->load('product:id,name,sku', 'bom:id,name'), 201); + } + + public function confirmOrder(ManufacturingOrder $manufacturingOrder): JsonResponse + { + if ($manufacturingOrder->status !== 'draft') { + return $this->error('Only draft orders can be confirmed.', 422); + } + + $manufacturingOrder->confirm(); + + return $this->success($manufacturingOrder->fresh()); + } + + public function startOrder(ManufacturingOrder $manufacturingOrder): JsonResponse + { + if ($manufacturingOrder->status !== 'confirmed') { + return $this->error('Only confirmed orders can be started.', 422); + } + + $manufacturingOrder->startProduction(); + + return $this->success($manufacturingOrder->fresh()); + } + + public function completeOrder(Request $request, ManufacturingOrder $manufacturingOrder): JsonResponse + { + if ($manufacturingOrder->status !== 'in_progress') { + return $this->error('Only in-progress orders can be completed.', 422); + } + + $data = $request->validate([ + 'qty_produced' => ['required', 'numeric', 'min:0'], + ]); + + $manufacturingOrder->complete($data['qty_produced']); + + return $this->success($manufacturingOrder->fresh()); + } + + public function cancelOrder(ManufacturingOrder $manufacturingOrder): JsonResponse + { + if (in_array($manufacturingOrder->status, ['done'])) { + return $this->error('Completed orders cannot be cancelled.', 422); + } + + $manufacturingOrder->cancel(); + + return $this->success($manufacturingOrder->fresh()); + } + + // ── Work Centers ────────────────────────────────────────────────────────── + + public function indexWorkCenters(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + $workCenters = WorkCenter::where('tenant_id', $tenantId) + ->when($request->boolean('active_only'), fn ($q) => $q->where('is_active', true)) + ->orderBy('name') + ->get(); + + return $this->success($workCenters); + } + + public function storeWorkCenter(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + + $data = $request->validate([ + 'name' => ['required', 'string', 'max:100'], + 'code' => ['nullable', 'string', 'max:20'], + 'capacity' => ['nullable', 'numeric', 'min:0'], + ]); + + $wc = WorkCenter::create([ + 'tenant_id' => $tenantId, + 'name' => $data['name'], + 'code' => $data['code'] ?? null, + 'capacity' => $data['capacity'] ?? 1, + 'is_active' => true, + ]); + + return $this->success($wc, 201); + } +} diff --git a/erp/app/Http/Controllers/Api/V1/MarketingApiController.php b/erp/app/Http/Controllers/Api/V1/MarketingApiController.php new file mode 100644 index 00000000000..7dd7b9b4f09 --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/MarketingApiController.php @@ -0,0 +1,111 @@ +has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $query = EmailCampaign::where('tenant_id', $tenantId); + + if ($request->filled('status')) { + $query->where('status', $request->status); + } + + return $this->paginated($query->latest()->paginate(15)); + } + + public function showCampaign(int $id): JsonResponse + { + $campaign = EmailCampaign::with(['mailingList', 'creator'])->findOrFail($id); + + $stats = [ + 'total_recipients' => $campaign->total_recipients, + 'sent_count' => $campaign->sent_count, + 'open_count' => $campaign->open_count, + 'click_count' => $campaign->click_count, + 'bounce_count' => $campaign->bounce_count, + 'unsubscribe_count' => $campaign->unsubscribe_count, + ]; + + return $this->success(array_merge($campaign->toArray(), ['stats' => $stats])); + } + + public function storeCampaign(Request $request): JsonResponse + { + $tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'subject' => 'required|string|max:255', + 'preview_text' => 'nullable|string|max:255', + 'body_html' => 'nullable|string', + 'body_text' => 'nullable|string', + 'from_name' => 'nullable|string|max:255', + 'from_email' => 'nullable|email|max:255', + 'mailing_list_id'=> 'nullable|integer|exists:mailing_lists,id', + 'status' => 'nullable|string', + 'scheduled_at' => 'nullable|date', + ]); + + $validated['tenant_id'] = $tenantId; + $validated['created_by'] = $request->user()->id; + + $campaign = EmailCampaign::create($validated); + + return $this->success($campaign, 201); + } + + public function mailingLists(Request $request): JsonResponse + { + $tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $paginator = MailingList::where('tenant_id', $tenantId) + ->latest() + ->paginate(15); + + return $this->paginated($paginator); + } + + public function storeMailingList(Request $request): JsonResponse + { + $tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'description' => 'nullable|string', + 'is_active' => 'nullable|boolean', + ]); + + $validated['tenant_id'] = $tenantId; + + $list = MailingList::create($validated); + + return $this->success($list, 201); + } + + public function subscribers(Request $request): JsonResponse + { + $tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $query = Subscriber::where('tenant_id', $tenantId); + + if ($request->filled('mailing_list_id')) { + $query->whereHas('mailingLists', fn ($q) => $q->where('mailing_lists.id', $request->mailing_list_id)); + } + + if ($request->filled('status')) { + $query->where('status', $request->status); + } + + return $this->paginated($query->latest()->paginate(15)); + } +} diff --git a/erp/app/Http/Controllers/Api/V1/NotificationController.php b/erp/app/Http/Controllers/Api/V1/NotificationController.php new file mode 100644 index 00000000000..6634ff947db --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/NotificationController.php @@ -0,0 +1,62 @@ +has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + $userId = $request->user()->id; + + $notifications = ErpNotification::where('tenant_id', $tenantId) + ->where('user_id', $userId) + ->latest() + ->paginate(20); + + return $this->paginated($notifications); + } + + public function unreadCount(Request $request): JsonResponse + { + $tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + $userId = $request->user()->id; + + $count = ErpNotification::where('tenant_id', $tenantId) + ->where('user_id', $userId) + ->whereNull('read_at') + ->count(); + + return $this->success(['count' => $count]); + } + + public function markRead(Request $request, int $id): JsonResponse + { + $tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + $userId = $request->user()->id; + + $notification = ErpNotification::where('tenant_id', $tenantId) + ->where('user_id', $userId) + ->findOrFail($id); + + $notification->markAsRead(); + + return $this->success(['message' => 'Notification marked as read']); + } + + public function markAllRead(Request $request): JsonResponse + { + $tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + $userId = $request->user()->id; + + ErpNotification::where('tenant_id', $tenantId) + ->where('user_id', $userId) + ->whereNull('read_at') + ->update(['read_at' => now()]); + + return $this->success(['message' => 'All notifications marked as read']); + } +} diff --git a/erp/app/Http/Controllers/Api/V1/PaymentTermApiController.php b/erp/app/Http/Controllers/Api/V1/PaymentTermApiController.php new file mode 100644 index 00000000000..93c7c961d82 --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/PaymentTermApiController.php @@ -0,0 +1,184 @@ +tenantId($request); + $terms = PaymentTerm::where('tenant_id', $tenantId) + ->when($request->boolean('active_only'), fn ($q) => $q->where('is_active', true)) + ->orderBy('days') + ->get(); + + return $this->success($terms->map(fn ($t) => [ + 'id' => $t->id, + 'name' => $t->name, + 'days' => $t->days, + 'discount_days' => $t->discount_days, + 'discount_percent' => $t->discount_percent, + 'has_early_discount' => $t->has_early_discount, + 'display_label' => $t->display_label, + 'description' => $t->description, + 'is_active' => $t->is_active, + ])); + } + + public function storeTerm(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + + $data = $request->validate([ + 'name' => ['required', 'string', 'max:100'], + 'days' => ['required', 'integer', 'min:0'], + 'discount_days' => ['nullable', 'integer', 'min:0'], + 'discount_percent' => ['nullable', 'numeric', 'min:0', 'max:100'], + 'description' => ['nullable', 'string'], + ]); + + $term = PaymentTerm::create([...$data, 'tenant_id' => $tenantId, 'is_active' => true]); + + return $this->success($term, 201); + } + + public function updateTerm(Request $request, PaymentTerm $paymentTerm): JsonResponse + { + $data = $request->validate([ + 'name' => ['sometimes', 'string', 'max:100'], + 'days' => ['sometimes', 'integer', 'min:0'], + 'discount_days' => ['nullable', 'integer', 'min:0'], + 'discount_percent' => ['nullable', 'numeric', 'min:0', 'max:100'], + 'description' => ['nullable', 'string'], + 'is_active' => ['boolean'], + ]); + + $paymentTerm->update($data); + + return $this->success($paymentTerm->fresh()); + } + + public function destroyTerm(PaymentTerm $paymentTerm): JsonResponse + { + $paymentTerm->delete(); + return $this->success(['message' => 'Payment term deleted.']); + } + + // ── Payment Schedules ───────────────────────────────────────────────────── + + public function indexSchedules(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + $schedules = PaymentSchedule::where('tenant_id', $tenantId) + ->when($request->status, fn ($q) => $q->where('status', $request->status)) + ->with('items') + ->orderByDesc('created_at') + ->paginate(20); + + return $this->paginated($schedules); + } + + public function storeSchedule(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + + $data = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'total_amount' => ['required', 'numeric', 'min:0.01'], + 'currency' => ['nullable', 'string', 'max:3'], + 'frequency' => ['required', 'string', 'in:weekly,monthly,quarterly,yearly,custom'], + 'installments' => ['required', 'integer', 'min:1', 'max:120'], + 'start_date' => ['required', 'date'], + 'reference_type' => ['nullable', 'string', 'max:50'], + 'reference_id' => ['nullable', 'integer'], + 'notes' => ['nullable', 'string'], + ]); + + $schedule = PaymentSchedule::create([ + ...$data, + 'tenant_id' => $tenantId, + 'created_by' => $request->user()->id, + 'status' => 'active', + ]); + + $schedule->schedule_number = $schedule->generateScheduleNumber(); + $schedule->save(); + + $this->generateInstallments($schedule); + + return $this->success($schedule->load('items'), 201); + } + + public function showSchedule(PaymentSchedule $paymentSchedule): JsonResponse + { + return $this->success($paymentSchedule->load('items')); + } + + public function markInstallmentPaid(Request $request, PaymentSchedule $paymentSchedule, int $itemId): JsonResponse + { + $item = PaymentScheduleItem::where('payment_schedule_id', $paymentSchedule->id) + ->findOrFail($itemId); + + $data = $request->validate([ + 'paid_date' => ['nullable', 'date'], + ]); + + $item->markPaid($data['paid_date'] ?? null); + $paymentSchedule->recalculatePaidAmount(); + + return $this->success([ + 'item' => $item->fresh(), + 'schedule' => $paymentSchedule->fresh(), + ]); + } + + public function pauseSchedule(PaymentSchedule $paymentSchedule): JsonResponse + { + $paymentSchedule->pause(); + return $this->success($paymentSchedule->fresh()); + } + + public function cancelSchedule(PaymentSchedule $paymentSchedule): JsonResponse + { + $paymentSchedule->cancel(); + return $this->success($paymentSchedule->fresh()); + } + + private function generateInstallments(PaymentSchedule $schedule): void + { + $installmentAmount = round((float) $schedule->total_amount / $schedule->installments, 2); + $startDate = now()->parse($schedule->start_date); + + for ($i = 1; $i <= $schedule->installments; $i++) { + $dueDate = match ($schedule->frequency) { + 'weekly' => $startDate->copy()->addWeeks($i - 1), + 'quarterly' => $startDate->copy()->addMonths(($i - 1) * 3), + 'yearly' => $startDate->copy()->addYears($i - 1), + default => $startDate->copy()->addMonths($i - 1), // monthly / custom + }; + + PaymentScheduleItem::create([ + 'payment_schedule_id' => $schedule->id, + 'installment_number' => $i, + 'amount' => $i === $schedule->installments + ? (float) $schedule->total_amount - ($installmentAmount * ($schedule->installments - 1)) + : $installmentAmount, + 'due_date' => $dueDate->toDateString(), + 'status' => 'pending', + ]); + } + } + + private function tenantId(Request $request): int + { + return app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + } +} diff --git a/erp/app/Http/Controllers/Api/V1/PdfController.php b/erp/app/Http/Controllers/Api/V1/PdfController.php new file mode 100644 index 00000000000..e2c8b84896b --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/PdfController.php @@ -0,0 +1,72 @@ +findOrFail($id); + + $tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + if ((int) $invoice->tenant_id !== (int) $tenantId) { + abort(403, 'Access denied.'); + } + + $items = $invoice->items; + + $pdf = Pdf::loadView('pdfs.invoice', compact('invoice', 'items')); + + return $pdf->download("invoice-{$invoice->number}.pdf"); + } + + /** + * GET /api/v1/pdf/purchase-orders/{id} + * Generate and download a purchase order PDF. + */ + public function purchaseOrder(Request $request, int $id): mixed + { + $po = Po::with(['lines', 'vendor'])->findOrFail($id); + + $tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + if ((int) $po->tenant_id !== (int) $tenantId) { + abort(403, 'Access denied.'); + } + + $pdf = Pdf::loadView('pdfs.purchase-order', compact('po')); + + return $pdf->download("po-{$po->po_number}.pdf"); + } + + /** + * GET /api/v1/pdf/payslips/{id} + * Generate and download a payslip PDF. + */ + public function payslip(Request $request, int $id): mixed + { + $payrollRun = PayrollRun::findOrFail($id); + + $tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + if ((int) $payrollRun->tenant_id !== (int) $tenantId) { + abort(403, 'Access denied.'); + } + + $pdf = Pdf::loadView('pdfs.payslip', compact('payrollRun')); + + return $pdf->download("payslip-{$payrollRun->id}.pdf"); + } +} diff --git a/erp/app/Http/Controllers/Api/V1/PlanningApiController.php b/erp/app/Http/Controllers/Api/V1/PlanningApiController.php new file mode 100644 index 00000000000..fec5c0edcf5 --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/PlanningApiController.php @@ -0,0 +1,79 @@ +has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $query = Shift::where('tenant_id', $tenantId); + + if ($status = $request->query('status')) { + $query->where('status', $status); + } + + if ($employeeId = $request->query('employee_id')) { + $query->where('employee_id', $employeeId); + } + + $paginator = $query->latest('starts_at')->paginate(20); + + return $this->paginated($paginator); + } + + /** + * GET /api/v1/planning/shifts/{id} + */ + public function showShift(int $id): JsonResponse + { + $shift = Shift::with(['employee:id,name'])->findOrFail($id); + + return $this->success($shift); + } + + /** + * POST /api/v1/planning/shifts + */ + public function storeShift(Request $request): JsonResponse + { + $validated = $request->validate([ + 'employee_id' => 'required|integer|exists:users,id', + 'title' => 'required|string|max:255', + 'starts_at' => 'required|date', + 'ends_at' => 'required|date|after:starts_at', + 'break_minutes' => 'nullable|integer|min:0', + 'notes' => 'nullable|string', + ]); + + $tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $validated['tenant_id'] = $tenantId; + $validated['status'] = 'scheduled'; + + $shift = Shift::create($validated); + + return $this->success($shift, 201); + } + + /** + * GET /api/v1/planning/shifts/{id}/swaps + */ + public function swaps(Request $request, int $id): JsonResponse + { + $shift = Shift::findOrFail($id); + + $paginator = ShiftSwap::where('shift_id', $shift->id)->latest()->paginate(20); + + return $this->paginated($paginator); + } +} diff --git a/erp/app/Http/Controllers/Api/V1/PmApiController.php b/erp/app/Http/Controllers/Api/V1/PmApiController.php new file mode 100644 index 00000000000..e60fab31dc1 --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/PmApiController.php @@ -0,0 +1,156 @@ +query('status')) { + $query->where('status', $status); + } + + $paginator = $query->latest()->paginate(20); + + return $this->paginated($paginator); + } + + /** + * GET /api/v1/pm/projects/{id} + */ + public function showProject(int $id): JsonResponse + { + $project = Project::withCount('tasks')->with('milestones')->findOrFail($id); + + return $this->success($project); + } + + /** + * POST /api/v1/pm/projects + */ + public function storeProject(Request $request): JsonResponse + { + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'code' => 'nullable|string|max:50', + 'description' => 'nullable|string', + 'status' => 'nullable|string|in:draft,active,on_hold,completed,cancelled', + 'priority' => 'nullable|string|in:low,medium,high,critical', + 'budget' => 'nullable|numeric|min:0', + 'start_date' => 'nullable|date', + 'end_date' => 'nullable|date|after_or_equal:start_date', + 'client_name' => 'nullable|string|max:255', + 'manager_id' => 'nullable|integer|exists:users,id', + ]); + + $tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $validated['tenant_id'] = $tenantId; + $validated['created_by'] = $request->user()->id; + + $project = Project::create($validated); + + return $this->success($project, 201); + } + + /** + * GET /api/v1/pm/tasks + */ + public function tasks(Request $request): JsonResponse + { + $query = Task::query(); + + if ($projectId = $request->query('project_id')) { + $query->where('project_id', $projectId); + } + + if ($status = $request->query('status')) { + $query->where('status', $status); + } + + if ($assignedTo = $request->query('assigned_to')) { + $query->where('assignee_id', $assignedTo); + } + + $paginator = $query->latest()->paginate(20); + + return $this->paginated($paginator); + } + + /** + * GET /api/v1/pm/tasks/{id} + */ + public function showTask(int $id): JsonResponse + { + $task = Task::with('project:id,name')->findOrFail($id); + + return $this->success($task); + } + + /** + * POST /api/v1/pm/tasks + */ + public function storeTask(Request $request): JsonResponse + { + $validated = $request->validate([ + 'project_id' => 'required|integer|exists:projects,id', + 'title' => 'required|string|max:255', + 'description' => 'nullable|string', + 'status' => 'nullable|string|in:backlog,todo,in_progress,review,done,cancelled', + 'priority' => 'nullable|string|in:low,medium,high,critical', + 'assignee_id' => 'nullable|integer|exists:users,id', + 'sprint_id' => 'nullable|integer|exists:project_sprints,id', + 'start_date' => 'nullable|date', + 'due_date' => 'nullable|date', + 'estimated_hours' => 'nullable|numeric|min:0', + 'story_points' => 'nullable|integer|min:0', + 'parent_task_id' => 'nullable|integer|exists:tasks,id', + ]); + + $tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $validated['tenant_id'] = $tenantId; + $validated['created_by'] = $request->user()->id; + + $task = Task::create($validated); + + return $this->success($task, 201); + } + + /** + * PUT /api/v1/pm/tasks/{id} + */ + public function updateTask(Request $request, int $id): JsonResponse + { + $task = Task::findOrFail($id); + + $validated = $request->validate([ + 'title' => 'sometimes|string|max:255', + 'description' => 'nullable|string', + 'status' => 'nullable|string|in:backlog,todo,in_progress,review,done,cancelled', + 'priority' => 'nullable|string|in:low,medium,high,critical', + 'assignee_id' => 'nullable|integer|exists:users,id', + 'sprint_id' => 'nullable|integer|exists:project_sprints,id', + 'start_date' => 'nullable|date', + 'due_date' => 'nullable|date', + 'estimated_hours' => 'nullable|numeric|min:0', + 'actual_hours' => 'nullable|numeric|min:0', + 'story_points' => 'nullable|integer|min:0', + ]); + + $task->update($validated); + + return $this->success($task); + } +} diff --git a/erp/app/Http/Controllers/Api/V1/PosApiController.php b/erp/app/Http/Controllers/Api/V1/PosApiController.php new file mode 100644 index 00000000000..a859034f479 --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/PosApiController.php @@ -0,0 +1,110 @@ +query('status')) { + $query->where('status', $status); + } + + $paginator = $query->latest()->paginate(20); + + return $this->paginated($paginator); + } + + /** + * GET /api/v1/pos/sessions/{session}/orders + */ + public function sessionOrders(int $session): JsonResponse + { + $posSession = PosSession::findOrFail($session); + + $orders = $posSession->orders()->with('items')->latest()->paginate(20); + + return $this->paginated($orders); + } + + /** + * POST /api/v1/pos/orders + */ + public function createOrder(Request $request): JsonResponse + { + $validated = $request->validate([ + 'session_id' => 'required|integer|exists:pos_sessions,id', + 'customer_name' => 'nullable|string|max:255', + 'customer_email' => 'nullable|email|max:255', + 'discount_amount' => 'nullable|numeric|min:0', + 'tax_amount' => 'nullable|numeric|min:0', + 'amount_paid' => 'nullable|numeric|min:0', + 'payment_method' => 'nullable|string|max:50', + 'notes' => 'nullable|string', + 'items' => 'nullable|array', + 'items.*.product_id' => 'required_with:items|integer|exists:products,id', + 'items.*.quantity' => 'required_with:items|numeric|min:0', + 'items.*.unit_price' => 'required_with:items|numeric|min:0', + ]); + + $tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $items = $validated['items'] ?? []; + unset($validated['items']); + + $validated['tenant_id'] = $tenantId; + $validated['created_by'] = $request->user()->id; + $validated['served_by'] = $request->user()->id; + $validated['status'] = 'completed'; + + // Calculate subtotal + $subtotal = 0; + foreach ($items as $item) { + $subtotal += (float) $item['quantity'] * (float) $item['unit_price']; + } + + $validated['subtotal'] = $subtotal; + $validated['discount_amount'] = $validated['discount_amount'] ?? 0; + $validated['tax_amount'] = $validated['tax_amount'] ?? 0; + $validated['total'] = $subtotal - $validated['discount_amount'] + $validated['tax_amount']; + $validated['amount_paid'] = $validated['amount_paid'] ?? $validated['total']; + $validated['change_given'] = max(0, $validated['amount_paid'] - $validated['total']); + + $order = PosOrder::create($validated); + $order->receipt_number = $order->generateReceiptNumber(); + $order->save(); + + foreach ($items as $item) { + $lineTotal = (float) $item['quantity'] * (float) $item['unit_price']; + $order->items()->create([ + 'product_id' => $item['product_id'], + 'quantity' => $item['quantity'], + 'unit_price' => $item['unit_price'], + 'line_total' => $lineTotal, + ]); + } + + return $this->success($order->load('items'), 201); + } + + /** + * GET /api/v1/pos/orders/{order} + */ + public function showOrder(int $order): JsonResponse + { + $posOrder = PosOrder::with(['items.product:id,name,sku', 'session:id,name'])->findOrFail($order); + + return $this->success($posOrder); + } +} diff --git a/erp/app/Http/Controllers/Api/V1/PriceListApiController.php b/erp/app/Http/Controllers/Api/V1/PriceListApiController.php new file mode 100644 index 00000000000..0b4264e7656 --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/PriceListApiController.php @@ -0,0 +1,133 @@ +tenantId($request); + $priceLists = PriceList::where('tenant_id', $tenantId) + ->when($request->boolean('active_only'), fn ($q) => $q->where('is_active', true)) + ->withCount('items') + ->orderByDesc('is_default') + ->get(); + + return $this->success($priceLists); + } + + public function store(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + + $data = $request->validate([ + 'name' => ['required', 'string', 'max:100'], + 'description' => ['nullable', 'string'], + 'currency_code' => ['nullable', 'string', 'max:3'], + 'discount_percent' => ['nullable', 'numeric', 'min:0', 'max:100'], + 'valid_from' => ['nullable', 'date'], + 'valid_to' => ['nullable', 'date', 'after_or_equal:valid_from'], + 'is_default' => ['boolean'], + ]); + + if (! empty($data['is_default'])) { + PriceList::where('tenant_id', $tenantId)->update(['is_default' => false]); + } + + $priceList = PriceList::create([...$data, 'tenant_id' => $tenantId, 'is_active' => true]); + + return $this->success($priceList, 201); + } + + public function show(PriceList $priceList): JsonResponse + { + return $this->success($priceList->load('items.product:id,name,sku')); + } + + public function update(Request $request, PriceList $priceList): JsonResponse + { + $data = $request->validate([ + 'name' => ['sometimes', 'string', 'max:100'], + 'description' => ['nullable', 'string'], + 'discount_percent' => ['nullable', 'numeric', 'min:0', 'max:100'], + 'valid_from' => ['nullable', 'date'], + 'valid_to' => ['nullable', 'date'], + 'is_active' => ['boolean'], + 'is_default' => ['boolean'], + ]); + + if (! empty($data['is_default'])) { + PriceList::where('tenant_id', $priceList->tenant_id)->update(['is_default' => false]); + } + + $priceList->update($data); + + return $this->success($priceList->fresh()); + } + + public function destroy(PriceList $priceList): JsonResponse + { + $priceList->items()->delete(); + $priceList->delete(); + + return $this->success(['message' => 'Price list deleted.']); + } + + // ── Price List Items ────────────────────────────────────────────────────── + + public function addItem(Request $request, PriceList $priceList): JsonResponse + { + $tenantId = $this->tenantId($request); + + $data = $request->validate([ + 'product_id' => ['required', 'integer', 'exists:products,id'], + 'unit_price' => ['required', 'numeric', 'min:0'], + 'min_quantity' => ['nullable', 'integer', 'min:1'], + ]); + + $item = PriceListItem::updateOrCreate( + ['price_list_id' => $priceList->id, 'product_id' => $data['product_id'], 'min_quantity' => $data['min_quantity'] ?? 1], + ['tenant_id' => $tenantId, 'unit_price' => $data['unit_price']] + ); + + return $this->success($item->load('product:id,name,sku'), 201); + } + + public function removeItem(PriceList $priceList, PriceListItem $item): JsonResponse + { + $item->delete(); + return $this->success(['message' => 'Item removed.']); + } + + public function lookup(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + + $data = $request->validate([ + 'price_list_id' => ['required', 'integer', 'exists:price_lists,id'], + 'product_id' => ['required', 'integer', 'exists:products,id'], + 'quantity' => ['nullable', 'numeric', 'min:1'], + ]); + + $priceList = PriceList::where('tenant_id', $tenantId)->findOrFail($data['price_list_id']); + $price = $priceList->getPriceForProduct($data['product_id'], $data['quantity'] ?? 1); + + return $this->success([ + 'price_list_id' => $data['price_list_id'], + 'product_id' => $data['product_id'], + 'quantity' => $data['quantity'] ?? 1, + 'unit_price' => $price, + 'has_override' => $price !== null, + ]); + } + + private function tenantId(Request $request): int + { + return app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + } +} diff --git a/erp/app/Http/Controllers/Api/V1/ProductApiController.php b/erp/app/Http/Controllers/Api/V1/ProductApiController.php new file mode 100644 index 00000000000..55fc9f03959 --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/ProductApiController.php @@ -0,0 +1,107 @@ +query('search')) { + $query->where(function ($q) use ($search) { + $q->where('name', 'like', "%{$search}%") + ->orWhere('sku', 'like', "%{$search}%"); + }); + } + + if ($categoryId = $request->query('category_id')) { + $query->where('category_id', $categoryId); + } + + if ($request->has('is_active')) { + $query->where('is_active', filter_var($request->query('is_active'), FILTER_VALIDATE_BOOLEAN)); + } + + $paginator = $query->select(['id', 'name', 'sku', 'sale_price', 'cost_price', 'stock_quantity', 'is_active', 'category_id']) + ->latest() + ->paginate(20); + + return $this->paginated($paginator); + } + + /** + * GET /api/v1/products/{id} + */ + public function show(int $id): JsonResponse + { + $product = Product::with('category')->findOrFail($id); + + return $this->success($product); + } + + /** + * POST /api/v1/products + */ + public function store(Request $request): JsonResponse + { + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'sku' => 'required|string|max:100', + 'sale_price' => 'required|numeric|min:0', + 'cost_price' => 'required|numeric|min:0', + 'description' => 'nullable|string', + 'category_id' => 'nullable|integer|exists:product_categories,id', + 'is_active' => 'nullable|boolean', + 'reorder_point' => 'nullable|numeric|min:0', + ]); + + $tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + $validated['tenant_id'] = $tenantId; + + $product = Product::create($validated); + + return $this->success($product, 201); + } + + /** + * PUT /api/v1/products/{id} + */ + public function update(Request $request, int $id): JsonResponse + { + $product = Product::findOrFail($id); + + $validated = $request->validate([ + 'name' => 'sometimes|string|max:255', + 'sku' => 'sometimes|string|max:100', + 'sale_price' => 'sometimes|numeric|min:0', + 'cost_price' => 'sometimes|numeric|min:0', + 'description' => 'nullable|string', + 'category_id' => 'nullable|integer|exists:product_categories,id', + 'is_active' => 'nullable|boolean', + 'reorder_point' => 'nullable|numeric|min:0', + ]); + + $product->update($validated); + + return $this->success($product); + } + + /** + * DELETE /api/v1/products/{id} + */ + public function destroy(int $id): JsonResponse + { + $product = Product::findOrFail($id); + $product->delete(); + + return $this->success(['message' => 'Product deleted']); + } +} diff --git a/erp/app/Http/Controllers/Api/V1/ProductBundleApiController.php b/erp/app/Http/Controllers/Api/V1/ProductBundleApiController.php new file mode 100644 index 00000000000..d632020ada2 --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/ProductBundleApiController.php @@ -0,0 +1,140 @@ +tenantId($request); + $bundles = ProductBundle::where('tenant_id', $tenantId) + ->when($request->boolean('active_only'), fn ($q) => $q->where('is_active', true)) + ->withCount('items') + ->orderByDesc('created_at') + ->get() + ->map(fn ($b) => array_merge($b->toArray(), ['calculated_price' => $b->load('items.product')->calculatePrice()])); + + return $this->success($bundles); + } + + public function store(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + + $data = $request->validate([ + 'name' => ['required', 'string', 'max:100'], + 'sku' => ['nullable', 'string', 'max:50'], + 'description' => ['nullable', 'string'], + 'bundle_price' => ['nullable', 'numeric', 'min:0'], + 'items' => ['required', 'array', 'min:1'], + 'items.*.product_id' => ['required', 'integer', 'exists:products,id'], + 'items.*.quantity' => ['required', 'numeric', 'min:0.001'], + ]); + + $bundle = ProductBundle::create([ + 'tenant_id' => $tenantId, + 'name' => $data['name'], + 'sku' => $data['sku'] ?? null, + 'description' => $data['description'] ?? null, + 'bundle_price' => $data['bundle_price'] ?? null, + 'is_active' => true, + ]); + + foreach ($data['items'] as $item) { + ProductBundleItem::create([ + 'tenant_id' => $tenantId, + 'product_bundle_id' => $bundle->id, + 'product_id' => $item['product_id'], + 'quantity' => $item['quantity'], + ]); + } + + $bundle->load('items.product:id,name,sku,sale_price'); + + return $this->success(array_merge($bundle->toArray(), ['calculated_price' => $bundle->calculatePrice()]), 201); + } + + public function show(ProductBundle $productBundle): JsonResponse + { + $productBundle->load('items.product:id,name,sku,sale_price'); + + return $this->success(array_merge($productBundle->toArray(), ['calculated_price' => $productBundle->calculatePrice()])); + } + + public function update(Request $request, ProductBundle $productBundle): JsonResponse + { + $data = $request->validate([ + 'name' => ['sometimes', 'string', 'max:100'], + 'sku' => ['nullable', 'string', 'max:50'], + 'description' => ['nullable', 'string'], + 'bundle_price' => ['nullable', 'numeric', 'min:0'], + 'is_active' => ['boolean'], + ]); + + $productBundle->update($data); + + return $this->success($productBundle->fresh()->load('items.product:id,name,sku,sale_price')); + } + + public function destroy(ProductBundle $productBundle): JsonResponse + { + $productBundle->items()->delete(); + $productBundle->delete(); + + return $this->success(['message' => 'Bundle deleted.']); + } + + public function addItem(Request $request, ProductBundle $productBundle): JsonResponse + { + $tenantId = $this->tenantId($request); + + $data = $request->validate([ + 'product_id' => ['required', 'integer', 'exists:products,id'], + 'quantity' => ['required', 'numeric', 'min:0.001'], + ]); + + $item = ProductBundleItem::updateOrCreate( + ['product_bundle_id' => $productBundle->id, 'product_id' => $data['product_id']], + ['tenant_id' => $tenantId, 'quantity' => $data['quantity']] + ); + + return $this->success($item->load('product:id,name,sku,sale_price'), 201); + } + + public function removeItem(ProductBundle $productBundle, ProductBundleItem $item): JsonResponse + { + $item->delete(); + return $this->success(['message' => 'Item removed from bundle.']); + } + + public function price(ProductBundle $productBundle): JsonResponse + { + $productBundle->load('items.product'); + $calculatedPrice = $productBundle->calculatePrice(); + $savings = null; + + if ($productBundle->bundle_price !== null) { + $componentTotal = (float) $productBundle->items->sum(fn ($i) => ($i->product?->sale_price ?? 0) * $i->quantity); + $savings = $componentTotal > $productBundle->bundle_price + ? round($componentTotal - $productBundle->bundle_price, 2) + : 0; + } + + return $this->success([ + 'bundle_id' => $productBundle->id, + 'calculated_price' => $calculatedPrice, + 'has_fixed_price' => $productBundle->bundle_price !== null, + 'savings' => $savings, + ]); + } + + private function tenantId(Request $request): int + { + return app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + } +} diff --git a/erp/app/Http/Controllers/Api/V1/ProductVariantController.php b/erp/app/Http/Controllers/Api/V1/ProductVariantController.php new file mode 100644 index 00000000000..1442a8ffc76 --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/ProductVariantController.php @@ -0,0 +1,142 @@ +tenantId($request); + $attributes = ProductAttribute::where('tenant_id', $tenantId)->get(); + return $this->success($attributes); + } + + public function storeAttribute(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + + $data = $request->validate([ + 'name' => ['required', 'string', 'max:100'], + 'type' => ['required', 'in:text,select,color,size'], + 'options' => ['nullable', 'array'], + ]); + + $attribute = ProductAttribute::create([...$data, 'tenant_id' => $tenantId]); + return $this->success($attribute, 201); + } + + public function updateAttribute(Request $request, ProductAttribute $productAttribute): JsonResponse + { + $data = $request->validate([ + 'name' => ['sometimes', 'string', 'max:100'], + 'type' => ['sometimes', 'in:text,select,color,size'], + 'options' => ['nullable', 'array'], + ]); + + $productAttribute->update($data); + return $this->success($productAttribute->fresh()); + } + + public function destroyAttribute(ProductAttribute $productAttribute): JsonResponse + { + $productAttribute->delete(); + return $this->success(['message' => 'Attribute deleted.']); + } + + // ── Variants ───────────────────────────────────────────────── + + public function index(Request $request, Product $product): JsonResponse + { + $variants = $product->variants()->with('values.attribute')->get(); + return $this->success($variants); + } + + public function store(Request $request, Product $product): JsonResponse + { + $tenantId = $this->tenantId($request); + + $data = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'sku' => ['required', 'string', 'unique:product_variants,sku'], + 'price_adjustment' => ['nullable', 'numeric'], + 'stock_quantity' => ['nullable', 'integer', 'min:0'], + 'attributes' => ['nullable', 'array'], + 'attributes.*.attribute_id' => ['required', 'exists:product_attributes,id'], + 'attributes.*.value' => ['required', 'string'], + ]); + + $variant = ProductVariant::create([ + 'tenant_id' => $tenantId, + 'product_id' => $product->id, + 'name' => $data['name'], + 'sku' => $data['sku'], + 'price_adjustment' => $data['price_adjustment'] ?? 0, + 'stock_quantity' => $data['stock_quantity'] ?? 0, + ]); + + foreach ($data['attributes'] ?? [] as $attr) { + ProductVariantValue::create([ + 'tenant_id' => $tenantId, + 'variant_id' => $variant->id, + 'attribute_id' => $attr['attribute_id'], + 'value' => $attr['value'], + ]); + } + + return $this->success($variant->load('values.attribute'), 201); + } + + public function update(Request $request, Product $product, ProductVariant $variant): JsonResponse + { + $data = $request->validate([ + 'name' => ['sometimes', 'string', 'max:255'], + 'price_adjustment' => ['sometimes', 'numeric'], + 'stock_quantity' => ['sometimes', 'integer', 'min:0'], + 'is_active' => ['sometimes', 'boolean'], + ]); + + $variant->update($data); + return $this->success($variant->fresh()->load('values.attribute')); + } + + public function destroy(Product $product, ProductVariant $variant): JsonResponse + { + $variant->delete(); + return $this->success(['message' => 'Variant deleted.']); + } + + public function matrix(Request $request, Product $product): JsonResponse + { + $variants = $product->variants()->with('values.attribute')->active()->get(); + $attributes = $variants->flatMap(fn ($v) => $v->values)->pluck('attribute')->unique('id')->values(); + + return $this->success([ + 'product' => $product->only(['id', 'name', 'sale_price']), + 'attributes' => $attributes, + 'variants' => $variants->map(fn ($v) => [ + 'id' => $v->id, + 'name' => $v->name, + 'sku' => $v->sku, + 'price_adjustment' => $v->price_adjustment, + 'effective_price' => $v->effective_price, + 'stock_quantity' => $v->stock_quantity, + 'is_active' => $v->is_active, + 'attributes' => $v->values->mapWithKeys(fn ($val) => [$val->attribute?->name => $val->value]), + ]), + ]); + } + + private function tenantId(Request $request): int + { + return app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + } +} diff --git a/erp/app/Http/Controllers/Api/V1/PurchaseApiController.php b/erp/app/Http/Controllers/Api/V1/PurchaseApiController.php new file mode 100644 index 00000000000..3f164a15513 --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/PurchaseApiController.php @@ -0,0 +1,143 @@ +has('is_active')) { + $query->where('is_active', filter_var($request->query('is_active'), FILTER_VALIDATE_BOOLEAN)); + } + + $paginator = $query->latest()->paginate(20); + + return $this->paginated($paginator); + } + + /** + * POST /api/v1/purchase/vendors + */ + public function storeVendor(Request $request): JsonResponse + { + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'email' => 'nullable|email|max:255', + 'phone' => 'nullable|string|max:50', + 'currency' => 'nullable|string|max:10', + 'is_active' => 'nullable|boolean', + ]); + + $tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $validated['tenant_id'] = $tenantId; + + $vendor = PurchaseVendor::create($validated); + + return $this->success($vendor, 201); + } + + /** + * GET /api/v1/purchase/rfqs + */ + public function rfqs(Request $request): JsonResponse + { + $query = PurchaseRfq::with('vendor:id,name'); + + if ($status = $request->query('status')) { + $query->where('status', $status); + } + + if ($vendorId = $request->query('po_vendor_id')) { + $query->where('po_vendor_id', $vendorId); + } + + $paginator = $query->latest()->paginate(20); + + return $this->paginated($paginator); + } + + /** + * POST /api/v1/purchase/rfqs + */ + public function storeRfq(Request $request): JsonResponse + { + $validated = $request->validate([ + 'po_vendor_id' => 'required|integer|exists:po_vendors,id', + 'expected_delivery' => 'nullable|date', + 'notes' => 'nullable|string', + 'currency' => 'nullable|string|max:10', + ]); + + $tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $validated['tenant_id'] = $tenantId; + + $rfq = PurchaseRfq::create($validated); + + return $this->success($rfq, 201); + } + + /** + * GET /api/v1/purchase/purchase-orders + */ + public function purchaseOrders(Request $request): JsonResponse + { + $query = Po::with('vendor:id,name')->withCount('lines'); + + if ($status = $request->query('status')) { + $query->where('status', $status); + } + + if ($vendorId = $request->query('po_vendor_id')) { + $query->where('po_vendor_id', $vendorId); + } + + $paginator = $query->latest()->paginate(20); + + return $this->paginated($paginator); + } + + /** + * GET /api/v1/purchase/purchase-orders/{id} + */ + public function showPurchaseOrder(int $id): JsonResponse + { + $po = Po::with(['vendor', 'lines'])->findOrFail($id); + + return $this->success($po); + } + + /** + * POST /api/v1/purchase/purchase-orders/{id}/confirm + */ + public function confirmPurchaseOrder(int $id): JsonResponse + { + $po = Po::findOrFail($id); + $po->confirm(); + + return $this->success(['message' => 'Purchase order confirmed']); + } + + /** + * POST /api/v1/purchase/purchase-orders/{id}/receive + */ + public function receivePurchaseOrder(int $id): JsonResponse + { + $po = Po::findOrFail($id); + $po->receive(); + + return $this->success(['message' => 'Purchase order received']); + } +} diff --git a/erp/app/Http/Controllers/Api/V1/PurchaseRequisitionApiController.php b/erp/app/Http/Controllers/Api/V1/PurchaseRequisitionApiController.php new file mode 100644 index 00000000000..956ddab2e99 --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/PurchaseRequisitionApiController.php @@ -0,0 +1,150 @@ +tenantId($request); + $reqs = PurchaseRequisition::where('tenant_id', $tenantId) + ->with('requester:id,name', 'approver:id,name') + ->withCount('items') + ->when($request->input('status'), fn ($q, $s) => $q->where('status', $s)) + ->orderByDesc('created_at') + ->get() + ->map(fn ($r) => array_merge($r->toArray(), ['total_estimated_cost' => $r->load('items')->total_estimated_cost])); + + return $this->success($reqs); + } + + public function store(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + + $data = $request->validate([ + 'needed_by' => ['nullable', 'date'], + 'notes' => ['nullable', 'string'], + 'items' => ['required', 'array', 'min:1'], + 'items.*.product_id' => ['nullable', 'integer', 'exists:products,id'], + 'items.*.description' => ['required', 'string'], + 'items.*.quantity' => ['required', 'numeric', 'min:0.001'], + 'items.*.estimated_unit_cost' => ['nullable', 'numeric', 'min:0'], + ]); + + $ref = 'PR-' . strtoupper(uniqid()); + + $requisition = PurchaseRequisition::create([ + 'tenant_id' => $tenantId, + 'reference' => $ref, + 'requested_by' => $request->user()->id, + 'status' => 'draft', + 'needed_by' => $data['needed_by'] ?? null, + 'notes' => $data['notes'] ?? null, + ]); + + foreach ($data['items'] as $item) { + PurchaseRequisitionItem::create([ + 'purchase_requisition_id' => $requisition->id, + 'product_id' => $item['product_id'] ?? null, + 'description' => $item['description'], + 'quantity' => $item['quantity'], + 'estimated_unit_cost' => $item['estimated_unit_cost'] ?? 0, + ]); + } + + return $this->success($requisition->load('items.product:id,name,sku', 'requester:id,name'), 201); + } + + public function show(PurchaseRequisition $purchaseRequisition): JsonResponse + { + $purchaseRequisition->load('items.product:id,name,sku', 'requester:id,name', 'approver:id,name'); + + return $this->success(array_merge( + $purchaseRequisition->toArray(), + ['total_estimated_cost' => $purchaseRequisition->total_estimated_cost] + )); + } + + public function update(Request $request, PurchaseRequisition $purchaseRequisition): JsonResponse + { + if (! in_array($purchaseRequisition->status, ['draft', 'submitted'])) { + return $this->error('Only draft or submitted requisitions can be updated.', 422); + } + + $data = $request->validate([ + 'needed_by' => ['nullable', 'date'], + 'notes' => ['nullable', 'string'], + ]); + + $purchaseRequisition->update($data); + + return $this->success($purchaseRequisition->fresh()->load('items')); + } + + public function submit(PurchaseRequisition $purchaseRequisition): JsonResponse + { + if ($purchaseRequisition->status !== 'draft') { + return $this->error('Only draft requisitions can be submitted.', 422); + } + + $purchaseRequisition->update(['status' => 'submitted']); + + return $this->success($purchaseRequisition->fresh()); + } + + public function approve(Request $request, PurchaseRequisition $purchaseRequisition): JsonResponse + { + if ($purchaseRequisition->status !== 'submitted') { + return $this->error('Only submitted requisitions can be approved.', 422); + } + + $purchaseRequisition->update([ + 'status' => 'approved', + 'approved_by' => $request->user()->id, + 'approved_at' => now(), + ]); + + return $this->success($purchaseRequisition->fresh()->load('approver:id,name')); + } + + public function reject(Request $request, PurchaseRequisition $purchaseRequisition): JsonResponse + { + if ($purchaseRequisition->status !== 'submitted') { + return $this->error('Only submitted requisitions can be rejected.', 422); + } + + $data = $request->validate([ + 'rejection_reason' => ['required', 'string'], + ]); + + $purchaseRequisition->update([ + 'status' => 'rejected', + 'rejection_reason' => $data['rejection_reason'], + ]); + + return $this->success($purchaseRequisition->fresh()); + } + + public function destroy(PurchaseRequisition $purchaseRequisition): JsonResponse + { + if ($purchaseRequisition->status !== 'draft') { + return $this->error('Only draft requisitions can be deleted.', 422); + } + + $purchaseRequisition->items()->delete(); + $purchaseRequisition->delete(); + + return $this->success(['message' => 'Requisition deleted.']); + } + + private function tenantId(Request $request): int + { + return app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + } +} diff --git a/erp/app/Http/Controllers/Api/V1/QcApiController.php b/erp/app/Http/Controllers/Api/V1/QcApiController.php new file mode 100644 index 00000000000..e50c650937a --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/QcApiController.php @@ -0,0 +1,270 @@ +tenantId($request); + $checklists = QcChecklist::where('tenant_id', $tenantId) + ->withCount('items') + ->when($request->input('category'), fn ($q, $c) => $q->where('category', $c)) + ->when($request->boolean('active_only'), fn ($q) => $q->active()) + ->orderBy('name') + ->get(); + + return $this->success($checklists); + } + + public function storeChecklist(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + + $data = $request->validate([ + 'name' => ['required', 'string', 'max:100'], + 'description' => ['nullable', 'string'], + 'category' => ['nullable', 'in:incoming,process,final,audit'], + 'items' => ['nullable', 'array'], + 'items.*.description' => ['required', 'string'], + 'items.*.check_type' => ['required', 'in:pass_fail,measurement,text'], + 'items.*.expected_value' => ['nullable', 'string'], + 'items.*.unit' => ['nullable', 'string', 'max:20'], + 'items.*.is_required' => ['boolean'], + 'items.*.sequence' => ['integer', 'min:1'], + ]); + + $checklist = QcChecklist::create([ + 'tenant_id' => $tenantId, + 'name' => $data['name'], + 'description' => $data['description'] ?? null, + 'category' => $data['category'] ?? null, + 'is_active' => true, + 'created_by' => $request->user()->id, + ]); + + foreach ($data['items'] ?? [] as $i => $item) { + QcChecklistItem::create([ + 'tenant_id' => $tenantId, + 'checklist_id' => $checklist->id, + 'description' => $item['description'], + 'check_type' => $item['check_type'], + 'expected_value' => $item['expected_value'] ?? null, + 'unit' => $item['unit'] ?? null, + 'is_required' => $item['is_required'] ?? true, + 'sequence' => $item['sequence'] ?? ($i + 1), + ]); + } + + return $this->success($checklist->load('items'), 201); + } + + public function showChecklist(QcChecklist $qcChecklist): JsonResponse + { + return $this->success($qcChecklist->load('items')); + } + + public function destroyChecklist(QcChecklist $qcChecklist): JsonResponse + { + $qcChecklist->items()->delete(); + $qcChecklist->delete(); + + return $this->success(['message' => 'Checklist deleted.']); + } + + // ── Inspections ─────────────────────────────────────────────────────────── + + public function indexInspections(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + $inspections = QcInspection::where('tenant_id', $tenantId) + ->with('checklist:id,name', 'inspector:id,name') + ->withCount('results') + ->when($request->input('status'), fn ($q, $s) => $q->where('status', $s)) + ->orderByDesc('created_at') + ->get() + ->map(fn ($i) => array_merge($i->toArray(), ['pass_rate' => $i->load('results')->passRate()])); + + return $this->success($inspections); + } + + public function storeInspection(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + + $data = $request->validate([ + 'checklist_id' => ['required', 'integer', 'exists:quality_checklists,id'], + 'reference_type' => ['nullable', 'string', 'max:50'], + 'reference_id' => ['nullable', 'integer'], + 'notes' => ['nullable', 'string'], + ]); + + $inspection = QcInspection::create([ + 'tenant_id' => $tenantId, + 'checklist_id' => $data['checklist_id'], + 'reference_type' => $data['reference_type'] ?? null, + 'reference_id' => $data['reference_id'] ?? null, + 'inspector_id' => $request->user()->id, + 'status' => 'pending', + 'notes' => $data['notes'] ?? null, + ]); + + return $this->success($inspection->load('checklist.items', 'inspector:id,name'), 201); + } + + public function showInspection(QcInspection $qcInspection): JsonResponse + { + $qcInspection->load('checklist.items', 'results.item', 'inspector:id,name'); + + return $this->success(array_merge($qcInspection->toArray(), ['pass_rate' => $qcInspection->passRate()])); + } + + public function startInspection(QcInspection $qcInspection): JsonResponse + { + if ($qcInspection->status !== 'pending') { + return $this->error('Only pending inspections can be started.', 422); + } + + $qcInspection->start(); + + return $this->success($qcInspection->fresh()); + } + + public function recordResults(Request $request, QcInspection $qcInspection): JsonResponse + { + $tenantId = $this->tenantId($request); + + if ($qcInspection->status !== 'in_progress') { + return $this->error('Only in-progress inspections can have results recorded.', 422); + } + + $data = $request->validate([ + 'results' => ['required', 'array', 'min:1'], + 'results.*.checklist_item_id' => ['required', 'integer', 'exists:quality_checklist_items,id'], + 'results.*.result' => ['required', 'in:pass,fail,na'], + 'results.*.measured_value' => ['nullable', 'string'], + 'results.*.notes' => ['nullable', 'string'], + ]); + + foreach ($data['results'] as $resultData) { + QcInspectionResult::updateOrCreate( + ['inspection_id' => $qcInspection->id, 'checklist_item_id' => $resultData['checklist_item_id']], + [ + 'tenant_id' => $tenantId, + 'result' => $resultData['result'], + 'measured_value' => $resultData['measured_value'] ?? null, + 'notes' => $resultData['notes'] ?? null, + ] + ); + } + + return $this->success([ + 'results_recorded' => count($data['results']), + 'pass_rate' => $qcInspection->fresh()->load('results')->passRate(), + ]); + } + + public function completeInspection(Request $request, QcInspection $qcInspection): JsonResponse + { + if ($qcInspection->status !== 'in_progress') { + return $this->error('Only in-progress inspections can be completed.', 422); + } + + $data = $request->validate([ + 'outcome' => ['required', 'in:passed,failed'], + ]); + + $qcInspection->complete($data['outcome']); + + return $this->success(array_merge($qcInspection->fresh()->toArray(), [ + 'pass_rate' => $qcInspection->load('results')->passRate(), + ])); + } + + // ── Non-Conformance Reports ─────────────────────────────────────────────── + + public function indexNcr(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + $ncrs = NonConformanceReport::where('tenant_id', $tenantId) + ->with('reporter:id,name', 'assignee:id,name') + ->when($request->input('status'), fn ($q, $s) => $q->where('status', $s)) + ->when($request->input('severity'), fn ($q, $s) => $q->where('severity', $s)) + ->orderByDesc('created_at') + ->get() + ->map(fn ($n) => array_merge($n->toArray(), ['is_overdue' => $n->isOverdue()])); + + return $this->success($ncrs); + } + + public function storeNcr(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + + $data = $request->validate([ + 'inspection_id' => ['nullable', 'integer', 'exists:quality_inspections,id'], + 'title' => ['required', 'string', 'max:200'], + 'description' => ['required', 'string'], + 'severity' => ['required', 'in:minor,major,critical'], + 'assigned_to' => ['nullable', 'integer', 'exists:users,id'], + 'due_date' => ['nullable', 'date'], + ]); + + $ncr = NonConformanceReport::create([ + 'tenant_id' => $tenantId, + 'inspection_id' => $data['inspection_id'] ?? null, + 'ncr_number' => NonConformanceReport::generateNumber($tenantId), + 'title' => $data['title'], + 'description' => $data['description'], + 'severity' => $data['severity'], + 'status' => 'open', + 'reported_by' => $request->user()->id, + 'assigned_to' => $data['assigned_to'] ?? null, + 'due_date' => $data['due_date'] ?? null, + ]); + + return $this->success($ncr->load('reporter:id,name', 'assignee:id,name'), 201); + } + + public function resolveNcr(Request $request, NonConformanceReport $ncr): JsonResponse + { + if ($ncr->status !== 'open') { + return $this->error('Only open NCRs can be resolved.', 422); + } + + $data = $request->validate([ + 'root_cause' => ['required', 'string'], + 'corrective_action' => ['required', 'string'], + ]); + + $ncr->resolve($data['root_cause'], $data['corrective_action']); + + return $this->success($ncr->fresh()); + } + + public function closeNcr(NonConformanceReport $ncr): JsonResponse + { + if ($ncr->status !== 'resolved') { + return $this->error('Only resolved NCRs can be closed.', 422); + } + + $ncr->close(); + + return $this->success($ncr->fresh()); + } + + private function tenantId(Request $request): int + { + return app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + } +} diff --git a/erp/app/Http/Controllers/Api/V1/QualityControlApiController.php b/erp/app/Http/Controllers/Api/V1/QualityControlApiController.php new file mode 100644 index 00000000000..47ab4202e95 --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/QualityControlApiController.php @@ -0,0 +1,126 @@ +query('status')) { + $query->where('status', $status); + } + + if ($checklistId = $request->query('checklist_id')) { + $query->where('checklist_id', $checklistId); + } + + $paginator = $query->latest()->paginate(20); + + return $this->paginated($paginator); + } + + /** + * GET /api/v1/quality/inspections/{id} + */ + public function showInspection(int $id): JsonResponse + { + $inspection = QcInspection::with(['checklist', 'results'])->findOrFail($id); + + return $this->success($inspection); + } + + /** + * POST /api/v1/quality/inspections + */ + public function storeInspection(Request $request): JsonResponse + { + $validated = $request->validate([ + 'checklist_id' => 'required|integer', + 'reference_type' => 'nullable|string|max:100', + 'reference_id' => 'nullable|integer', + 'inspector_id' => 'nullable|integer', + 'status' => 'nullable|string|max:50', + 'notes' => 'nullable|string', + 'started_at' => 'nullable|date', + ]); + + $tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $inspection = QcInspection::create(array_merge($validated, [ + 'tenant_id' => $tenantId, + 'status' => $validated['status'] ?? 'pending', + ])); + + return $this->success($inspection, 201); + } + + /** + * PUT /api/v1/quality/inspections/{id} + */ + public function updateInspection(Request $request, int $id): JsonResponse + { + $inspection = QcInspection::findOrFail($id); + + $validated = $request->validate([ + 'checklist_id' => 'sometimes|integer', + 'reference_type' => 'nullable|string|max:100', + 'reference_id' => 'nullable|integer', + 'inspector_id' => 'nullable|integer', + 'status' => 'nullable|string|max:50', + 'notes' => 'nullable|string', + 'started_at' => 'nullable|date', + 'completed_at' => 'nullable|date', + ]); + + $inspection->update($validated); + + return $this->success($inspection->fresh()); + } + + /** + * GET /api/v1/quality/alerts + */ + public function alerts(Request $request): JsonResponse + { + $query = NonConformanceReport::query(); + + if ($status = $request->query('status')) { + $query->where('status', $status); + } + + if ($severity = $request->query('severity')) { + $query->where('severity', $severity); + } + + $paginator = $query->latest()->paginate(20); + + return $this->paginated($paginator); + } + + /** + * GET /api/v1/quality/checklists + */ + public function checklists(Request $request): JsonResponse + { + $query = QcChecklist::query(); + + if ($request->boolean('active')) { + $query->active(); + } + + $paginator = $query->latest()->paginate(20); + + return $this->paginated($paginator); + } +} diff --git a/erp/app/Http/Controllers/Api/V1/QuoteApiController.php b/erp/app/Http/Controllers/Api/V1/QuoteApiController.php new file mode 100644 index 00000000000..dd3595a6a2a --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/QuoteApiController.php @@ -0,0 +1,178 @@ +tenantId($request); + $quotes = Quote::where('tenant_id', $tenantId) + ->with('contact:id,name') + ->withCount('items') + ->when($request->input('status'), fn ($q, $s) => $q->where('status', $s)) + ->when($request->input('contact_id'), fn ($q, $c) => $q->where('contact_id', $c)) + ->orderByDesc('issue_date') + ->get(); + + return $this->success($quotes); + } + + public function store(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + + $data = $request->validate([ + 'contact_id' => ['required', 'integer', 'exists:contacts,id'], + 'issue_date' => ['required', 'date'], + 'expiry_date' => ['nullable', 'date', 'after_or_equal:issue_date'], + 'currency_code' => ['nullable', 'string', 'max:3'], + 'notes' => ['nullable', 'string'], + 'items' => ['required', 'array', 'min:1'], + 'items.*.description' => ['required', 'string'], + 'items.*.quantity' => ['required', 'numeric', 'min:0.001'], + 'items.*.unit_price' => ['required', 'numeric', 'min:0'], + 'items.*.tax_rate' => ['nullable', 'numeric', 'min:0', 'max:100'], + ]); + + $number = 'QT-' . now()->format('Y') . '-' . str_pad((string) (Quote::max('id') + 1), 5, '0', STR_PAD_LEFT); + + $quote = Quote::create([ + 'tenant_id' => $tenantId, + 'contact_id' => $data['contact_id'], + 'number' => $number, + 'issue_date' => $data['issue_date'], + 'expiry_date' => $data['expiry_date'] ?? null, + 'currency_code' => $data['currency_code'] ?? 'USD', + 'notes' => $data['notes'] ?? null, + 'created_by' => $request->user()->id, + ]); + + foreach ($data['items'] as $item) { + QuoteItem::create([ + 'quote_id' => $quote->id, + 'description' => $item['description'], + 'quantity' => $item['quantity'], + 'unit_price' => $item['unit_price'], + 'tax_rate' => $item['tax_rate'] ?? 0, + ]); + } + + return $this->success($quote->load('items', 'contact:id,name'), 201); + } + + public function show(Quote $quote): JsonResponse + { + return $this->success($quote->load('items', 'contact:id,name')); + } + + public function update(Request $request, Quote $quote): JsonResponse + { + if ($quote->status !== 'draft') { + return $this->error('Only draft quotes can be updated.', 422); + } + + $data = $request->validate([ + 'expiry_date' => ['nullable', 'date'], + 'notes' => ['nullable', 'string'], + ]); + + $quote->update($data); + + return $this->success($quote->fresh()->load('items')); + } + + public function destroy(Quote $quote): JsonResponse + { + if (! in_array($quote->status, ['draft', 'cancelled'])) { + return $this->error('Only draft or cancelled quotes can be deleted.', 422); + } + + $quote->items()->delete(); + $quote->delete(); + + return $this->success(['message' => 'Quote deleted.']); + } + + public function send(Quote $quote): JsonResponse + { + if ($quote->status !== 'draft') { + return $this->error('Only draft quotes can be sent.', 422); + } + + $quote->update(['status' => 'sent']); + + return $this->success($quote->fresh()); + } + + public function accept(Quote $quote): JsonResponse + { + if ($quote->status !== 'sent') { + return $this->error('Only sent quotes can be accepted.', 422); + } + + $quote->update(['status' => 'accepted']); + + return $this->success($quote->fresh()); + } + + public function decline(Quote $quote): JsonResponse + { + if ($quote->status !== 'sent') { + return $this->error('Only sent quotes can be declined.', 422); + } + + $quote->update(['status' => 'declined']); + + return $this->success($quote->fresh()); + } + + public function convertToInvoice(Quote $quote): JsonResponse + { + if ($quote->status !== 'accepted') { + return $this->error('Only accepted quotes can be converted to invoices.', 422); + } + + $quote->load('items'); + $number = 'INV-' . now()->format('Y') . '-' . str_pad((string) (Invoice::max('id') + 1), 5, '0', STR_PAD_LEFT); + + $invoice = Invoice::create([ + 'tenant_id' => $quote->tenant_id, + 'contact_id' => $quote->contact_id, + 'number' => $number, + 'issue_date' => now()->toDateString(), + 'due_date' => now()->addDays(30)->toDateString(), + 'status' => 'draft', + 'notes' => $quote->notes, + 'currency_code' => $quote->currency_code, + 'exchange_rate' => $quote->exchange_rate ?? 1, + 'created_by' => $quote->created_by, + ]); + + foreach ($quote->items as $item) { + InvoiceItem::create([ + 'invoice_id' => $invoice->id, + 'description' => $item->description, + 'quantity' => $item->quantity, + 'unit_price' => $item->unit_price, + 'tax_rate' => $item->tax_rate, + ]); + } + + return $this->success(['invoice' => $invoice->load('items'), 'quote_number' => $quote->number]); + } + + private function tenantId(Request $request): int + { + return app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + } +} diff --git a/erp/app/Http/Controllers/Api/V1/RecurringInvoiceApiController.php b/erp/app/Http/Controllers/Api/V1/RecurringInvoiceApiController.php new file mode 100644 index 00000000000..5238e15becb --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/RecurringInvoiceApiController.php @@ -0,0 +1,155 @@ +tenantId($request); + $invoices = RecurringInvoice::where('tenant_id', $tenantId) + ->with('contact:id,name') + ->withCount('invoices') + ->when($request->input('status'), fn ($q, $s) => $q->where('status', $s)) + ->orderByDesc('created_at') + ->get(); + + return $this->success($invoices); + } + + public function store(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + + $data = $request->validate([ + 'contact_id' => ['required', 'integer', 'exists:contacts,id'], + 'frequency' => ['required', 'in:weekly,monthly,quarterly,yearly'], + 'interval' => ['nullable', 'integer', 'min:1'], + 'start_date' => ['required', 'date'], + 'end_date' => ['nullable', 'date', 'after:start_date'], + 'due_days' => ['nullable', 'integer', 'min:0'], + 'auto_send' => ['boolean'], + 'currency_code' => ['nullable', 'string', 'max:3'], + 'notes' => ['nullable', 'string'], + 'reference_prefix' => ['nullable', 'string', 'max:20'], + 'items' => ['required', 'array', 'min:1'], + 'items.*.description' => ['required', 'string'], + 'items.*.quantity' => ['required', 'numeric', 'min:0.01'], + 'items.*.unit_price' => ['required', 'numeric', 'min:0'], + 'items.*.tax_rate' => ['nullable', 'numeric', 'min:0', 'max:100'], + ]); + + $recurring = RecurringInvoice::create([ + 'tenant_id' => $tenantId, + 'contact_id' => $data['contact_id'], + 'frequency' => $data['frequency'], + 'interval' => $data['interval'] ?? 1, + 'start_date' => $data['start_date'], + 'next_run_date' => $data['start_date'], + 'end_date' => $data['end_date'] ?? null, + 'due_days' => $data['due_days'] ?? 30, + 'auto_send' => $data['auto_send'] ?? false, + 'currency_code' => $data['currency_code'] ?? 'USD', + 'notes' => $data['notes'] ?? null, + 'reference_prefix' => $data['reference_prefix'] ?? 'REC-INV', + 'created_by' => $request->user()->id, + ]); + + foreach ($data['items'] as $item) { + RecurringInvoiceItem::create([ + 'recurring_invoice_id' => $recurring->id, + 'description' => $item['description'], + 'quantity' => $item['quantity'], + 'unit_price' => $item['unit_price'], + 'tax_rate' => $item['tax_rate'] ?? 0, + ]); + } + + return $this->success($recurring->load('items', 'contact:id,name'), 201); + } + + public function show(RecurringInvoice $recurringInvoice): JsonResponse + { + return $this->success($recurringInvoice->load('items', 'contact:id,name', 'invoices')); + } + + public function update(Request $request, RecurringInvoice $recurringInvoice): JsonResponse + { + $data = $request->validate([ + 'frequency' => ['sometimes', 'in:weekly,monthly,quarterly,yearly'], + 'interval' => ['integer', 'min:1'], + 'end_date' => ['nullable', 'date'], + 'due_days' => ['integer', 'min:0'], + 'auto_send' => ['boolean'], + 'notes' => ['nullable', 'string'], + 'status' => ['in:active,paused,ended,cancelled'], + ]); + + $recurringInvoice->update($data); + + return $this->success($recurringInvoice->fresh()->load('items', 'contact:id,name')); + } + + public function destroy(RecurringInvoice $recurringInvoice): JsonResponse + { + $recurringInvoice->items()->delete(); + $recurringInvoice->delete(); + + return $this->success(['message' => 'Recurring invoice deleted.']); + } + + public function pause(RecurringInvoice $recurringInvoice): JsonResponse + { + if ($recurringInvoice->status !== 'active') { + return $this->error('Only active recurring invoices can be paused.', 422); + } + + $recurringInvoice->update(['status' => 'paused']); + + return $this->success($recurringInvoice->fresh()); + } + + public function resume(RecurringInvoice $recurringInvoice): JsonResponse + { + if ($recurringInvoice->status !== 'paused') { + return $this->error('Only paused recurring invoices can be resumed.', 422); + } + + $recurringInvoice->update(['status' => 'active']); + + return $this->success($recurringInvoice->fresh()); + } + + public function generate(RecurringInvoice $recurringInvoice): JsonResponse + { + if ($recurringInvoice->status !== 'active') { + return $this->error('Only active recurring invoices can generate invoices.', 422); + } + + $invoice = $recurringInvoice->generateInvoice(); + + return $this->success(['invoice' => $invoice, 'next_run_date' => $recurringInvoice->fresh()->next_run_date]); + } + + public function due(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + $due = RecurringInvoice::where('tenant_id', $tenantId) + ->due() + ->with('contact:id,name') + ->withCount('invoices') + ->get(); + + return $this->success($due); + } + + private function tenantId(Request $request): int + { + return app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + } +} diff --git a/erp/app/Http/Controllers/Api/V1/RentalApiController.php b/erp/app/Http/Controllers/Api/V1/RentalApiController.php new file mode 100644 index 00000000000..a0031fc0880 --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/RentalApiController.php @@ -0,0 +1,99 @@ +query('status')) { + $query->where('status', $status); + } + + $paginator = $query->latest()->paginate(20); + + return $this->paginated($paginator); + } + + /** + * GET /api/v1/rental/{id} + */ + public function show(int $id): JsonResponse + { + $agreement = RentalAgreement::with('item')->findOrFail($id); + + return $this->success($agreement); + } + + /** + * POST /api/v1/rental + */ + public function store(Request $request): JsonResponse + { + $validated = $request->validate([ + 'rental_item_id' => 'required|integer', + 'customer_name' => 'required|string|max:255', + 'customer_email' => 'nullable|email|max:255', + 'start_date' => 'required|date', + 'end_date' => 'nullable|date|after_or_equal:start_date', + 'daily_rate' => 'required|numeric|min:0', + 'deposit' => 'nullable|numeric|min:0', + 'status' => 'nullable|string|max:50', + 'notes' => 'nullable|string', + ]); + + $tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $agreement = RentalAgreement::create(array_merge($validated, [ + 'tenant_id' => $tenantId, + 'status' => $validated['status'] ?? 'pending', + ])); + + return $this->success($agreement->load('item'), 201); + } + + /** + * PUT /api/v1/rental/{id} + */ + public function update(Request $request, int $id): JsonResponse + { + $agreement = RentalAgreement::findOrFail($id); + + $validated = $request->validate([ + 'rental_item_id' => 'sometimes|integer', + 'customer_name' => 'sometimes|string|max:255', + 'customer_email' => 'nullable|email|max:255', + 'start_date' => 'sometimes|date', + 'end_date' => 'nullable|date', + 'daily_rate' => 'sometimes|numeric|min:0', + 'deposit' => 'nullable|numeric|min:0', + 'status' => 'nullable|string|max:50', + 'notes' => 'nullable|string', + 'returned_at' => 'nullable|date', + ]); + + $agreement->update($validated); + + return $this->success($agreement->fresh()->load('item')); + } + + /** + * DELETE /api/v1/rental/{id} + */ + public function destroy(int $id): JsonResponse + { + $agreement = RentalAgreement::findOrFail($id); + $agreement->delete(); + + return $this->success(['message' => 'Rental agreement deleted.']); + } +} diff --git a/erp/app/Http/Controllers/Api/V1/ReorderController.php b/erp/app/Http/Controllers/Api/V1/ReorderController.php new file mode 100644 index 00000000000..176a4b59d8f --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/ReorderController.php @@ -0,0 +1,76 @@ +tenantId($request); + $threshold = (float) $request->get('threshold', 1.0); + + $products = Product::where('tenant_id', $tenantId) + ->where('is_active', true) + ->where('reorder_point', '>', 0) + ->with(['preferredSupplier']) + ->get() + ->map(fn ($p) => (object) [ + 'product' => $p, + 'stock' => (float) $p->stock_quantity, + ]) + ->filter(fn ($r) => ($r->stock / max($r->product->reorder_point, 1)) <= $threshold) + ->map(fn ($r) => [ + 'product_id' => $r->product->id, + 'sku' => $r->product->sku, + 'name' => $r->product->name, + 'current_stock' => $r->stock, + 'reorder_point' => (float) $r->product->reorder_point, + 'reorder_quantity' => (float) ($r->product->reorder_quantity ?? 0), + 'deficit' => max(0.0, (float) $r->product->reorder_point - $r->stock), + 'suggested_qty' => max((float) ($r->product->reorder_quantity ?? 0), (float) $r->product->reorder_point - $r->stock), + 'supplier' => $r->product->preferredSupplier?->only(['id', 'name']), + 'urgency' => $r->stock <= 0 ? 'critical' : ($r->stock < $r->product->reorder_point * 0.5 ? 'high' : 'medium'), + ]) + ->sortByDesc('urgency') + ->values(); + + return $this->success([ + 'total_items' => $products->count(), + 'critical_count' => $products->where('urgency', 'critical')->count(), + 'high_count' => $products->where('urgency', 'high')->count(), + 'suggestions' => $products, + ]); + } + + public function summary(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + + $total = Product::where('tenant_id', $tenantId)->where('is_active', true)->count(); + $lowStock = Product::where('tenant_id', $tenantId) + ->where('is_active', true) + ->where('reorder_point', '>', 0) + ->where('stock_quantity', '<=', \DB::raw('reorder_point')) + ->count(); + $outOfStock = Product::where('tenant_id', $tenantId) + ->where('is_active', true) + ->where('stock_quantity', '<=', 0) + ->count(); + + return $this->success([ + 'total_products' => $total, + 'low_stock_count' => $lowStock, + 'out_of_stock' => $outOfStock, + 'healthy_stock' => $total - $lowStock, + ]); + } + + private function tenantId(Request $request): int + { + return app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + } +} diff --git a/erp/app/Http/Controllers/Api/V1/RepairsApiController.php b/erp/app/Http/Controllers/Api/V1/RepairsApiController.php new file mode 100644 index 00000000000..673d5596656 --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/RepairsApiController.php @@ -0,0 +1,140 @@ +query('status')) { + $query->where('status', $status); + } + + $paginator = $query->latest()->paginate(20); + + return $this->paginated($paginator); + } + + /** + * GET /api/v1/repairs/{id} + */ + public function show(int $id): JsonResponse + { + $order = RepairOrder::with('lines')->findOrFail($id); + + return $this->success($order); + } + + /** + * POST /api/v1/repairs + */ + public function store(Request $request): JsonResponse + { + $validated = $request->validate([ + 'contact_id' => 'nullable|integer', + 'product_id' => 'nullable|integer', + 'product_name' => 'nullable|string|max:255', + 'serial_number' => 'nullable|string|max:100', + 'status' => 'nullable|string|max:50', + 'priority' => 'nullable|string|max:50', + 'diagnosis' => 'nullable|string', + 'internal_notes' => 'nullable|string', + 'warranty_claim' => 'nullable|boolean', + 'scheduled_date' => 'nullable|date', + 'estimated_hours' => 'nullable|numeric|min:0', + 'estimated_cost' => 'nullable|numeric|min:0', + 'assigned_to' => 'nullable|integer', + ]); + + $tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $order = RepairOrder::create(array_merge($validated, [ + 'tenant_id' => $tenantId, + 'order_number' => RepairOrder::generateOrderNumber($tenantId), + ])); + + return $this->success($order, 201); + } + + /** + * PUT /api/v1/repairs/{id} + */ + public function update(Request $request, int $id): JsonResponse + { + $order = RepairOrder::findOrFail($id); + + $validated = $request->validate([ + 'contact_id' => 'nullable|integer', + 'product_id' => 'nullable|integer', + 'product_name' => 'nullable|string|max:255', + 'serial_number' => 'nullable|string|max:100', + 'status' => 'nullable|string|max:50', + 'priority' => 'nullable|string|max:50', + 'diagnosis' => 'nullable|string', + 'internal_notes' => 'nullable|string', + 'warranty_claim' => 'nullable|boolean', + 'scheduled_date' => 'nullable|date', + 'started_at' => 'nullable|date', + 'completed_at' => 'nullable|date', + 'estimated_hours' => 'nullable|numeric|min:0', + 'actual_hours' => 'nullable|numeric|min:0', + 'estimated_cost' => 'nullable|numeric|min:0', + 'final_cost' => 'nullable|numeric|min:0', + 'assigned_to' => 'nullable|integer', + ]); + + $order->update($validated); + + return $this->success($order->fresh()); + } + + /** + * DELETE /api/v1/repairs/{id} + */ + public function destroy(int $id): JsonResponse + { + $order = RepairOrder::findOrFail($id); + $order->delete(); + + return $this->success(['message' => 'Repair order deleted.']); + } + + /** + * POST /api/v1/repairs/{id}/lines + */ + public function addLine(Request $request, int $id): JsonResponse + { + $order = RepairOrder::findOrFail($id); + + $validated = $request->validate([ + 'line_type' => 'required|string|in:part,labor,other', + 'product_id' => 'nullable|integer', + 'description' => 'nullable|string', + 'quantity' => 'required|numeric|min:0', + 'unit_price' => 'required|numeric|min:0', + 'is_invoiced' => 'nullable|boolean', + ]); + + $tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $total = (float) $validated['quantity'] * (float) $validated['unit_price']; + + $line = RepairLine::create(array_merge($validated, [ + 'tenant_id' => $tenantId, + 'repair_order_id' => $order->id, + 'total' => $total, + ])); + + return $this->success($line, 201); + } +} diff --git a/erp/app/Http/Controllers/Api/V1/ReportScheduleController.php b/erp/app/Http/Controllers/Api/V1/ReportScheduleController.php new file mode 100644 index 00000000000..c72a0c1ba9c --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/ReportScheduleController.php @@ -0,0 +1,81 @@ +has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + $schedules = ReportSchedule::where('tenant_id', $tenantId) + ->with('user:id,name') + ->latest() + ->get(); + + return $this->success($schedules); + } + + public function store(Request $request): JsonResponse + { + $tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $data = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'report_type' => ['required', 'in:financial,inventory,hr'], + 'frequency' => ['required', 'in:daily,weekly,monthly'], + 'recipients' => ['required', 'array', 'min:1'], + 'recipients.*' => ['email'], + 'filters' => ['nullable', 'array'], + 'is_active' => ['boolean'], + ]); + + $schedule = ReportSchedule::create([ + ...$data, + 'tenant_id' => $tenantId, + 'user_id' => $request->user()->id, + 'is_active' => $data['is_active'] ?? true, + 'next_run_at' => (new ReportSchedule())->fill($data)->computeNextRunAt(), + ]); + + return $this->success($schedule, 201); + } + + public function show(Request $request, ReportSchedule $reportSchedule): JsonResponse + { + return $this->success($reportSchedule->load('user:id,name')); + } + + public function update(Request $request, ReportSchedule $reportSchedule): JsonResponse + { + $data = $request->validate([ + 'name' => ['sometimes', 'string', 'max:255'], + 'report_type' => ['sometimes', 'in:financial,inventory,hr'], + 'frequency' => ['sometimes', 'in:daily,weekly,monthly'], + 'recipients' => ['sometimes', 'array', 'min:1'], + 'recipients.*' => ['email'], + 'filters' => ['nullable', 'array'], + 'is_active' => ['boolean'], + ]); + + $reportSchedule->update($data); + + return $this->success($reportSchedule->fresh()); + } + + public function destroy(ReportSchedule $reportSchedule): JsonResponse + { + $reportSchedule->delete(); + return $this->success(['message' => 'Report schedule deleted.']); + } + + public function sendNow(ReportSchedule $reportSchedule): JsonResponse + { + SendScheduledReportJob::dispatch($reportSchedule); + return $this->success(['message' => 'Report queued for delivery.']); + } +} diff --git a/erp/app/Http/Controllers/Api/V1/ReportsController.php b/erp/app/Http/Controllers/Api/V1/ReportsController.php new file mode 100644 index 00000000000..83edc6ea98f --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/ReportsController.php @@ -0,0 +1,99 @@ +has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + $year = $request->integer('year', now()->year); + + $invoiceSummary = Invoice::where('tenant_id', $tenantId) + ->whereYear('created_at', $year) + ->selectRaw('status, COUNT(*) as count') + ->groupBy('status') + ->get(); + + $monthlyRevenue = DB::table('invoices') + ->join('invoice_items', 'invoices.id', '=', 'invoice_items.invoice_id') + ->where('invoices.tenant_id', $tenantId) + ->where('invoices.status', 'paid') + ->whereYear('invoices.created_at', $year) + ->selectRaw("strftime('%m', invoices.created_at) as month, SUM(invoice_items.quantity * invoice_items.unit_price) as revenue") + ->groupBy('month') + ->orderBy('month') + ->get(); + + $totalExpenses = DB::table('bills') + ->join('bill_items', 'bills.id', '=', 'bill_items.bill_id') + ->where('bills.tenant_id', $tenantId) + ->whereYear('bills.created_at', $year) + ->whereNull('bills.deleted_at') + ->sum(DB::raw('bill_items.quantity * bill_items.unit_price')); + + return $this->success([ + 'year' => $year, + 'invoice_summary' => $invoiceSummary, + 'monthly_revenue' => $monthlyRevenue, + 'total_expenses' => $totalExpenses ?? 0, + ]); + } + + public function inventory(Request $request): JsonResponse + { + $tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $stockStats = Product::where('tenant_id', $tenantId) + ->selectRaw('COUNT(*) as total_products, SUM(stock_quantity * cost_price) as stock_value') + ->first(); + + $lowStock = Product::where('tenant_id', $tenantId) + ->whereColumn('stock_quantity', '<=', 'reorder_point') + ->where('reorder_point', '>', 0) + ->count(); + + $recentMovements = StockMovement::where('tenant_id', $tenantId) + ->with('product:id,name,sku') + ->latest() + ->limit(10) + ->get(['id', 'product_id', 'type', 'quantity', 'created_at']); + + return $this->success([ + 'total_products' => $stockStats?->total_products ?? 0, + 'stock_value' => $stockStats?->stock_value ?? 0, + 'low_stock_count' => $lowStock, + 'recent_movements' => $recentMovements, + ]); + } + + public function hr(Request $request): JsonResponse + { + $tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $headcount = Employee::where('tenant_id', $tenantId) + ->selectRaw('status, COUNT(*) as count') + ->groupBy('status') + ->get(); + + $payrollSummary = PayrollRun::where('tenant_id', $tenantId) + ->whereYear('created_at', now()->year) + ->selectRaw('SUM(total_gross) as total_gross, SUM(total_net) as total_net, COUNT(*) as run_count') + ->first(); + + return $this->success([ + 'headcount' => $headcount, + 'payroll_summary' => $payrollSummary, + ]); + } +} diff --git a/erp/app/Http/Controllers/Api/V1/SalesOrderApiController.php b/erp/app/Http/Controllers/Api/V1/SalesOrderApiController.php new file mode 100644 index 00000000000..fa1162285b4 --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/SalesOrderApiController.php @@ -0,0 +1,148 @@ +tenantId($request); + $orders = SalesOrder::where('tenant_id', $tenantId) + ->with('contact:id,name') + ->withCount('items') + ->when($request->input('status'), fn ($q, $s) => $q->where('status', $s)) + ->when($request->input('contact_id'), fn ($q, $c) => $q->where('contact_id', $c)) + ->orderByDesc('order_date') + ->get(); + + return $this->success($orders); + } + + public function store(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + + $data = $request->validate([ + 'contact_id' => ['required', 'integer', 'exists:contacts,id'], + 'order_date' => ['required', 'date'], + 'expected_date' => ['nullable', 'date', 'after_or_equal:order_date'], + 'currency_code' => ['nullable', 'string', 'max:3'], + 'notes' => ['nullable', 'string'], + 'items' => ['required', 'array', 'min:1'], + 'items.*.product_id' => ['nullable', 'integer', 'exists:products,id'], + 'items.*.description' => ['required', 'string'], + 'items.*.quantity' => ['required', 'numeric', 'min:0.001'], + 'items.*.unit_price' => ['required', 'numeric', 'min:0'], + 'items.*.tax_rate' => ['nullable', 'numeric', 'min:0', 'max:100'], + ]); + + $soNumber = 'SO-' . now()->format('Y') . '-' . str_pad((string) (SalesOrder::max('id') + 1), 5, '0', STR_PAD_LEFT); + + $order = SalesOrder::create([ + 'tenant_id' => $tenantId, + 'contact_id' => $data['contact_id'], + 'number' => $soNumber, + 'order_date' => $data['order_date'], + 'expected_date' => $data['expected_date'] ?? null, + 'currency_code' => $data['currency_code'] ?? 'USD', + 'notes' => $data['notes'] ?? null, + 'status' => 'draft', + 'created_by' => $request->user()->id, + ]); + + foreach ($data['items'] as $item) { + $qty = (float) $item['quantity']; + $price = (float) $item['unit_price']; + $tax = (float) ($item['tax_rate'] ?? 0); + + SalesOrderItem::create([ + 'sales_order_id' => $order->id, + 'product_id' => $item['product_id'] ?? null, + 'description' => $item['description'], + 'quantity' => $qty, + 'unit_price' => $price, + 'tax_rate' => $tax, + 'line_total' => round($qty * $price * (1 + $tax / 100), 2), + ]); + } + + return $this->success($order->load('items.product:id,name,sku', 'contact:id,name'), 201); + } + + public function show(SalesOrder $salesOrder): JsonResponse + { + return $this->success($salesOrder->load('items.product:id,name,sku', 'contact:id,name', 'generatedInvoice')); + } + + public function update(Request $request, SalesOrder $salesOrder): JsonResponse + { + if (! in_array($salesOrder->status, ['draft'])) { + return $this->error('Only draft orders can be updated.', 422); + } + + $data = $request->validate([ + 'expected_date' => ['nullable', 'date'], + 'notes' => ['nullable', 'string'], + 'currency_code' => ['nullable', 'string', 'max:3'], + ]); + + $salesOrder->update($data); + + return $this->success($salesOrder->fresh()->load('items')); + } + + public function destroy(SalesOrder $salesOrder): JsonResponse + { + if (! in_array($salesOrder->status, ['draft', 'cancelled'])) { + return $this->error('Only draft or cancelled orders can be deleted.', 422); + } + + $salesOrder->items()->delete(); + $salesOrder->delete(); + + return $this->success(['message' => 'Sales order deleted.']); + } + + public function confirm(SalesOrder $salesOrder): JsonResponse + { + if ($salesOrder->status !== 'draft') { + return $this->error('Only draft orders can be confirmed.', 422); + } + + $salesOrder->update(['status' => 'confirmed']); + + return $this->success($salesOrder->fresh()); + } + + public function cancel(SalesOrder $salesOrder): JsonResponse + { + if (in_array($salesOrder->status, ['invoiced', 'cancelled'])) { + return $this->error("Cannot cancel order in status '{$salesOrder->status}'.", 422); + } + + $salesOrder->update(['status' => 'cancelled']); + + return $this->success($salesOrder->fresh()); + } + + public function convertToInvoice(SalesOrder $salesOrder): JsonResponse + { + try { + $invoice = $salesOrder->convertToInvoice(); + } catch (\Throwable $e) { + return $this->error($e->getMessage(), 422); + } + + return $this->success(['invoice' => $invoice, 'sales_order_status' => $salesOrder->fresh()->status]); + } + + private function tenantId(Request $request): int + { + return app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + } +} diff --git a/erp/app/Http/Controllers/Api/V1/SearchController.php b/erp/app/Http/Controllers/Api/V1/SearchController.php new file mode 100644 index 00000000000..15f85e0cf46 --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/SearchController.php @@ -0,0 +1,142 @@ +validate(['q' => 'required|string|min:2|max:100']); + $q = $request->q; + $tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + $results = []; + + // Invoices + Invoice::where('tenant_id', $tenantId) + ->where(function ($query) use ($q) { + $query->where('number', 'like', "%{$q}%") + ->orWhereHas('contact', fn ($q2) => $q2->where('name', 'like', "%{$q}%")); + }) + ->limit(5)->get() + ->each(function ($inv) use (&$results) { + $results[] = [ + 'module' => 'invoice', + 'id' => $inv->id, + 'title' => "Invoice #{$inv->number}", + 'subtitle' => $inv->status, + 'url' => "/finance/invoices/{$inv->id}", + ]; + }); + + // Contacts + Contact::where('tenant_id', $tenantId) + ->where(function ($query) use ($q) { + $query->where('name', 'like', "%{$q}%") + ->orWhere('email', 'like', "%{$q}%"); + }) + ->limit(5)->get() + ->each(function ($c) use (&$results) { + $results[] = [ + 'module' => 'contact', + 'id' => $c->id, + 'title' => $c->name, + 'subtitle' => $c->type, + 'url' => "/finance/contacts/{$c->id}", + ]; + }); + + // Products + Product::where('tenant_id', $tenantId) + ->where(function ($query) use ($q) { + $query->where('name', 'like', "%{$q}%") + ->orWhere('sku', 'like', "%{$q}%"); + }) + ->limit(5)->get() + ->each(function ($p) use (&$results) { + $results[] = [ + 'module' => 'product', + 'id' => $p->id, + 'title' => $p->name, + 'subtitle' => $p->sku, + 'url' => "/inventory/products/{$p->id}", + ]; + }); + + // Employees + Employee::where('tenant_id', $tenantId) + ->where(function ($query) use ($q) { + $query->where('first_name', 'like', "%{$q}%") + ->orWhere('last_name', 'like', "%{$q}%") + ->orWhere('email', 'like', "%{$q}%"); + }) + ->limit(5)->get() + ->each(function ($e) use (&$results) { + $results[] = [ + 'module' => 'employee', + 'id' => $e->id, + 'title' => "{$e->first_name} {$e->last_name}", + 'subtitle' => $e->email, + 'url' => "/hr/employees/{$e->id}", + ]; + }); + + // CRM Leads + CrmLead::where('tenant_id', $tenantId) + ->where(function ($query) use ($q) { + $query->where('contact_name', 'like', "%{$q}%") + ->orWhere('company_name', 'like', "%{$q}%") + ->orWhere('email', 'like', "%{$q}%") + ->orWhere('reference', 'like', "%{$q}%"); + }) + ->limit(5)->get() + ->each(function ($l) use (&$results) { + $results[] = [ + 'module' => 'lead', + 'id' => $l->id, + 'title' => $l->contact_name ?? $l->company_name ?? $l->reference, + 'subtitle' => $l->status ?? '', + 'url' => "/crm/leads/{$l->id}", + ]; + }); + + // Projects + Project::where('tenant_id', $tenantId) + ->where('name', 'like', "%{$q}%") + ->limit(5)->get() + ->each(function ($p) use (&$results) { + $results[] = [ + 'module' => 'project', + 'id' => $p->id, + 'title' => $p->name, + 'subtitle' => $p->status, + 'url' => "/pm/projects/{$p->id}", + ]; + }); + + // Purchase Orders + PurchaseOrder::where('tenant_id', $tenantId) + ->where('po_number', 'like', "%{$q}%") + ->limit(5)->get() + ->each(function ($po) use (&$results) { + $results[] = [ + 'module' => 'purchase_order', + 'id' => $po->id, + 'title' => "PO #{$po->po_number}", + 'subtitle' => $po->status, + 'url' => "/purchase/orders/{$po->id}", + ]; + }); + + return $this->success(['query' => $q, 'results' => $results, 'total' => count($results)]); + } +} diff --git a/erp/app/Http/Controllers/Api/V1/SelfServiceController.php b/erp/app/Http/Controllers/Api/V1/SelfServiceController.php new file mode 100644 index 00000000000..0e8e3735959 --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/SelfServiceController.php @@ -0,0 +1,152 @@ +resolveEmployee($request); + + if (! $employee) { + return $this->error('No employee profile linked to your account.', 404); + } + + return $this->success($employee->load(['department:id,name', 'leaveBalances.leaveType:id,name'])); + } + + public function updateProfile(Request $request): JsonResponse + { + $employee = $this->resolveEmployee($request); + + if (! $employee) { + return $this->error('No employee profile linked to your account.', 404); + } + + $data = $request->validate([ + 'phone' => ['nullable', 'string', 'max:50'], + ]); + + $employee->update($data); + + return $this->success($employee->fresh()); + } + + public function payslips(Request $request): JsonResponse + { + $employee = $this->resolveEmployee($request); + + if (! $employee) { + return $this->error('No employee profile linked to your account.', 404); + } + + $payslips = Payslip::where('employee_id', $employee->id) + ->with('payrollRun:id,period_start,period_end,status') + ->orderByDesc('created_at') + ->paginate(12); + + return $this->paginated($payslips); + } + + public function leaveRequests(Request $request): JsonResponse + { + $employee = $this->resolveEmployee($request); + + if (! $employee) { + return $this->error('No employee profile linked to your account.', 404); + } + + $requests = LeaveRequest::where('employee_id', $employee->id) + ->when($request->status, fn ($q) => $q->where('status', $request->status)) + ->with('leaveType:id,name') + ->orderByDesc('created_at') + ->paginate(20); + + return $this->paginated($requests); + } + + public function applyLeave(Request $request): JsonResponse + { + $employee = $this->resolveEmployee($request); + + if (! $employee) { + return $this->error('No employee profile linked to your account.', 404); + } + + $data = $request->validate([ + 'leave_type_id' => ['required', 'integer', 'exists:leave_types,id'], + 'start_date' => ['required', 'date', 'after_or_equal:today'], + 'end_date' => ['required', 'date', 'after_or_equal:start_date'], + 'reason' => ['nullable', 'string', 'max:500'], + ]); + + $days = (int) now()->parse($data['start_date'])->diffInWeekdays(now()->parse($data['end_date'])) + 1; + + $leaveRequest = LeaveRequest::create([ + 'tenant_id' => $employee->tenant_id, + 'employee_id' => $employee->id, + 'leave_type_id' => $data['leave_type_id'], + 'start_date' => $data['start_date'], + 'end_date' => $data['end_date'], + 'days' => $days, + 'days_requested' => $days, + 'reason' => $data['reason'] ?? null, + 'notes' => $data['reason'] ?? null, + 'status' => 'pending', + ]); + + return $this->success($leaveRequest->load('leaveType:id,name'), 201); + } + + public function expenseClaims(Request $request): JsonResponse + { + $employee = $this->resolveEmployee($request); + + if (! $employee) { + return $this->error('No employee profile linked to your account.', 404); + } + + $claims = ExpenseClaim::where('employee_id', $employee->id) + ->when($request->status, fn ($q) => $q->where('status', $request->status)) + ->with('items') + ->orderByDesc('created_at') + ->paginate(20); + + return $this->paginated($claims); + } + + public function summary(Request $request): JsonResponse + { + $employee = $this->resolveEmployee($request); + + if (! $employee) { + return $this->error('No employee profile linked to your account.', 404); + } + + $pendingLeave = LeaveRequest::where('employee_id', $employee->id)->where('status', 'pending')->count(); + $pendingExpense = ExpenseClaim::where('employee_id', $employee->id)->where('status', 'submitted')->count(); + $totalPayslips = Payslip::where('employee_id', $employee->id)->count(); + + return $this->success([ + 'employee_id' => $employee->id, + 'name' => $employee->full_name, + 'position' => $employee->position, + 'department' => $employee->department?->name, + 'pending_leave_requests' => $pendingLeave, + 'pending_expense_claims' => $pendingExpense, + 'total_payslips' => $totalPayslips, + ]); + } + + private function resolveEmployee(Request $request): ?Employee + { + return Employee::where('user_id', $request->user()->id)->first(); + } +} diff --git a/erp/app/Http/Controllers/Api/V1/ShiftOvertimeApiController.php b/erp/app/Http/Controllers/Api/V1/ShiftOvertimeApiController.php new file mode 100644 index 00000000000..cdde6dc4ab0 --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/ShiftOvertimeApiController.php @@ -0,0 +1,209 @@ +has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + } + + // ── Shift Templates ─────────────────────────────────────────────────────── + + public function indexTemplates(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + $templates = ShiftTemplate::where('tenant_id', $tenantId) + ->withCount('assignments') + ->when($request->boolean('active_only'), fn ($q) => $q->where('is_active', true)) + ->orderBy('name') + ->get(); + + return $this->success($templates); + } + + public function storeTemplate(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + + $data = $request->validate([ + 'name' => ['required', 'string', 'max:100'], + 'start_time' => ['required', 'date_format:H:i'], + 'end_time' => ['required', 'date_format:H:i'], + 'break_minutes' => ['nullable', 'integer', 'min:0'], + 'days_of_week' => ['nullable', 'array'], + 'color' => ['nullable', 'string', 'max:20'], + ]); + + $template = ShiftTemplate::create([ + 'tenant_id' => $tenantId, + 'name' => $data['name'], + 'start_time' => $data['start_time'], + 'end_time' => $data['end_time'], + 'break_minutes' => $data['break_minutes'] ?? 0, + 'days_of_week' => $data['days_of_week'] ?? null, + 'color' => $data['color'] ?? '#6366f1', + 'is_active' => true, + ]); + + return $this->success($template, 201); + } + + public function updateTemplate(Request $request, ShiftTemplate $shiftTemplate): JsonResponse + { + $data = $request->validate([ + 'name' => ['sometimes', 'string', 'max:100'], + 'start_time' => ['sometimes', 'date_format:H:i'], + 'end_time' => ['sometimes', 'date_format:H:i'], + 'break_minutes' => ['nullable', 'integer', 'min:0'], + 'days_of_week' => ['nullable', 'array'], + 'color' => ['nullable', 'string', 'max:20'], + 'is_active' => ['boolean'], + ]); + + $shiftTemplate->update($data); + + return $this->success($shiftTemplate->fresh()); + } + + public function destroyTemplate(ShiftTemplate $shiftTemplate): JsonResponse + { + $shiftTemplate->delete(); + + return $this->success(['message' => 'Shift template deleted.']); + } + + // ── Shift Assignments ───────────────────────────────────────────────────── + + public function indexAssignments(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + $assignments = ShiftAssignment::where('tenant_id', $tenantId) + ->with('employee:id,first_name,last_name', 'shiftTemplate:id,name,start_time,end_time') + ->when($request->input('employee_id'), fn ($q, $e) => $q->where('employee_id', $e)) + ->when($request->input('from'), fn ($q, $d) => $q->whereDate('assigned_date', '>=', $d)) + ->when($request->input('to'), fn ($q, $d) => $q->whereDate('assigned_date', '<=', $d)) + ->orderBy('assigned_date') + ->get(); + + return $this->success($assignments); + } + + public function storeAssignment(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + + $data = $request->validate([ + 'shift_template_id' => ['required', 'integer', 'exists:shift_templates,id'], + 'employee_id' => ['required', 'integer', 'exists:employees,id'], + 'assigned_date' => ['required', 'date'], + 'notes' => ['nullable', 'string'], + ]); + + $assignment = ShiftAssignment::create([ + 'tenant_id' => $tenantId, + 'shift_template_id' => $data['shift_template_id'], + 'employee_id' => $data['employee_id'], + 'assigned_date' => $data['assigned_date'], + 'notes' => $data['notes'] ?? null, + 'status' => 'scheduled', + ]); + + return $this->success($assignment->load('employee:id,first_name,last_name', 'shiftTemplate:id,name,start_time,end_time'), 201); + } + + public function destroyAssignment(ShiftAssignment $shiftAssignment): JsonResponse + { + $shiftAssignment->delete(); + + return $this->success(['message' => 'Shift assignment removed.']); + } + + // ── Overtime Requests ───────────────────────────────────────────────────── + + public function indexOvertime(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + $requests = OvertimeRequest::where('tenant_id', $tenantId) + ->with('employee:id,first_name,last_name') + ->when($request->input('status'), fn ($q, $s) => $q->where('status', $s)) + ->when($request->input('employee_id'), fn ($q, $e) => $q->where('employee_id', $e)) + ->orderByDesc('work_date') + ->get() + ->map(fn ($r) => array_merge($r->toArray(), ['total_pay' => $r->total_pay])); + + return $this->success($requests); + } + + public function storeOvertime(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + + $data = $request->validate([ + 'employee_id' => ['required', 'integer', 'exists:employees,id'], + 'work_date' => ['required', 'date'], + 'hours' => ['required', 'numeric', 'min:0.5', 'max:12'], + 'rate_multiplier' => ['nullable', 'numeric', 'min:1'], + 'reason' => ['nullable', 'string'], + ]); + + $overtime = OvertimeRequest::create([ + 'tenant_id' => $tenantId, + 'employee_id' => $data['employee_id'], + 'work_date' => $data['work_date'], + 'hours' => $data['hours'], + 'rate_multiplier' => $data['rate_multiplier'] ?? 1.5, + 'reason' => $data['reason'] ?? null, + 'status' => 'pending', + ]); + + return $this->success($overtime->load('employee:id,first_name,last_name'), 201); + } + + public function approveOvertime(Request $request, OvertimeRequest $overtimeRequest): JsonResponse + { + if ($overtimeRequest->status !== 'pending') { + return $this->error('Only pending overtime requests can be approved.', 422); + } + + $overtimeRequest->approve($request->user()->id); + + return $this->success(array_merge($overtimeRequest->fresh()->toArray(), [ + 'total_pay' => $overtimeRequest->fresh()->total_pay, + ])); + } + + public function rejectOvertime(Request $request, OvertimeRequest $overtimeRequest): JsonResponse + { + if ($overtimeRequest->status !== 'pending') { + return $this->error('Only pending overtime requests can be rejected.', 422); + } + + $data = $request->validate([ + 'rejection_reason' => ['nullable', 'string'], + ]); + + $overtimeRequest->reject($data['rejection_reason'] ?? ''); + + return $this->success($overtimeRequest->fresh()); + } + + public function cancelOvertime(OvertimeRequest $overtimeRequest): JsonResponse + { + if ($overtimeRequest->status === 'approved') { + return $this->error('Approved overtime requests cannot be cancelled.', 422); + } + + $overtimeRequest->cancel(); + + return $this->success($overtimeRequest->fresh()); + } +} diff --git a/erp/app/Http/Controllers/Api/V1/SignApiController.php b/erp/app/Http/Controllers/Api/V1/SignApiController.php new file mode 100644 index 00000000000..e7ef9a21c92 --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/SignApiController.php @@ -0,0 +1,106 @@ +has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $query = SignRequest::where('tenant_id', $tenantId); + + if ($status = $request->query('status')) { + $query->where('status', $status); + } + + $paginator = $query->latest()->paginate(20); + + return $this->paginated($paginator); + } + + /** + * GET /api/v1/sign/documents/{id} + */ + public function showDocument(int $id): JsonResponse + { + $document = SignRequest::with(['signers'])->findOrFail($id); + + return $this->success($document); + } + + /** + * POST /api/v1/sign/documents + */ + public function storeDocument(Request $request): JsonResponse + { + $validated = $request->validate([ + 'title' => 'required|string|max:255', + 'document_name' => 'required|string|max:255', + 'document_path' => 'required|string', + 'message' => 'nullable|string', + 'signers' => 'nullable|array', + 'signers.*.signer_name' => 'required_with:signers|string|max:255', + 'signers.*.signer_email' => 'required_with:signers|email|max:255', + 'signers.*.sequence' => 'nullable|integer|min:1', + ]); + + $tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $validated['tenant_id'] = $tenantId; + $validated['created_by'] = $request->user()->id; + $validated['status'] = 'draft'; + + $signers = $validated['signers'] ?? []; + unset($validated['signers']); + + $document = SignRequest::create($validated); + + foreach ($signers as $index => $signer) { + $document->signers()->create([ + 'tenant_id' => $tenantId, + 'signer_name' => $signer['signer_name'], + 'signer_email' => $signer['signer_email'], + 'sequence' => $signer['sequence'] ?? ($index + 1), + 'status' => 'pending', + ]); + } + + return $this->success($document->load('signers'), 201); + } + + /** + * POST /api/v1/sign/documents/{id}/send + */ + public function sendForSignature(int $id): JsonResponse + { + $document = SignRequest::findOrFail($id); + $document->update(['status' => 'sent']); + + return $this->success($document); + } + + /** + * PUT /api/v1/sign/documents/{id}/status + */ + public function updateStatus(Request $request, int $id): JsonResponse + { + $document = SignRequest::findOrFail($id); + + $validated = $request->validate([ + 'status' => 'required|string|in:draft,sent,signed,declined,expired,cancelled', + ]); + + $document->update($validated); + + return $this->success($document); + } +} diff --git a/erp/app/Http/Controllers/Api/V1/SlaApiController.php b/erp/app/Http/Controllers/Api/V1/SlaApiController.php new file mode 100644 index 00000000000..63215eab882 --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/SlaApiController.php @@ -0,0 +1,133 @@ +tenantId($request); + + $policies = HelpdeskSlaPolicy::where('tenant_id', $tenantId) + ->when($request->boolean('active_only'), fn ($q) => $q->where('is_active', true)) + ->orderBy('priority') + ->get(); + + return $this->success($policies); + } + + public function storePolicy(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + + $data = $request->validate([ + 'name' => ['required', 'string', 'max:100'], + 'priority' => ['required', 'string', 'in:low,medium,high,urgent'], + 'response_hours' => ['required', 'integer', 'min:1'], + 'resolution_hours' => ['required', 'integer', 'min:1'], + ]); + + $policy = HelpdeskSlaPolicy::create([ + ...$data, + 'tenant_id' => $tenantId, + 'is_active' => true, + ]); + + return $this->success($policy, 201); + } + + public function updatePolicy(Request $request, HelpdeskSlaPolicy $slaPolicy): JsonResponse + { + $data = $request->validate([ + 'name' => ['sometimes', 'string', 'max:100'], + 'response_hours' => ['sometimes', 'integer', 'min:1'], + 'resolution_hours' => ['sometimes', 'integer', 'min:1'], + 'is_active' => ['boolean'], + ]); + + $slaPolicy->update($data); + + return $this->success($slaPolicy->fresh()); + } + + public function destroyPolicy(HelpdeskSlaPolicy $slaPolicy): JsonResponse + { + $slaPolicy->delete(); + return $this->success(['message' => 'SLA policy deleted.']); + } + + // ── SLA Dashboard / Metrics ─────────────────────────────────────────────── + + public function dashboard(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + + $open = HelpdeskTicket::where('tenant_id', $tenantId)->whereIn('status', ['open', 'pending'])->get(); + $breached = $open->filter(fn ($t) => $t->is_overdue); + + $byPriority = $open->groupBy('priority')->map(fn ($group) => [ + 'total' => $group->count(), + 'breached' => $group->filter(fn ($t) => $t->is_overdue)->count(), + ]); + + $overdue = $open->filter(fn ($t) => $t->sla_deadline !== null && now()->gt($t->sla_deadline)) + ->sortBy('sla_deadline') + ->take(10) + ->map(fn ($t) => [ + 'id' => $t->id, + 'subject' => $t->subject, + 'priority' => $t->priority, + 'sla_deadline' => $t->sla_deadline?->toDateTimeString(), + 'overdue_by' => (int) now()->diffInMinutes($t->sla_deadline), + ]) + ->values(); + + return $this->success([ + 'open_tickets' => $open->count(), + 'breached_count' => $breached->count(), + 'breach_rate' => $open->count() > 0 ? round($breached->count() / $open->count() * 100, 1) : 0, + 'by_priority' => $byPriority, + 'overdue_tickets' => $overdue, + ]); + } + + public function atRisk(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + $minutesAhead = (int) $request->get('within_minutes', 120); + + $deadline = now()->addMinutes($minutesAhead); + + $tickets = HelpdeskTicket::where('tenant_id', $tenantId) + ->whereIn('status', ['open', 'pending']) + ->where('sla_deadline', '>', now()) + ->where('sla_deadline', '<=', $deadline) + ->orderBy('sla_deadline') + ->get(['id', 'subject', 'priority', 'status', 'sla_deadline']); + + return $this->success([ + 'within_minutes' => $minutesAhead, + 'count' => $tickets->count(), + 'tickets' => $tickets->map(fn ($t) => [ + 'id' => $t->id, + 'subject' => $t->subject, + 'priority' => $t->priority, + 'status' => $t->status, + 'sla_deadline' => $t->sla_deadline?->toDateTimeString(), + 'minutes_remaining' => (int) now()->diffInMinutes($t->sla_deadline, false), + ]), + ]); + } + + private function tenantId(Request $request): int + { + return app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + } +} diff --git a/erp/app/Http/Controllers/Api/V1/SocialMarketingApiController.php b/erp/app/Http/Controllers/Api/V1/SocialMarketingApiController.php new file mode 100644 index 00000000000..6547ad75305 --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/SocialMarketingApiController.php @@ -0,0 +1,95 @@ +has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $paginator = SocialAccount::where('tenant_id', $tenantId)->latest()->paginate(20); + + return $this->paginated($paginator); + } + + /** + * GET /api/v1/social-marketing/posts + */ + public function posts(Request $request): JsonResponse + { + $tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $query = SocialPost::where('tenant_id', $tenantId); + + if ($accountId = $request->query('social_account_id')) { + $query->whereJsonContains('social_account_ids', (int) $accountId); + } + + if ($status = $request->query('status')) { + $query->where('status', $status); + } + + $paginator = $query->latest()->paginate(20); + + return $this->paginated($paginator); + } + + /** + * GET /api/v1/social-marketing/posts/{id} + */ + public function showPost(int $id): JsonResponse + { + $post = SocialPost::findOrFail($id); + + return $this->success($post); + } + + /** + * POST /api/v1/social-marketing/posts + */ + public function storePost(Request $request): JsonResponse + { + $validated = $request->validate([ + 'content' => 'required|string', + 'platforms' => 'nullable|array', + 'social_account_ids' => 'nullable|array', + 'media_urls' => 'nullable|array', + 'status' => 'nullable|string|in:draft,scheduled,published', + 'scheduled_at' => 'nullable|date', + 'campaign_id' => 'nullable|integer', + ]); + + $tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $validated['tenant_id'] = $tenantId; + $validated['created_by'] = $request->user()->id; + $validated['status'] = $validated['status'] ?? 'draft'; + + $post = SocialPost::create($validated); + + return $this->success($post, 201); + } + + /** + * POST /api/v1/social-marketing/posts/{id}/publish + */ + public function publishPost(int $id): JsonResponse + { + $post = SocialPost::findOrFail($id); + $post->update([ + 'status' => 'published', + 'published_at' => now(), + ]); + + return $this->success($post); + } +} diff --git a/erp/app/Http/Controllers/Api/V1/SubcontractingApiController.php b/erp/app/Http/Controllers/Api/V1/SubcontractingApiController.php new file mode 100644 index 00000000000..79ae33d9544 --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/SubcontractingApiController.php @@ -0,0 +1,108 @@ +has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $query = SubcontractOrder::where('tenant_id', $tenantId); + + if ($status = $request->query('status')) { + $query->where('status', $status); + } + + if ($vendorId = $request->query('vendor_id')) { + $query->where('vendor_id', $vendorId); + } + + $paginator = $query->latest()->paginate(20); + + return $this->paginated($paginator); + } + + /** + * GET /api/v1/subcontracting/orders/{id} + */ + public function show(int $id): JsonResponse + { + $order = SubcontractOrder::with(['components'])->findOrFail($id); + + return $this->success($order); + } + + /** + * POST /api/v1/subcontracting/orders + */ + public function store(Request $request): JsonResponse + { + $validated = $request->validate([ + 'vendor_id' => 'required|integer', + 'reference' => 'nullable|string|max:100', + 'finished_product' => 'required|string|max:255', + 'finished_qty' => 'required|numeric|min:0', + 'unit_price' => 'nullable|numeric|min:0', + 'notes' => 'nullable|string', + 'components' => 'nullable|array', + 'components.*.component_name' => 'required_with:components|string|max:255', + 'components.*.quantity' => 'required_with:components|numeric|min:0', + 'components.*.unit' => 'nullable|string|max:50', + ]); + + $tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $components = $validated['components'] ?? []; + unset($validated['components']); + + $validated['tenant_id'] = $tenantId; + $validated['status'] = 'draft'; + + $order = SubcontractOrder::create($validated); + + foreach ($components as $component) { + SubcontractComponent::create([ + 'tenant_id' => $tenantId, + 'subcontract_id' => $order->id, + 'component_name' => $component['component_name'], + 'quantity' => $component['quantity'], + 'unit' => $component['unit'] ?? null, + ]); + } + + return $this->success($order->load('components'), 201); + } + + /** + * PUT /api/v1/subcontracting/orders/{id} + */ + public function update(Request $request, int $id): JsonResponse + { + $order = SubcontractOrder::findOrFail($id); + + $validated = $request->validate([ + 'vendor_id' => 'sometimes|integer', + 'reference' => 'nullable|string|max:100', + 'finished_product' => 'sometimes|string|max:255', + 'finished_qty' => 'sometimes|numeric|min:0', + 'unit_price' => 'nullable|numeric|min:0', + 'notes' => 'nullable|string', + 'status' => 'nullable|string|in:draft,sent,in_progress,done,cancelled', + 'sent_at' => 'nullable|date', + 'received_at' => 'nullable|date', + ]); + + $order->update($validated); + + return $this->success($order); + } +} diff --git a/erp/app/Http/Controllers/Api/V1/SubscriptionsApiController.php b/erp/app/Http/Controllers/Api/V1/SubscriptionsApiController.php new file mode 100644 index 00000000000..c9c1c1e851b --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/SubscriptionsApiController.php @@ -0,0 +1,127 @@ +has('is_active')) { + $query->where('is_active', filter_var($request->query('is_active'), FILTER_VALIDATE_BOOLEAN)); + } + + $paginator = $query->latest()->paginate(20); + + return $this->paginated($paginator); + } + + /** + * POST /api/v1/subscriptions/plans + */ + public function storePlan(Request $request): JsonResponse + { + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'description' => 'nullable|string', + 'billing_cycle' => 'required|string|in:monthly,quarterly,annual', + 'price' => 'required|numeric|min:0', + 'trial_days' => 'nullable|integer|min:0', + 'is_active' => 'nullable|boolean', + ]); + + $tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $validated['tenant_id'] = $tenantId; + + $plan = SubscriptionPlan::create($validated); + + return $this->success($plan, 201); + } + + /** + * GET /api/v1/subscriptions + */ + public function subscriptions(Request $request): JsonResponse + { + $query = Subscription::with('plan:id,name'); + + if ($status = $request->query('status')) { + $query->where('status', $status); + } + + if ($planId = $request->query('plan_id')) { + $query->where('plan_id', $planId); + } + + $paginator = $query->latest()->paginate(20); + + return $this->paginated($paginator); + } + + /** + * GET /api/v1/subscriptions/{id} + */ + public function showSubscription(int $id): JsonResponse + { + $subscription = Subscription::with('plan')->withCount('invoices')->findOrFail($id); + + return $this->success($subscription); + } + + /** + * POST /api/v1/subscriptions + */ + public function storeSubscription(Request $request): JsonResponse + { + $validated = $request->validate([ + 'plan_id' => 'required|integer|exists:subscription_plans,id', + 'customer_name' => 'required|string|max:255', + 'customer_email' => 'nullable|email|max:255', + 'status' => 'nullable|string|in:trial,active,paused,cancelled,expired', + 'trial_ends_at' => 'nullable|date', + 'current_period_start' => 'nullable|date', + 'current_period_end' => 'nullable|date', + 'notes' => 'nullable|string', + ]); + + $tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $validated['tenant_id'] = $tenantId; + + $subscription = Subscription::create($validated); + + return $this->success($subscription, 201); + } + + /** + * POST /api/v1/subscriptions/{id}/renew + */ + public function renewSubscription(int $id): JsonResponse + { + $subscription = Subscription::findOrFail($id); + $invoice = $subscription->renew(); + + return $this->success(['message' => 'Subscription renewed', 'invoice' => $invoice]); + } + + /** + * POST /api/v1/subscriptions/{id}/cancel + */ + public function cancelSubscription(int $id): JsonResponse + { + $subscription = Subscription::findOrFail($id); + $subscription->cancel(); + + return $this->success(['message' => 'Subscription cancelled']); + } +} diff --git a/erp/app/Http/Controllers/Api/V1/SuccessionMentorApiController.php b/erp/app/Http/Controllers/Api/V1/SuccessionMentorApiController.php new file mode 100644 index 00000000000..8bd0f49bf62 --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/SuccessionMentorApiController.php @@ -0,0 +1,219 @@ +has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + } + + // ── Succession Plans ────────────────────────────────────────────────────── + + public function indexPlans(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + $plans = SuccessionPlan::where('tenant_id', $tenantId) + ->with('currentHolder:id,first_name,last_name') + ->withCount('candidates') + ->when($request->input('status'), fn ($q, $s) => $q->where('status', $s)) + ->when($request->boolean('critical_only'), fn ($q) => $q->where('is_critical', true)) + ->orderBy('position_title') + ->get(); + + return $this->success($plans); + } + + public function storePlan(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + + $data = $request->validate([ + 'position_title' => ['required', 'string', 'max:150'], + 'department' => ['nullable', 'string', 'max:100'], + 'description' => ['nullable', 'string'], + 'is_critical' => ['boolean'], + 'current_holder_id' => ['nullable', 'integer', 'exists:employees,id'], + ]); + + $plan = SuccessionPlan::create([ + 'tenant_id' => $tenantId, + 'position_title' => $data['position_title'], + 'department' => $data['department'] ?? null, + 'description' => $data['description'] ?? null, + 'is_critical' => $data['is_critical'] ?? false, + 'current_holder_id' => $data['current_holder_id'] ?? null, + 'status' => 'active', + 'created_by' => $request->user()->id, + ]); + + return $this->success($plan->load('currentHolder:id,first_name,last_name'), 201); + } + + public function showPlan(SuccessionPlan $successionPlan): JsonResponse + { + $successionPlan->load('candidates.employee:id,first_name,last_name', 'currentHolder:id,first_name,last_name'); + + return $this->success($successionPlan); + } + + public function updatePlan(Request $request, SuccessionPlan $successionPlan): JsonResponse + { + $data = $request->validate([ + 'position_title' => ['sometimes', 'string', 'max:150'], + 'department' => ['nullable', 'string', 'max:100'], + 'description' => ['nullable', 'string'], + 'is_critical' => ['boolean'], + 'current_holder_id' => ['nullable', 'integer', 'exists:employees,id'], + ]); + + $successionPlan->update($data); + + return $this->success($successionPlan->fresh()); + } + + public function completePlan(SuccessionPlan $successionPlan): JsonResponse + { + $successionPlan->complete(); + + return $this->success($successionPlan->fresh()); + } + + public function deactivatePlan(SuccessionPlan $successionPlan): JsonResponse + { + $successionPlan->deactivate(); + + return $this->success($successionPlan->fresh()); + } + + public function addCandidate(Request $request, SuccessionPlan $successionPlan): JsonResponse + { + $data = $request->validate([ + 'employee_id' => ['required', 'integer', 'exists:employees,id'], + 'readiness_level' => ['nullable', 'in:not-ready,developing,ready,ready-now'], + 'priority' => ['nullable', 'integer', 'min:1'], + 'readiness_score' => ['nullable', 'integer', 'min:0', 'max:100'], + 'development_notes'=> ['nullable', 'string'], + ]); + + $candidate = SuccessionCandidate::create([ + 'succession_plan_id'=> $successionPlan->id, + 'employee_id' => $data['employee_id'], + 'readiness_level' => $data['readiness_level'] ?? 'not-ready', + 'priority' => $data['priority'] ?? 1, + 'readiness_score' => $data['readiness_score'] ?? 0, + 'development_notes' => $data['development_notes'] ?? null, + ]); + + return $this->success($candidate->load('employee:id,first_name,last_name'), 201); + } + + public function removeCandidate(SuccessionPlan $successionPlan, SuccessionCandidate $candidate): JsonResponse + { + $candidate->delete(); + + return $this->success(['message' => 'Candidate removed.']); + } + + // ── Mentorship Programs ─────────────────────────────────────────────────── + + public function indexMentorship(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + $programs = MentorshipProgram::where('tenant_id', $tenantId) + ->with('mentor:id,first_name,last_name', 'mentee:id,first_name,last_name') + ->when($request->input('status'), fn ($q, $s) => $q->where('status', $s)) + ->when($request->input('mentor_id'), fn ($q, $m) => $q->where('mentor_id', $m)) + ->orderByDesc('start_date') + ->get() + ->map(fn ($p) => array_merge($p->toArray(), ['progress_percent' => $p->progress_percent])); + + return $this->success($programs); + } + + public function storeMentorship(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + + $data = $request->validate([ + 'mentor_id' => ['required', 'integer', 'exists:employees,id'], + 'mentee_id' => ['required', 'integer', 'exists:employees,id', 'different:mentor_id'], + 'title' => ['required', 'string', 'max:200'], + 'objectives' => ['nullable', 'string'], + 'start_date' => ['required', 'date'], + 'end_date' => ['nullable', 'date', 'after:start_date'], + 'meeting_frequency'=> ['nullable', 'in:weekly,biweekly,monthly'], + 'sessions_planned' => ['nullable', 'integer', 'min:1'], + ]); + + $program = MentorshipProgram::create([ + 'tenant_id' => $tenantId, + 'mentor_id' => $data['mentor_id'], + 'mentee_id' => $data['mentee_id'], + 'title' => $data['title'], + 'objectives' => $data['objectives'] ?? null, + 'start_date' => $data['start_date'], + 'end_date' => $data['end_date'] ?? null, + 'meeting_frequency' => $data['meeting_frequency'] ?? 'monthly', + 'sessions_planned' => $data['sessions_planned'] ?? 0, + 'status' => 'active', + 'created_by' => $request->user()->id, + ]); + + return $this->success($program->load('mentor:id,first_name,last_name', 'mentee:id,first_name,last_name'), 201); + } + + public function showMentorship(MentorshipProgram $mentorshipProgram): JsonResponse + { + $mentorshipProgram->load('mentor:id,first_name,last_name', 'mentee:id,first_name,last_name'); + + return $this->success(array_merge($mentorshipProgram->toArray(), [ + 'progress_percent' => $mentorshipProgram->progress_percent, + ])); + } + + public function logSession(MentorshipProgram $mentorshipProgram): JsonResponse + { + if ($mentorshipProgram->status !== 'active') { + return $this->error('Can only log sessions for active programs.', 422); + } + + $mentorshipProgram->logSession(); + + return $this->success(array_merge($mentorshipProgram->fresh()->toArray(), [ + 'progress_percent' => $mentorshipProgram->fresh()->progress_percent, + ])); + } + + public function completeMentorship(MentorshipProgram $mentorshipProgram): JsonResponse + { + $mentorshipProgram->complete(); + + return $this->success($mentorshipProgram->fresh()); + } + + public function pauseMentorship(MentorshipProgram $mentorshipProgram): JsonResponse + { + if ($mentorshipProgram->status !== 'active') { + return $this->error('Only active programs can be paused.', 422); + } + + $mentorshipProgram->pause(); + + return $this->success($mentorshipProgram->fresh()); + } + + public function cancelMentorship(MentorshipProgram $mentorshipProgram): JsonResponse + { + $mentorshipProgram->cancel(); + + return $this->success($mentorshipProgram->fresh()); + } +} diff --git a/erp/app/Http/Controllers/Api/V1/SurveyApiController.php b/erp/app/Http/Controllers/Api/V1/SurveyApiController.php new file mode 100644 index 00000000000..0d3507da079 --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/SurveyApiController.php @@ -0,0 +1,280 @@ +has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + } + + // ── Surveys ─────────────────────────────────────────────────────────────── + + public function surveys(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + + $surveys = Survey::where('tenant_id', $tenantId) + ->withCount('questions', 'responses') + ->when($request->query('status'), fn ($q, $s) => $q->where('status', $s)) + ->latest() + ->get(); + + return $this->success($surveys); + } + + public function showSurvey(Request $request, int $id): JsonResponse + { + $survey = Survey::with(['questions' => fn ($q) => $q->orderBy('sequence')]) + ->withCount('responses') + ->findOrFail($id); + + return $this->success($survey); + } + + public function storeSurvey(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + + $validated = $request->validate([ + 'title' => 'required|string|max:255', + 'description' => 'nullable|string', + 'starts_at' => 'nullable|date', + 'ends_at' => 'nullable|date|after_or_equal:starts_at', + 'questions' => 'nullable|array', + 'questions.*.question_text' => 'required_with:questions|string', + 'questions.*.question_type' => ['required_with:questions', 'string', 'in:text,single_choice,multiple_choice,rating,yes_no'], + 'questions.*.is_required' => 'nullable|boolean', + 'questions.*.sequence' => 'nullable|integer|min:1', + 'questions.*.options' => 'nullable|array', + ]); + + $questions = $validated['questions'] ?? []; + + $survey = Survey::create([ + 'tenant_id' => $tenantId, + 'title' => $validated['title'], + 'description' => $validated['description'] ?? null, + 'status' => 'draft', + 'starts_at' => $validated['starts_at'] ?? null, + 'ends_at' => $validated['ends_at'] ?? null, + 'created_by' => $request->user()->id, + ]); + + foreach ($questions as $i => $q) { + SurveyQuestion::create([ + 'tenant_id' => $tenantId, + 'survey_id' => $survey->id, + 'question_text' => $q['question_text'], + 'question_type' => $q['question_type'], + 'is_required' => $q['is_required'] ?? true, + 'sequence' => $q['sequence'] ?? ($i + 1), + 'options' => $q['options'] ?? null, + ]); + } + + return $this->success($survey->load('questions'), 201); + } + + public function updateSurvey(Request $request, int $id): JsonResponse + { + $survey = Survey::findOrFail($id); + + if ($survey->status === 'closed') { + return $this->error('Closed surveys cannot be edited.', 422); + } + + $validated = $request->validate([ + 'title' => 'sometimes|string|max:255', + 'description' => 'nullable|string', + 'starts_at' => 'nullable|date', + 'ends_at' => 'nullable|date', + ]); + + $survey->update($validated); + + return $this->success($survey->fresh()->load('questions')); + } + + public function destroySurvey(int $id): JsonResponse + { + $survey = Survey::findOrFail($id); + $survey->delete(); + + return $this->success(['message' => 'Survey deleted.']); + } + + // ── Lifecycle ───────────────────────────────────────────────────────────── + + public function publishSurvey(int $id): JsonResponse + { + $survey = Survey::findOrFail($id); + + if ($survey->status !== 'draft') { + return $this->error('Only draft surveys can be published.', 422); + } + + $survey->publish(); + + return $this->success($survey->fresh()); + } + + public function closeSurvey(int $id): JsonResponse + { + $survey = Survey::findOrFail($id); + + if ($survey->status !== 'published') { + return $this->error('Only published surveys can be closed.', 422); + } + + $survey->close(); + + return $this->success($survey->fresh()); + } + + // ── Questions ───────────────────────────────────────────────────────────── + + public function addQuestion(Request $request, int $id): JsonResponse + { + $tenantId = $this->tenantId($request); + $survey = Survey::findOrFail($id); + + $data = $request->validate([ + 'question_text' => ['required', 'string'], + 'question_type' => ['required', 'in:text,single_choice,multiple_choice,rating,yes_no'], + 'is_required' => ['boolean'], + 'sequence' => ['nullable', 'integer', 'min:1'], + 'options' => ['nullable', 'array'], + ]); + + $question = SurveyQuestion::create([ + 'tenant_id' => $tenantId, + 'survey_id' => $survey->id, + 'question_text' => $data['question_text'], + 'question_type' => $data['question_type'], + 'is_required' => $data['is_required'] ?? true, + 'sequence' => $data['sequence'] ?? ($survey->questions()->count() + 1), + 'options' => $data['options'] ?? null, + ]); + + return $this->success($question, 201); + } + + public function updateQuestion(Request $request, int $id, int $questionId): JsonResponse + { + $question = SurveyQuestion::where('survey_id', $id)->findOrFail($questionId); + + $data = $request->validate([ + 'question_text' => ['sometimes', 'string'], + 'question_type' => ['sometimes', 'in:text,single_choice,multiple_choice,rating,yes_no'], + 'is_required' => ['boolean'], + 'sequence' => ['nullable', 'integer', 'min:1'], + 'options' => ['nullable', 'array'], + ]); + + $question->update($data); + + return $this->success($question->fresh()); + } + + public function deleteQuestion(int $id, int $questionId): JsonResponse + { + $question = SurveyQuestion::where('survey_id', $id)->findOrFail($questionId); + $question->delete(); + + return $this->success(['message' => 'Question deleted.']); + } + + // ── Responses ───────────────────────────────────────────────────────────── + + public function submitResponse(Request $request, int $id): JsonResponse + { + $survey = Survey::findOrFail($id); + $tenantId = $this->tenantId($request); + + $validated = $request->validate([ + 'respondent_name' => ['nullable', 'string', 'max:255'], + 'respondent_email' => ['nullable', 'email', 'max:255'], + 'answers' => ['required', 'array'], + 'answers.*.survey_question_id' => ['required', 'integer', 'exists:survey_questions,id'], + 'answers.*.answer_text' => ['nullable', 'string'], + 'answers.*.answer_options' => ['nullable', 'array'], + ]); + + $response = SurveyResponse::create([ + 'tenant_id' => $tenantId, + 'survey_id' => $survey->id, + 'respondent_name' => $validated['respondent_name'] ?? null, + 'respondent_email' => $validated['respondent_email'] ?? null, + 'submitted_at' => now(), + ]); + + foreach ($validated['answers'] as $answer) { + SurveyAnswer::create([ + 'tenant_id' => $tenantId, + 'survey_response_id' => $response->id, + 'survey_question_id' => $answer['survey_question_id'], + 'answer_text' => $answer['answer_text'] ?? null, + 'answer_options' => $answer['answer_options'] ?? null, + ]); + } + + return $this->success($response->load('answers'), 201); + } + + // ── Analytics ───────────────────────────────────────────────────────────── + + public function surveyResults(int $id): JsonResponse + { + $survey = Survey::with(['questions.answers'])->findOrFail($id); + + $totalResponses = $survey->responseCount(); + + $questionResults = $survey->questions->map(function (SurveyQuestion $question) use ($totalResponses) { + $answers = $question->answers; + $answerCount = $answers->count(); + + $summary = match ($question->question_type) { + 'rating' => [ + 'average' => $answerCount > 0 + ? round($answers->avg(fn ($a) => (float) $a->answer_text), 2) + : null, + 'count' => $answerCount, + ], + 'yes_no', 'single_choice', 'multiple_choice' => [ + 'distribution' => $answers + ->flatMap(fn ($a) => $a->answer_options ?? [$a->answer_text]) + ->filter() + ->countBy() + ->toArray(), + 'count' => $answerCount, + ], + default => ['count' => $answerCount], + }; + + return [ + 'question_id' => $question->id, + 'question_text' => $question->question_text, + 'question_type' => $question->question_type, + 'answer_count' => $answerCount, + 'summary' => $summary, + ]; + }); + + return $this->success([ + 'survey_id' => $survey->id, + 'title' => $survey->title, + 'status' => $survey->status, + 'total_responses' => $totalResponses, + 'questions' => $questionResults, + ]); + } +} diff --git a/erp/app/Http/Controllers/Api/V1/TaxApiController.php b/erp/app/Http/Controllers/Api/V1/TaxApiController.php new file mode 100644 index 00000000000..c6c9bf50696 --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/TaxApiController.php @@ -0,0 +1,205 @@ +tenantId($request); + $rates = TaxRate::where('tenant_id', $tenantId) + ->when($request->input('type'), fn ($q, $t) => $q->where('tax_type', $t)) + ->when($request->boolean('active_only'), fn ($q) => $q->active()) + ->orderBy('name') + ->get(); + + return $this->success($rates); + } + + public function storeRate(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + + $data = $request->validate([ + 'name' => ['required', 'string', 'max:100'], + 'rate' => ['required', 'numeric', 'min:0', 'max:100'], + 'tax_type' => ['required', 'in:sales,purchase,both'], + 'is_compound' => ['boolean'], + 'is_active' => ['boolean'], + ]); + + $rate = TaxRate::create([...$data, 'tenant_id' => $tenantId]); + + return $this->success($rate, 201); + } + + public function updateRate(Request $request, TaxRate $taxRate): JsonResponse + { + $data = $request->validate([ + 'name' => ['sometimes', 'string', 'max:100'], + 'rate' => ['numeric', 'min:0', 'max:100'], + 'tax_type' => ['in:sales,purchase,both'], + 'is_compound' => ['boolean'], + 'is_active' => ['boolean'], + ]); + + $taxRate->update($data); + + return $this->success($taxRate->fresh()); + } + + public function destroyRate(TaxRate $taxRate): JsonResponse + { + $taxRate->delete(); + return $this->success(['message' => 'Tax rate deleted.']); + } + + // ── Tax Groups ──────────────────────────────────────────────────────────── + + public function indexGroups(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + $groups = TaxGroup::where('tenant_id', $tenantId) + ->with('items.taxRate:id,name,rate,tax_type') + ->withCount('items') + ->get() + ->map(fn ($g) => array_merge($g->toArray(), ['total_rate' => $g->total_rate])); + + return $this->success($groups); + } + + public function storeGroup(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + + $data = $request->validate([ + 'name' => ['required', 'string', 'max:100'], + 'description' => ['nullable', 'string'], + 'tax_rate_ids' => ['nullable', 'array'], + 'tax_rate_ids.*' => ['integer', 'exists:tax_rates,id'], + ]); + + $group = TaxGroup::create([ + 'tenant_id' => $tenantId, + 'name' => $data['name'], + 'description' => $data['description'] ?? null, + 'is_active' => true, + ]); + + foreach ($data['tax_rate_ids'] ?? [] as $rateId) { + TaxGroupItem::create([ + 'tenant_id' => $tenantId, + 'tax_group_id' => $group->id, + 'tax_rate_id' => $rateId, + ]); + } + + return $this->success($group->load('items.taxRate:id,name,rate'), 201); + } + + public function showGroup(TaxGroup $taxGroup): JsonResponse + { + $taxGroup->load('items.taxRate:id,name,rate,tax_type'); + + return $this->success(array_merge($taxGroup->toArray(), ['total_rate' => $taxGroup->total_rate])); + } + + public function updateGroup(Request $request, TaxGroup $taxGroup): JsonResponse + { + $data = $request->validate([ + 'name' => ['sometimes', 'string', 'max:100'], + 'description' => ['nullable', 'string'], + 'is_active' => ['boolean'], + ]); + + $taxGroup->update($data); + + return $this->success($taxGroup->fresh()->load('items.taxRate:id,name,rate')); + } + + public function destroyGroup(TaxGroup $taxGroup): JsonResponse + { + $taxGroup->items()->delete(); + $taxGroup->delete(); + + return $this->success(['message' => 'Tax group deleted.']); + } + + public function addRateToGroup(Request $request, TaxGroup $taxGroup): JsonResponse + { + $tenantId = $this->tenantId($request); + + $data = $request->validate([ + 'tax_rate_id' => ['required', 'integer', 'exists:tax_rates,id'], + ]); + + $item = TaxGroupItem::firstOrCreate( + ['tax_group_id' => $taxGroup->id, 'tax_rate_id' => $data['tax_rate_id']], + ['tenant_id' => $tenantId] + ); + + return $this->success($item->load('taxRate:id,name,rate'), 201); + } + + public function removeRateFromGroup(TaxGroup $taxGroup, TaxGroupItem $item): JsonResponse + { + $item->delete(); + return $this->success(['message' => 'Rate removed from group.']); + } + + // ── Tax Calculator ──────────────────────────────────────────────────────── + + public function calculate(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + + $data = $request->validate([ + 'amount' => ['required', 'numeric', 'min:0'], + 'tax_rate_id' => ['nullable', 'integer', 'exists:tax_rates,id'], + 'tax_group_id' => ['nullable', 'integer', 'exists:tax_groups,id'], + ]); + + if (empty($data['tax_rate_id']) && empty($data['tax_group_id'])) { + return $this->error('Provide either tax_rate_id or tax_group_id.', 422); + } + + $amount = (float) $data['amount']; + $taxAmount = 0.0; + $breakdown = []; + + if (! empty($data['tax_rate_id'])) { + $rate = TaxRate::where('tenant_id', $tenantId)->findOrFail($data['tax_rate_id']); + $taxAmount = $rate->calculateTax($amount); + $breakdown[] = ['name' => $rate->name, 'rate' => $rate->rate, 'amount' => $taxAmount]; + } else { + $group = TaxGroup::where('tenant_id', $tenantId)->with('items.taxRate')->findOrFail($data['tax_group_id']); + foreach ($group->items as $item) { + if ($item->taxRate) { + $t = $item->taxRate->calculateTax($amount); + $taxAmount += $t; + $breakdown[] = ['name' => $item->taxRate->name, 'rate' => $item->taxRate->rate, 'amount' => $t]; + } + } + } + + return $this->success([ + 'amount' => $amount, + 'tax_amount' => round($taxAmount, 4), + 'total' => round($amount + $taxAmount, 4), + 'breakdown' => $breakdown, + ]); + } + + private function tenantId(Request $request): int + { + return app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + } +} diff --git a/erp/app/Http/Controllers/Api/V1/TenantFeatureController.php b/erp/app/Http/Controllers/Api/V1/TenantFeatureController.php new file mode 100644 index 00000000000..a5439211a48 --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/TenantFeatureController.php @@ -0,0 +1,68 @@ +tenantId($request); + + $configured = TenantFeature::where('tenant_id', $tenantId) + ->get() + ->keyBy('feature'); + + $features = collect(TenantFeature::$availableFeatures)->map(function ($meta, $key) use ($configured) { + $record = $configured->get($key); + return [ + 'feature' => $key, + 'description' => $meta['description'], + 'is_enabled' => $record ? $record->is_enabled : true, + 'config' => $record?->config, + ]; + })->values(); + + return $this->success($features); + } + + public function toggle(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + + $data = $request->validate([ + 'feature' => ['required', Rule::in(array_keys(TenantFeature::$availableFeatures))], + 'is_enabled' => ['required', 'boolean'], + 'config' => ['nullable', 'array'], + ]); + + $instance = TenantFeature::toggle( + $tenantId, + $data['feature'], + $data['is_enabled'], + $data['config'] ?? null + ); + + return $this->success($instance); + } + + public function check(Request $request, string $feature): JsonResponse + { + $tenantId = $this->tenantId($request); + $isEnabled = TenantFeature::isEnabled($tenantId, $feature); + + return $this->success([ + 'feature' => $feature, + 'is_enabled' => $isEnabled, + ]); + } + + private function tenantId(Request $request): int + { + return app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + } +} diff --git a/erp/app/Http/Controllers/Api/V1/TenantSettingsController.php b/erp/app/Http/Controllers/Api/V1/TenantSettingsController.php new file mode 100644 index 00000000000..48bb2d8321a --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/TenantSettingsController.php @@ -0,0 +1,122 @@ + ['type' => 'string', 'max' => 255], + 'company_email' => ['type' => 'email'], + 'company_phone' => ['type' => 'string', 'max' => 50], + 'company_address' => ['type' => 'string', 'max' => 500], + 'currency' => ['type' => 'string', 'max' => 3], + 'timezone' => ['type' => 'timezone'], + 'fiscal_year_start' => ['type' => 'string', 'max' => 5], // MM-DD + 'date_format' => ['type' => 'string', 'max' => 20], + 'invoice_prefix' => ['type' => 'string', 'max' => 20], + 'invoice_next_number' => ['type' => 'integer', 'min' => 1], + 'po_prefix' => ['type' => 'string', 'max' => 20], + 'po_next_number' => ['type' => 'integer', 'min' => 1], + 'default_payment_terms' => ['type' => 'integer', 'min' => 0], + 'tax_id' => ['type' => 'string', 'max' => 50], + 'logo_url' => ['type' => 'string', 'max' => 500], + 'low_stock_threshold' => ['type' => 'integer', 'min' => 0], + 'enable_two_factor' => ['type' => 'boolean'], + 'allow_public_api' => ['type' => 'boolean'], + ]; + + public function index(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + + $settings = TenantSetting::where('tenant_id', $tenantId) + ->pluck('value', 'key'); + + $result = []; + foreach (array_keys(self::SCHEMA) as $key) { + $result[$key] = $settings[$key] ?? null; + } + + return $this->success($result); + } + + public function update(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + + $data = $request->validate($this->buildRules()); + + foreach ($data as $key => $value) { + TenantSetting::setValue($tenantId, $key, $value); + } + + return $this->success(['updated' => count($data), 'keys' => array_keys($data)]); + } + + public function get(Request $request, string $key): JsonResponse + { + if (! array_key_exists($key, self::SCHEMA)) { + return $this->error("Unknown setting key: {$key}", 404); + } + + $tenantId = $this->tenantId($request); + $value = TenantSetting::getValue($tenantId, $key); + + return $this->success(['key' => $key, 'value' => $value]); + } + + public function set(Request $request, string $key): JsonResponse + { + if (! array_key_exists($key, self::SCHEMA)) { + return $this->error("Unknown setting key: {$key}", 404); + } + + $tenantId = $this->tenantId($request); + $rules = $this->buildRules(); + + $data = $request->validate([ + 'value' => $rules[$key] ?? ['nullable', 'string'], + ]); + + TenantSetting::setValue($tenantId, $key, $data['value']); + + return $this->success(['key' => $key, 'value' => $data['value']]); + } + + public function schema(): JsonResponse + { + return $this->success(self::SCHEMA); + } + + private function buildRules(): array + { + $rules = []; + foreach (self::SCHEMA as $key => $def) { + $rule = ['nullable']; + $rule[] = match ($def['type']) { + 'integer' => 'integer', + 'boolean' => 'boolean', + 'email' => 'email', + 'timezone' => 'timezone', + default => 'string', + }; + if (isset($def['max'])) { + $rule[] = 'max:' . $def['max']; + } + if (isset($def['min'])) { + $rule[] = 'min:' . $def['min']; + } + $rules[$key] = $rule; + } + return $rules; + } + + private function tenantId(Request $request): int + { + return app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + } +} diff --git a/erp/app/Http/Controllers/Api/V1/TimeTrackingController.php b/erp/app/Http/Controllers/Api/V1/TimeTrackingController.php new file mode 100644 index 00000000000..09060faf07e --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/TimeTrackingController.php @@ -0,0 +1,107 @@ +tenantId($request); + + $entries = TimeEntry::where('tenant_id', $tenantId) + ->when($request->user_id, fn ($q) => $q->where('user_id', $request->user_id)) + ->when($request->from, fn ($q) => $q->whereDate('date', '>=', $request->from)) + ->when($request->to, fn ($q) => $q->whereDate('date', '<=', $request->to)) + ->when($request->is_billable !== null, fn ($q) => $q->where('is_billable', $request->boolean('is_billable'))) + ->with(['user:id,name', 'task:id,name,project_id']) + ->orderByDesc('date') + ->paginate(25); + + return $this->paginated($entries); + } + + public function store(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + + $data = $request->validate([ + 'task_id' => ['required', 'exists:tasks,id'], + 'hours' => ['required', 'numeric', 'min:0.1', 'max:24'], + 'date' => ['required', 'date'], + 'description' => ['nullable', 'string', 'max:500'], + 'is_billable' => ['sometimes', 'boolean'], + ]); + + $entry = TimeEntry::create([ + ...$data, + 'tenant_id' => $tenantId, + 'user_id' => $request->user()->id, + 'created_by' => $request->user()->id, + ]); + + return $this->success($entry->load('task:id,name,project_id'), 201); + } + + public function update(Request $request, TimeEntry $timeEntry): JsonResponse + { + $data = $request->validate([ + 'hours' => ['sometimes', 'numeric', 'min:0.1', 'max:24'], + 'date' => ['sometimes', 'date'], + 'description' => ['nullable', 'string', 'max:500'], + 'is_billable' => ['sometimes', 'boolean'], + ]); + + $timeEntry->update($data); + return $this->success($timeEntry->fresh()); + } + + public function destroy(TimeEntry $timeEntry): JsonResponse + { + $timeEntry->delete(); + return $this->success(['message' => 'Time entry deleted.']); + } + + public function projectSummary(Request $request, Project $project): JsonResponse + { + $from = $request->get('from', now()->startOfMonth()->toDateString()); + $to = $request->get('to', now()->toDateString()); + + $entries = $project->timeEntries() + ->whereDate('date', '>=', $from) + ->whereDate('date', '<=', $to) + ->with('user:id,name') + ->get(); + + $totalHours = $entries->sum('hours'); + $billableHours = $entries->where('is_billable', true)->sum('hours'); + + $byUser = $entries->groupBy('user_id')->map(fn ($group) => [ + 'user_id' => $group->first()->user_id, + 'name' => $group->first()->user?->name ?? 'Unknown', + 'total_hours' => round($group->sum('hours'), 2), + 'billable_hours' => round($group->where('is_billable', true)->sum('hours'), 2), + ])->values(); + + return $this->success([ + 'project_id' => $project->id, + 'project_name' => $project->name, + 'period' => ['from' => $from, 'to' => $to], + 'total_hours' => round($totalHours, 2), + 'billable_hours' => round($billableHours, 2), + 'non_billable' => round($totalHours - $billableHours, 2), + 'entries_count' => $entries->count(), + 'by_user' => $byUser, + ]); + } + + private function tenantId(Request $request): int + { + return app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + } +} diff --git a/erp/app/Http/Controllers/Api/V1/TimesheetApiController.php b/erp/app/Http/Controllers/Api/V1/TimesheetApiController.php new file mode 100644 index 00000000000..83e8b0d6c6d --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/TimesheetApiController.php @@ -0,0 +1,171 @@ +has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + } + + // ── Timesheets ──────────────────────────────────────────────────────────── + + public function index(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + $timesheets = Timesheet::where('tenant_id', $tenantId) + ->with('employee:id,first_name,last_name,employee_number') + ->withCount('entries') + ->when($request->input('status'), fn ($q, $s) => $q->where('status', $s)) + ->when($request->input('employee_id'), fn ($q, $e) => $q->where('employee_id', $e)) + ->orderByDesc('week_start') + ->get(); + + return $this->success($timesheets); + } + + public function store(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + + $data = $request->validate([ + 'employee_id' => ['required', 'integer', 'exists:employees,id'], + 'week_start' => ['required', 'date'], + 'week_end' => ['required', 'date', 'after_or_equal:week_start'], + 'notes' => ['nullable', 'string'], + ]); + + $timesheet = Timesheet::create([ + 'tenant_id' => $tenantId, + 'employee_id'=> $data['employee_id'], + 'week_start' => $data['week_start'], + 'week_end' => $data['week_end'], + 'status' => 'draft', + 'notes' => $data['notes'] ?? null, + ]); + + return $this->success($timesheet->load('employee:id,first_name,last_name'), 201); + } + + public function show(Timesheet $timesheet): JsonResponse + { + $timesheet->load('employee:id,first_name,last_name,employee_number', 'entries', 'approvedBy:id,name'); + + return $this->success($timesheet); + } + + public function destroy(Timesheet $timesheet): JsonResponse + { + if ($timesheet->status === 'approved') { + return $this->error('Approved timesheets cannot be deleted.', 422); + } + + $timesheet->delete(); + + return $this->success(['message' => 'Timesheet deleted.']); + } + + // ── Entries ─────────────────────────────────────────────────────────────── + + public function addEntry(Request $request, Timesheet $timesheet): JsonResponse + { + $tenantId = $this->tenantId($request); + + if ($timesheet->status !== 'draft') { + return $this->error('Entries can only be added to draft timesheets.', 422); + } + + $data = $request->validate([ + 'work_date' => ['required', 'date'], + 'hours' => ['required', 'numeric', 'min:0.5', 'max:24'], + 'project' => ['nullable', 'string', 'max:100'], + 'description' => ['nullable', 'string'], + ]); + + $entry = TimesheetEntry::create([ + 'tenant_id' => $tenantId, + 'timesheet_id' => $timesheet->id, + 'work_date' => $data['work_date'], + 'hours' => $data['hours'], + 'project' => $data['project'] ?? null, + 'description' => $data['description'] ?? null, + ]); + + $timesheet->recalculateHours(); + + return $this->success($entry, 201); + } + + public function removeEntry(Timesheet $timesheet, TimesheetEntry $entry): JsonResponse + { + if ($timesheet->status !== 'draft') { + return $this->error('Entries can only be removed from draft timesheets.', 422); + } + + $entry->delete(); + $timesheet->recalculateHours(); + + return $this->success(['message' => 'Entry removed.', 'total_hours' => $timesheet->fresh()->total_hours]); + } + + // ── Workflow ────────────────────────────────────────────────────────────── + + public function submit(Timesheet $timesheet): JsonResponse + { + if ($timesheet->status !== 'draft') { + return $this->error('Only draft timesheets can be submitted.', 422); + } + + $timesheet->submit(); + + return $this->success($timesheet->fresh()); + } + + public function approve(Request $request, Timesheet $timesheet): JsonResponse + { + if ($timesheet->status !== 'submitted') { + return $this->error('Only submitted timesheets can be approved.', 422); + } + + $timesheet->approve($request->user()->id); + + return $this->success($timesheet->fresh()->load('approvedBy:id,name')); + } + + public function reject(Timesheet $timesheet): JsonResponse + { + if ($timesheet->status !== 'submitted') { + return $this->error('Only submitted timesheets can be rejected.', 422); + } + + $timesheet->reject(); + + return $this->success($timesheet->fresh()); + } + + // ── Summary ─────────────────────────────────────────────────────────────── + + public function summary(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + + $counts = Timesheet::where('tenant_id', $tenantId) + ->selectRaw('status, COUNT(*) as total, SUM(total_hours) as hours') + ->groupBy('status') + ->get() + ->keyBy('status') + ->map(fn ($r) => ['count' => (int) $r->total, 'hours' => (float) $r->hours]); + + return $this->success([ + 'by_status' => $counts, + 'grand_total' => Timesheet::where('tenant_id', $tenantId)->sum('total_hours'), + ]); + } +} diff --git a/erp/app/Http/Controllers/Api/V1/UserPreferenceController.php b/erp/app/Http/Controllers/Api/V1/UserPreferenceController.php new file mode 100644 index 00000000000..a81a9a3a0ff --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/UserPreferenceController.php @@ -0,0 +1,47 @@ +user()->id); + return $this->success($prefs); + } + + public function update(Request $request): JsonResponse + { + $allowedKeys = array_keys(UserPreference::$defaults); + + $data = $request->validate([ + 'preferences' => ['required', 'array'], + 'preferences.*' => ['nullable', 'string', 'max:255'], + ]); + + $updated = []; + foreach ($data['preferences'] as $key => $value) { + if (! in_array($key, $allowedKeys, true)) { + continue; + } + UserPreference::setForUser($request->user()->id, $key, (string) $value); + $updated[$key] = $value; + } + + return $this->success([ + 'updated' => $updated, + 'preferences' => UserPreference::getAllForUser($request->user()->id), + ]); + } + + public function reset(Request $request): JsonResponse + { + UserPreference::where('user_id', $request->user()->id)->delete(); + return $this->success(UserPreference::$defaults); + } +} diff --git a/erp/app/Http/Controllers/Api/V1/VendorPerformanceController.php b/erp/app/Http/Controllers/Api/V1/VendorPerformanceController.php new file mode 100644 index 00000000000..e9feade0bc7 --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/VendorPerformanceController.php @@ -0,0 +1,118 @@ +tenantId($request); + + $vendors = Contact::where('tenant_id', $tenantId) + ->vendors() + ->with(['vendorEvaluations' => fn ($q) => $q->latest('evaluation_date')->limit(10)]) + ->get() + ->map(fn ($v) => $this->buildScore($v)) + ->sortByDesc('avg_overall') + ->values(); + + return $this->success($vendors); + } + + public function show(Request $request, Contact $contact): JsonResponse + { + $contact->load('vendorEvaluations.evaluator:id,name'); + return $this->success($this->buildScore($contact, detailed: true)); + } + + public function evaluate(Request $request, Contact $contact): JsonResponse + { + $tenantId = $this->tenantId($request); + + $data = $request->validate([ + 'evaluation_date' => ['required', 'date'], + 'quality_rating' => ['required', 'integer', 'min:1', 'max:5'], + 'delivery_rating' => ['required', 'integer', 'min:1', 'max:5'], + 'price_rating' => ['required', 'integer', 'min:1', 'max:5'], + 'communication_rating' => ['required', 'integer', 'min:1', 'max:5'], + 'comments' => ['nullable', 'string', 'max:1000'], + ]); + + $overall = round( + ($data['quality_rating'] + $data['delivery_rating'] + $data['price_rating'] + $data['communication_rating']) / 4, + 2 + ); + + $evaluation = VendorEvaluation::create([ + ...$data, + 'tenant_id' => $tenantId, + 'contact_id' => $contact->id, + 'evaluated_by' => $request->user()->id, + 'overall_rating' => $overall, + ]); + + return $this->success($evaluation, 201); + } + + public function scorecard(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + $from = $request->get('from', now()->subYear()->toDateString()); + $to = $request->get('to', now()->toDateString()); + + $evaluations = VendorEvaluation::where('tenant_id', $tenantId) + ->whereDate('evaluation_date', '>=', $from) + ->whereDate('evaluation_date', '<=', $to) + ->with('contact:id,name') + ->get(); + + $byVendor = $evaluations->groupBy('contact_id')->map(fn ($group) => [ + 'vendor_id' => $group->first()->contact_id, + 'vendor_name' => $group->first()->contact?->name, + 'evaluations' => $group->count(), + 'avg_quality' => round($group->avg('quality_rating'), 2), + 'avg_delivery' => round($group->avg('delivery_rating'), 2), + 'avg_price' => round($group->avg('price_rating'), 2), + 'avg_communication' => round($group->avg('communication_rating'), 2), + 'avg_overall' => round($group->avg('overall_rating'), 2), + ])->sortByDesc('avg_overall')->values(); + + return $this->success([ + 'period' => ['from' => $from, 'to' => $to], + 'vendors' => $byVendor, + 'total_evaluations' => $evaluations->count(), + ]); + } + + private function buildScore(Contact $contact, bool $detailed = false): array + { + $evals = $contact->vendorEvaluations; + $score = [ + 'vendor_id' => $contact->id, + 'vendor_name' => $contact->name, + 'evaluation_count' => $evals->count(), + 'avg_overall' => $evals->count() ? round($evals->avg('overall_rating'), 2) : null, + 'avg_quality' => $evals->count() ? round($evals->avg('quality_rating'), 2) : null, + 'avg_delivery' => $evals->count() ? round($evals->avg('delivery_rating'), 2) : null, + 'avg_price' => $evals->count() ? round($evals->avg('price_rating'), 2) : null, + 'avg_communication' => $evals->count() ? round($evals->avg('communication_rating'), 2) : null, + 'last_evaluated' => $evals->max('evaluation_date'), + ]; + + if ($detailed) { + $score['evaluations'] = $evals->values(); + } + + return $score; + } + + private function tenantId(Request $request): int + { + return app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + } +} diff --git a/erp/app/Http/Controllers/Api/V1/WarehouseStockController.php b/erp/app/Http/Controllers/Api/V1/WarehouseStockController.php new file mode 100644 index 00000000000..49492cc33c8 --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/WarehouseStockController.php @@ -0,0 +1,183 @@ +tenantId($request); + $warehouses = Warehouse::where('tenant_id', $tenantId) + ->when($request->boolean('active_only'), fn ($q) => $q->where('is_active', true)) + ->withCount('stockLevels') + ->get(); + + return $this->success($warehouses); + } + + public function storeWarehouse(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + + $data = $request->validate([ + 'name' => ['required', 'string', 'max:100'], + 'location' => ['nullable', 'string', 'max:255'], + ]); + + $warehouse = Warehouse::create([...$data, 'tenant_id' => $tenantId, 'is_active' => true]); + + return $this->success($warehouse, 201); + } + + public function showWarehouse(Warehouse $warehouse): JsonResponse + { + $warehouse->load('stockLevels.product:id,name,sku'); + + $totalValue = $warehouse->stockLevels->sum( + fn ($sl) => (float) $sl->quantity * (float) $sl->product?->cost_price + ); + + return $this->success([ + 'id' => $warehouse->id, + 'name' => $warehouse->name, + 'location' => $warehouse->location, + 'is_active' => $warehouse->is_active, + 'total_value' => round($totalValue, 2), + 'stock_lines' => $warehouse->stockLevels->count(), + 'stock_levels' => $warehouse->stockLevels->map(fn ($sl) => [ + 'product_id' => $sl->product_id, + 'product_name' => $sl->product?->name, + 'sku' => $sl->product?->sku, + 'quantity' => (float) $sl->quantity, + 'reserved_quantity' => (float) $sl->reserved_quantity, + 'available' => $sl->available, + ]), + ]); + } + + public function updateWarehouse(Request $request, Warehouse $warehouse): JsonResponse + { + $data = $request->validate([ + 'name' => ['sometimes', 'string', 'max:100'], + 'location' => ['nullable', 'string', 'max:255'], + 'is_active' => ['boolean'], + ]); + + $warehouse->update($data); + + return $this->success($warehouse->fresh()); + } + + // ── Stock Levels ────────────────────────────────────────────────────────── + + public function stockByProduct(Request $request, int $productId): JsonResponse + { + $tenantId = $this->tenantId($request); + + $product = Product::where('tenant_id', $tenantId)->findOrFail($productId); + $levels = StockLevel::where('product_id', $productId) + ->with('warehouse:id,name,location') + ->get(); + + return $this->success([ + 'product_id' => $product->id, + 'product_name' => $product->name, + 'sku' => $product->sku, + 'total_qty' => (float) $levels->sum('quantity'), + 'total_available' => (float) $levels->sum('available'), + 'warehouses' => $levels->map(fn ($sl) => [ + 'warehouse_id' => $sl->warehouse_id, + 'warehouse_name' => $sl->warehouse?->name, + 'quantity' => (float) $sl->quantity, + 'reserved' => (float) $sl->reserved_quantity, + 'available' => $sl->available, + ]), + ]); + } + + public function setStockLevel(Request $request, int $warehouseId, int $productId): JsonResponse + { + $tenantId = $this->tenantId($request); + + $data = $request->validate([ + 'quantity' => ['required', 'numeric', 'min:0'], + 'reserved_quantity' => ['nullable', 'numeric', 'min:0'], + ]); + + $level = StockLevel::updateOrCreate( + ['warehouse_id' => $warehouseId, 'product_id' => $productId], + [ + 'tenant_id' => $tenantId, + 'quantity' => $data['quantity'], + 'reserved_quantity' => $data['reserved_quantity'] ?? 0, + ] + ); + + return $this->success($level); + } + + // ── Stock Transfer ──────────────────────────────────────────────────────── + + public function transfer(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + + $data = $request->validate([ + 'from_warehouse_id' => ['required', 'integer', 'exists:warehouses,id'], + 'to_warehouse_id' => ['required', 'integer', 'exists:warehouses,id', 'different:from_warehouse_id'], + 'product_id' => ['required', 'integer', 'exists:products,id'], + 'quantity' => ['required', 'numeric', 'min:0.01'], + 'notes' => ['nullable', 'string', 'max:500'], + ]); + + $from = StockLevel::where('warehouse_id', $data['from_warehouse_id']) + ->where('product_id', $data['product_id']) + ->first(); + + if (! $from || (float) $from->available < (float) $data['quantity']) { + return $this->error('Insufficient available stock in source warehouse.', 422); + } + + $from->decrement('quantity', $data['quantity']); + + StockLevel::updateOrCreate( + ['warehouse_id' => $data['to_warehouse_id'], 'product_id' => $data['product_id']], + ['tenant_id' => $tenantId, 'quantity' => 0, 'reserved_quantity' => 0] + ); + StockLevel::where('warehouse_id', $data['to_warehouse_id']) + ->where('product_id', $data['product_id']) + ->increment('quantity', $data['quantity']); + + StockMovement::create([ + 'tenant_id' => $tenantId, + 'product_id' => $data['product_id'], + 'warehouse_id' => $data['from_warehouse_id'], + 'type' => 'transfer', + 'quantity' => -$data['quantity'], + 'reference' => "Transfer to W#{$data['to_warehouse_id']}", + 'notes' => $data['notes'] ?? null, + 'created_by' => $request->user()->id, + ]); + + return $this->success([ + 'transferred' => $data['quantity'], + 'product_id' => $data['product_id'], + 'from' => $data['from_warehouse_id'], + 'to' => $data['to_warehouse_id'], + ]); + } + + private function tenantId(Request $request): int + { + return app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + } +} diff --git a/erp/app/Http/Controllers/Api/V1/WebhookApiController.php b/erp/app/Http/Controllers/Api/V1/WebhookApiController.php new file mode 100644 index 00000000000..89667737ec7 --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/WebhookApiController.php @@ -0,0 +1,121 @@ +tenantId($request); + $webhooks = Webhook::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->withCount('deliveries') + ->latest() + ->get(); + + return $this->success($webhooks); + } + + public function store(Request $request): JsonResponse + { + $tenantId = $this->tenantId($request); + + $data = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'url' => ['required', 'url'], + 'events' => ['required', 'array', 'min:1'], + 'events.*' => ['string', 'in:' . implode(',', self::$supportedEvents)], + 'is_active' => ['sometimes', 'boolean'], + ]); + + $webhook = Webhook::create([ + ...$data, + 'tenant_id' => $tenantId, + 'secret' => Str::random(32), + ]); + + return $this->success($webhook, 201); + } + + public function show(Webhook $webhook): JsonResponse + { + return $this->success($webhook->load('deliveries')); + } + + public function update(Request $request, Webhook $webhook): JsonResponse + { + $data = $request->validate([ + 'name' => ['sometimes', 'string', 'max:255'], + 'url' => ['sometimes', 'url'], + 'events' => ['sometimes', 'array', 'min:1'], + 'events.*' => ['string', 'in:' . implode(',', self::$supportedEvents)], + 'is_active' => ['sometimes', 'boolean'], + ]); + + $webhook->update($data); + return $this->success($webhook->fresh()); + } + + public function destroy(Webhook $webhook): JsonResponse + { + $webhook->delete(); + return $this->success(['message' => 'Webhook deleted.']); + } + + public function deliveries(Webhook $webhook): JsonResponse + { + $deliveries = $webhook->deliveries() + ->latest() + ->paginate(20); + + return $this->paginated($deliveries); + } + + public function ping(Request $request, Webhook $webhook): JsonResponse + { + $tenantId = $this->tenantId($request); + $delivery = WebhookService::send($webhook, 'ping', [ + 'tenant_id' => $tenantId, + 'message' => 'Webhook test ping from ERP', + 'timestamp' => now()->toIso8601String(), + ]); + + return $this->success([ + 'delivery_id' => $delivery->id, + 'success' => $delivery->delivered_at !== null, + 'response_status' => $delivery->response_status, + ]); + } + + public function rotateSecret(Webhook $webhook): JsonResponse + { + $webhook->update(['secret' => Str::random(32)]); + return $this->success(['secret' => $webhook->fresh()->secret]); + } + + public function events(): JsonResponse + { + return $this->success(self::$supportedEvents); + } + + private function tenantId(Request $request): int + { + return app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + } +} diff --git a/erp/app/Http/Controllers/Api/V1/WebsiteApiController.php b/erp/app/Http/Controllers/Api/V1/WebsiteApiController.php new file mode 100644 index 00000000000..e3e359771f6 --- /dev/null +++ b/erp/app/Http/Controllers/Api/V1/WebsiteApiController.php @@ -0,0 +1,132 @@ +has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $query = WebPage::where('tenant_id', $tenantId); + + if ($status = $request->query('status')) { + $query->where('status', $status); + } + + $paginator = $query->latest()->paginate(20); + + return $this->paginated($paginator); + } + + /** + * GET /api/v1/website/pages/{id} + */ + public function showPage(int $id): JsonResponse + { + $page = WebPage::findOrFail($id); + + return $this->success($page); + } + + /** + * POST /api/v1/website/pages + */ + public function storePage(Request $request): JsonResponse + { + $validated = $request->validate([ + 'title' => 'required|string|max:255', + 'slug' => 'required|string|max:255', + 'content' => 'nullable|string', + 'meta_title' => 'nullable|string|max:255', + 'meta_description' => 'nullable|string', + 'status' => 'nullable|string|in:draft,published,archived', + 'is_homepage' => 'nullable|boolean', + 'layout' => 'nullable|string|max:100', + ]); + + $tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $validated['tenant_id'] = $tenantId; + $validated['status'] = $validated['status'] ?? 'draft'; + + $page = WebPage::create($validated); + + return $this->success($page, 201); + } + + /** + * GET /api/v1/website/blog-posts + */ + public function blogPosts(Request $request): JsonResponse + { + $tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $query = BlogPost::where('tenant_id', $tenantId); + + if ($status = $request->query('status')) { + $query->where('status', $status); + } + + $paginator = $query->latest()->paginate(20); + + return $this->paginated($paginator); + } + + /** + * GET /api/v1/website/blog-posts/{id} + */ + public function showBlogPost(int $id): JsonResponse + { + $blogPost = BlogPost::findOrFail($id); + + return $this->success($blogPost); + } + + /** + * POST /api/v1/website/blog-posts + */ + public function storeBlogPost(Request $request): JsonResponse + { + $validated = $request->validate([ + 'title' => 'required|string|max:255', + 'slug' => 'required|string|max:255', + 'excerpt' => 'nullable|string', + 'content' => 'nullable|string', + 'featured_image' => 'nullable|string', + 'status' => 'nullable|string|in:draft,published,archived', + 'tags' => 'nullable|array', + ]); + + $tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $validated['tenant_id'] = $tenantId; + $validated['author_id'] = $request->user()->id; + $validated['status'] = $validated['status'] ?? 'draft'; + + $blogPost = BlogPost::create($validated); + + return $this->success($blogPost, 201); + } + + /** + * GET /api/v1/website/menus + */ + public function menus(Request $request): JsonResponse + { + $tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id; + + $menus = WebMenu::where('tenant_id', $tenantId)->get(); + + return $this->success($menus); + } +} diff --git a/erp/app/Http/Controllers/AuditLogController.php b/erp/app/Http/Controllers/AuditLogController.php new file mode 100644 index 00000000000..16eaeb2f6bc --- /dev/null +++ b/erp/app/Http/Controllers/AuditLogController.php @@ -0,0 +1,50 @@ +user()->hasAnyRole(['super-admin', 'admin'])) { + abort(403); + } + + $tenantId = $request->user()->tenant_id; + + $query = AuditLog::where('tenant_id', $tenantId) + ->with('user') + ->orderByDesc('created_at'); + + // Optional filters + if ($event = $request->query('event')) { + $query->where('event', $event); + } + if ($model = $request->query('model')) { + $query->where('auditable_type', 'like', "%{$model}%"); + } + + $logs = $query->paginate(50)->withQueryString()->through(fn ($log) => [ + 'id' => $log->id, + 'event' => $log->event, + 'model_name' => class_basename($log->auditable_type), + 'auditable_id' => $log->auditable_id, + 'user_name' => $log->user?->name ?? 'System', + 'old_values' => $log->old_values, + 'new_values' => $log->new_values, + 'ip_address' => $log->ip_address, + 'created_at' => $log->created_at?->toISOString(), + ]); + + return Inertia::render('Settings/AuditLog', [ + 'logs' => $logs, + 'filter_event' => $request->query('event', ''), + 'filter_model' => $request->query('model', ''), + ]); + } +} diff --git a/erp/app/Http/Controllers/Auth/AuthenticatedSessionController.php b/erp/app/Http/Controllers/Auth/AuthenticatedSessionController.php new file mode 100644 index 00000000000..d44fe974541 --- /dev/null +++ b/erp/app/Http/Controllers/Auth/AuthenticatedSessionController.php @@ -0,0 +1,52 @@ + Route::has('password.request'), + 'status' => session('status'), + ]); + } + + /** + * Handle an incoming authentication request. + */ + public function store(LoginRequest $request): RedirectResponse + { + $request->authenticate(); + + $request->session()->regenerate(); + + return redirect()->intended(route('dashboard', absolute: false)); + } + + /** + * Destroy an authenticated session. + */ + public function destroy(Request $request): RedirectResponse + { + Auth::guard('web')->logout(); + + $request->session()->invalidate(); + + $request->session()->regenerateToken(); + + return redirect('/'); + } +} diff --git a/erp/app/Http/Controllers/Auth/ConfirmablePasswordController.php b/erp/app/Http/Controllers/Auth/ConfirmablePasswordController.php new file mode 100644 index 00000000000..d2b1f14be72 --- /dev/null +++ b/erp/app/Http/Controllers/Auth/ConfirmablePasswordController.php @@ -0,0 +1,41 @@ +validate([ + 'email' => $request->user()->email, + 'password' => $request->password, + ])) { + throw ValidationException::withMessages([ + 'password' => __('auth.password'), + ]); + } + + $request->session()->put('auth.password_confirmed_at', time()); + + return redirect()->intended(route('dashboard', absolute: false)); + } +} diff --git a/erp/app/Http/Controllers/Auth/EmailVerificationNotificationController.php b/erp/app/Http/Controllers/Auth/EmailVerificationNotificationController.php new file mode 100644 index 00000000000..f64fa9ba798 --- /dev/null +++ b/erp/app/Http/Controllers/Auth/EmailVerificationNotificationController.php @@ -0,0 +1,24 @@ +user()->hasVerifiedEmail()) { + return redirect()->intended(route('dashboard', absolute: false)); + } + + $request->user()->sendEmailVerificationNotification(); + + return back()->with('status', 'verification-link-sent'); + } +} diff --git a/erp/app/Http/Controllers/Auth/EmailVerificationPromptController.php b/erp/app/Http/Controllers/Auth/EmailVerificationPromptController.php new file mode 100644 index 00000000000..b42e0d53462 --- /dev/null +++ b/erp/app/Http/Controllers/Auth/EmailVerificationPromptController.php @@ -0,0 +1,22 @@ +user()->hasVerifiedEmail() + ? redirect()->intended(route('dashboard', absolute: false)) + : Inertia::render('Auth/VerifyEmail', ['status' => session('status')]); + } +} diff --git a/erp/app/Http/Controllers/Auth/NewPasswordController.php b/erp/app/Http/Controllers/Auth/NewPasswordController.php new file mode 100644 index 00000000000..740cc5163f1 --- /dev/null +++ b/erp/app/Http/Controllers/Auth/NewPasswordController.php @@ -0,0 +1,69 @@ + $request->email, + 'token' => $request->route('token'), + ]); + } + + /** + * Handle an incoming new password request. + * + * @throws ValidationException + */ + public function store(Request $request): RedirectResponse + { + $request->validate([ + 'token' => 'required', + 'email' => 'required|email', + 'password' => ['required', 'confirmed', Rules\Password::defaults()], + ]); + + // Here we will attempt to reset the user's password. If it is successful we + // will update the password on an actual user model and persist it to the + // database. Otherwise we will parse the error and return the response. + $status = Password::reset( + $request->only('email', 'password', 'password_confirmation', 'token'), + function ($user) use ($request) { + $user->forceFill([ + 'password' => Hash::make($request->password), + 'remember_token' => Str::random(60), + ])->save(); + + event(new PasswordReset($user)); + } + ); + + // If the password was successfully reset, we will redirect the user back to + // the application's home authenticated view. If there is an error we can + // redirect them back to where they came from with their error message. + if ($status == Password::PASSWORD_RESET) { + return redirect()->route('login')->with('status', __($status)); + } + + throw ValidationException::withMessages([ + 'email' => [trans($status)], + ]); + } +} diff --git a/erp/app/Http/Controllers/Auth/PasswordController.php b/erp/app/Http/Controllers/Auth/PasswordController.php new file mode 100644 index 00000000000..57a82b58e0c --- /dev/null +++ b/erp/app/Http/Controllers/Auth/PasswordController.php @@ -0,0 +1,29 @@ +validate([ + 'current_password' => ['required', 'current_password'], + 'password' => ['required', Password::defaults(), 'confirmed'], + ]); + + $request->user()->update([ + 'password' => Hash::make($validated['password']), + ]); + + return back(); + } +} diff --git a/erp/app/Http/Controllers/Auth/PasswordResetLinkController.php b/erp/app/Http/Controllers/Auth/PasswordResetLinkController.php new file mode 100644 index 00000000000..c8b2b6f01b2 --- /dev/null +++ b/erp/app/Http/Controllers/Auth/PasswordResetLinkController.php @@ -0,0 +1,51 @@ + session('status'), + ]); + } + + /** + * Handle an incoming password reset link request. + * + * @throws ValidationException + */ + public function store(Request $request): RedirectResponse + { + $request->validate([ + 'email' => 'required|email', + ]); + + // We will send the password reset link to this user. Once we have attempted + // to send the link, we will examine the response then see the message we + // need to show to the user. Finally, we'll send out a proper response. + $status = Password::sendResetLink( + $request->only('email') + ); + + if ($status == Password::RESET_LINK_SENT) { + return back()->with('status', __($status)); + } + + throw ValidationException::withMessages([ + 'email' => [trans($status)], + ]); + } +} diff --git a/erp/app/Http/Controllers/Auth/RegisteredUserController.php b/erp/app/Http/Controllers/Auth/RegisteredUserController.php new file mode 100644 index 00000000000..3887f1cf4fb --- /dev/null +++ b/erp/app/Http/Controllers/Auth/RegisteredUserController.php @@ -0,0 +1,52 @@ +validate([ + 'name' => 'required|string|max:255', + 'email' => 'required|string|lowercase|email|max:255|unique:'.User::class, + 'password' => ['required', 'confirmed', Rules\Password::defaults()], + ]); + + $user = User::create([ + 'name' => $request->name, + 'email' => $request->email, + 'password' => Hash::make($request->password), + ]); + + event(new Registered($user)); + + Auth::login($user); + + return redirect(route('dashboard', absolute: false)); + } +} diff --git a/erp/app/Http/Controllers/Auth/VerifyEmailController.php b/erp/app/Http/Controllers/Auth/VerifyEmailController.php new file mode 100644 index 00000000000..784765e3a59 --- /dev/null +++ b/erp/app/Http/Controllers/Auth/VerifyEmailController.php @@ -0,0 +1,27 @@ +user()->hasVerifiedEmail()) { + return redirect()->intended(route('dashboard', absolute: false).'?verified=1'); + } + + if ($request->user()->markEmailAsVerified()) { + event(new Verified($request->user())); + } + + return redirect()->intended(route('dashboard', absolute: false).'?verified=1'); + } +} diff --git a/erp/app/Http/Controllers/CompanySettingsController.php b/erp/app/Http/Controllers/CompanySettingsController.php new file mode 100644 index 00000000000..7fbcf5d9926 --- /dev/null +++ b/erp/app/Http/Controllers/CompanySettingsController.php @@ -0,0 +1,88 @@ +user()->hasAnyRole(['super-admin', 'admin'])) { + abort(403); + } + } + + public function show(Request $request) + { + $this->authorizeAdmin($request); + + $tenant = Tenant::findOrFail($request->user()->tenant_id); + + $timezones = \DateTimeZone::listIdentifiers(); + $currencies = ['USD', 'EUR', 'GBP', 'JPY', 'CAD', 'AUD', 'CHF', 'CNY', 'INR', 'SGD', + 'AED', 'SAR', 'BRL', 'MXN', 'ZAR', 'NOK', 'SEK', 'DKK', 'NZD', 'HKD']; + $dateFormats = [ + 'Y-m-d' => now()->format('Y-m-d') . ' (YYYY-MM-DD)', + 'd/m/Y' => now()->format('d/m/Y') . ' (DD/MM/YYYY)', + 'm/d/Y' => now()->format('m/d/Y') . ' (MM/DD/YYYY)', + 'd-m-Y' => now()->format('d-m-Y') . ' (DD-MM-YYYY)', + 'd M Y' => now()->format('d M Y') . ' (DD Mon YYYY)', + ]; + + return Inertia::render('Settings/Company', [ + 'tenant' => $tenant->only([ + 'id', 'name', 'slug', 'email', 'phone', 'address', 'city', + 'country', 'currency_code', 'timezone', 'date_format', 'logo_path', + ]), + 'timezones' => $timezones, + 'currencies' => $currencies, + 'dateFormats' => $dateFormats, + ]); + } + + public function update(Request $request) + { + $this->authorizeAdmin($request); + + $tenant = Tenant::findOrFail($request->user()->tenant_id); + + $data = $request->validate([ + 'name' => 'required|string|max:191', + 'email' => 'nullable|email|max:191', + 'phone' => 'nullable|string|max:50', + 'address' => 'nullable|string|max:500', + 'city' => 'nullable|string|max:100', + 'country' => 'nullable|string|max:100', + 'currency_code' => 'required|string|size:3', + 'timezone' => 'required|string|timezone', + 'date_format' => 'required|string|in:Y-m-d,d/m/Y,m/d/Y,d-m-Y,d M Y', + ]); + + $tenant->update($data); + + return back()->with('success', 'Company settings saved.'); + } + + public function uploadLogo(Request $request) + { + $this->authorizeAdmin($request); + + $request->validate(['logo' => 'required|image|mimes:png,jpg,jpeg,gif,svg|max:1024']); + + $tenant = Tenant::findOrFail($request->user()->tenant_id); + + // Delete old logo if exists + if ($tenant->logo_path && Storage::disk('public')->exists($tenant->logo_path)) { + Storage::disk('public')->delete($tenant->logo_path); + } + + $path = $request->file('logo')->store('logos', 'public'); + $tenant->update(['logo_path' => $path]); + + return back()->with('success', 'Logo uploaded.'); + } +} diff --git a/erp/app/Http/Controllers/Controller.php b/erp/app/Http/Controllers/Controller.php new file mode 100644 index 00000000000..e7f7c94bba8 --- /dev/null +++ b/erp/app/Http/Controllers/Controller.php @@ -0,0 +1,10 @@ +user()->tenant_id; + + // KPI: revenue this month (invoices issued this month, not cancelled) + $revenueThisMonth = Invoice::where('tenant_id', $tenantId) + ->whereNotIn('status', ['cancelled']) + ->whereYear('issue_date', now()->year) + ->whereMonth('issue_date', now()->month) + ->get() + ->sum(fn ($inv) => $inv->load('items')->total); + + // KPI: expenses this month (bills issued this month, not cancelled) + $expensesThisMonth = Bill::where('tenant_id', $tenantId) + ->whereNotIn('status', ['cancelled']) + ->whereYear('issue_date', now()->year) + ->whereMonth('issue_date', now()->month) + ->get() + ->sum(fn ($b) => $b->load('items')->total); + + // KPI: outstanding AR (amount due on unpaid invoices) + $outstandingAr = Invoice::where('tenant_id', $tenantId) + ->whereNotIn('status', ['paid', 'cancelled']) + ->with(['items', 'payments']) + ->get() + ->sum(fn ($inv) => $inv->amount_due); + + // KPI: outstanding AP (amount due on unpaid bills) + $outstandingAp = Bill::where('tenant_id', $tenantId) + ->whereNotIn('status', ['paid', 'cancelled']) + ->with(['items', 'payments']) + ->get() + ->sum(fn ($b) => $b->amount_due); + + // Monthly revenue vs expenses — last 12 months + $months = collect(); + for ($i = 11; $i >= 0; $i--) { + $date = now()->subMonths($i); + $label = $date->format('M y'); + $year = (int) $date->format('Y'); + $month = (int) $date->format('n'); + + $rev = Invoice::where('tenant_id', $tenantId) + ->whereNotIn('status', ['cancelled']) + ->whereYear('issue_date', $year) + ->whereMonth('issue_date', $month) + ->with('items') + ->get() + ->sum(fn ($inv) => $inv->total); + + $exp = Bill::where('tenant_id', $tenantId) + ->whereNotIn('status', ['cancelled']) + ->whereYear('issue_date', $year) + ->whereMonth('issue_date', $month) + ->with('items') + ->get() + ->sum(fn ($b) => $b->total); + + $months->push(['month' => $label, 'revenue' => round($rev, 2), 'expenses' => round($exp, 2)]); + } + + // Recent invoices (last 5) + $recentInvoices = Invoice::where('tenant_id', $tenantId) + ->with(['contact', 'items', 'payments']) + ->latest() + ->take(5) + ->get() + ->map(fn ($inv) => [ + 'id' => $inv->id, + 'number' => $inv->number, + 'contact' => $inv->contact?->name, + 'total' => round($inv->total, 2), + 'amount_due'=> round($inv->amount_due, 2), + 'status' => $inv->status, + 'issue_date'=> $inv->issue_date, + ]); + + // Low stock: products where total stock across all warehouses < 10 + $lowStock = Product::where('tenant_id', $tenantId) + ->with(['stockLevels']) + ->get() + ->filter(function ($product) { + $total = $product->stockLevels->sum('quantity'); + return $total < 10; + }) + ->take(10) + ->map(fn ($p) => [ + 'id' => $p->id, + 'sku' => $p->sku, + 'name' => $p->name, + 'quantity' => round($p->stockLevels->sum('quantity'), 2), + ]) + ->values(); + + // Overdue invoices count + $overdueCount = Invoice::where('tenant_id', $tenantId) + ->whereNotIn('status', ['paid', 'cancelled']) + ->whereNotNull('due_date') + ->where('due_date', '<', now()->startOfDay()) + ->count(); + + // Module stats — counts per module scoped to tenant + $moduleStats = [ + 'open_invoices' => Invoice::where('tenant_id', $tenantId)->whereNotIn('status', ['paid', 'cancelled'])->count(), + 'open_bills' => Bill::where('tenant_id', $tenantId)->whereNotIn('status', ['paid', 'cancelled'])->count(), + 'pending_pos' => \App\Modules\Purchase\Models\Po::where('tenant_id', $tenantId)->where('status', 'draft')->count(), + 'active_projects' => \App\Modules\PM\Models\Project::where('tenant_id', $tenantId)->where('status', 'active')->count(), + 'open_tickets' => \App\Modules\Helpdesk\Models\HelpdeskTicket::where('tenant_id', $tenantId)->where('status', '!=', 'closed')->count(), + 'pending_approvals' => \App\Modules\Approvals\Models\ApprovalRequest::where('tenant_id', $tenantId)->where('status', 'pending')->count(), + 'active_employees' => \App\Modules\HR\Models\Employee::where('tenant_id', $tenantId)->where('status', 'active')->count(), + 'total_products' => \App\Modules\Inventory\Models\Product::where('tenant_id', $tenantId)->count(), + ]; + + // Activity feed — last 10 records across key models + $feed = collect(); + $feed = $feed->concat( + Invoice::where('tenant_id', $tenantId)->latest()->limit(3)->get()->map(fn ($i) => ['type' => 'invoice', 'label' => 'Invoice ' . $i->number, 'status' => $i->status, 'at' => $i->created_at]) + ); + $feed = $feed->concat( + \App\Modules\Purchase\Models\Po::where('tenant_id', $tenantId)->latest()->limit(3)->get()->map(fn ($p) => ['type' => 'po', 'label' => 'PO ' . $p->po_number, 'status' => $p->status, 'at' => $p->created_at]) + ); + $feed = $feed->concat( + \App\Modules\HR\Models\PayrollRun::where('tenant_id', $tenantId)->latest()->limit(2)->get()->map(fn ($r) => ['type' => 'payroll', 'label' => 'Payroll ' . $r->period_label, 'status' => $r->status, 'at' => $r->created_at]) + ); + $activityFeed = $feed->sortByDesc('at')->take(10)->values(); + + return Inertia::render('Dashboard', [ + 'breadcrumbs' => [ + ['label' => 'Dashboard', 'href' => route('dashboard')], + ], + 'kpis' => [ + 'revenue_this_month' => round($revenueThisMonth, 2), + 'expenses_this_month' => round($expensesThisMonth, 2), + 'outstanding_ar' => round($outstandingAr, 2), + 'outstanding_ap' => round($outstandingAp, 2), + 'overdue_count' => $overdueCount, + ], + 'monthly_chart' => $months->values(), + 'recent_invoices' => $recentInvoices->values(), + 'low_stock' => $lowStock, + 'module_stats' => $moduleStats, + 'activity_feed' => $activityFeed, + ]); + } +} diff --git a/erp/app/Http/Controllers/ExecutiveDashboardController.php b/erp/app/Http/Controllers/ExecutiveDashboardController.php new file mode 100644 index 00000000000..3762c6cf3e6 --- /dev/null +++ b/erp/app/Http/Controllers/ExecutiveDashboardController.php @@ -0,0 +1,216 @@ +user()->tenant_id; + $now = Carbon::now(); + + // ── Financial KPIs ──────────────────────────────────────────────────── + + // Monthly revenue: sum of paid invoice line-item totals this month + $monthlyRevenue = Invoice::where('tenant_id', $tenantId) + ->whereIn('status', ['paid', 'partial']) + ->whereYear('issue_date', $now->year) + ->whereMonth('issue_date', $now->month) + ->with('items') + ->get() + ->sum(fn ($inv) => $inv->total); + + // Monthly expenses: approved/paid expense claims this month + $monthlyExpenses = ExpenseClaim::where('tenant_id', $tenantId) + ->whereIn('status', ['approved', 'paid']) + ->whereYear('claim_date', $now->year) + ->whereMonth('claim_date', $now->month) + ->sum('total_amount'); + + // Outstanding invoices: status in (sent, partial, overdue) + $outstandingInvoices = Invoice::where('tenant_id', $tenantId) + ->whereIn('status', ['sent', 'partial']) + ->with(['items', 'payments']) + ->get(); + + $outstandingInvoicesCount = $outstandingInvoices->count(); + $outstandingInvoicesTotal = $outstandingInvoices->sum(fn ($inv) => $inv->amount_due); + + // Overdue invoices: status=overdue OR (due_date < today AND not paid/cancelled/draft) + $overdueInvoicesCount = Invoice::where('tenant_id', $tenantId) + ->where(function ($q) use ($now) { + $q->where(function ($sub) use ($now) { + $sub->whereNotIn('status', ['paid', 'cancelled', 'draft']) + ->where('due_date', '<', $now->startOfDay()->toDateString()); + }); + }) + ->count(); + + // ── Operations KPIs ─────────────────────────────────────────────────── + + // Low stock: products where stock_quantity <= reorder_point + $lowStockCount = Product::where('tenant_id', $tenantId) + ->whereColumn('stock_quantity', '<=', 'reorder_point') + ->where('reorder_point', '>', 0) + ->count(); + + // Open purchase orders: bills with status draft or received (closest to "sent") + $openPurchaseOrders = Bill::where('tenant_id', $tenantId) + ->whereIn('status', ['draft', 'received']) + ->count(); + + // Active manufacturing orders + $activeManufacturingOrders = ManufacturingOrder::where('tenant_id', $tenantId) + ->whereIn('status', ['confirmed', 'in_progress']) + ->count(); + + // ── People KPIs ─────────────────────────────────────────────────────── + + $totalEmployees = Employee::where('tenant_id', $tenantId) + ->where('status', 'active') + ->count(); + + $pendingLeaveRequests = LeaveRequest::where('tenant_id', $tenantId) + ->where('status', 'pending') + ->count(); + + $openHelpdeskTickets = HelpdeskTicket::where('tenant_id', $tenantId) + ->whereIn('status', ['open', 'in_progress', 'pending']) + ->count(); + + // ── CRM KPIs ────────────────────────────────────────────────────────── + + $openLeads = CrmLead::where('tenant_id', $tenantId) + ->where('type', 'lead') + ->where('status', 'open') + ->count(); + + $openOpportunities = CrmLead::where('tenant_id', $tenantId) + ->where('type', 'opportunity') + ->where('status', 'open') + ->count(); + + $pipelineValue = CrmLead::where('tenant_id', $tenantId) + ->where('type', 'opportunity') + ->where('status', 'open') + ->sum('expected_revenue'); + + // ── Revenue Trend (last 6 months) ───────────────────────────────────── + + $revenueTrend = collect(); + for ($i = 5; $i >= 0; $i--) { + $date = $now->copy()->subMonths($i); + $rev = Invoice::where('tenant_id', $tenantId) + ->whereIn('status', ['paid', 'partial', 'sent']) + ->whereYear('issue_date', $date->year) + ->whereMonth('issue_date', $date->month) + ->with('items') + ->get() + ->sum(fn ($inv) => $inv->total); + + $revenueTrend->push([ + 'month' => $date->format('M Y'), + 'revenue' => round($rev, 2), + ]); + } + + // ── Recent Activity ─────────────────────────────────────────────────── + + $recentInvoices = Invoice::where('tenant_id', $tenantId) + ->with('contact') + ->latest() + ->take(3) + ->get() + ->map(fn ($inv) => [ + 'type' => 'Invoice', + 'title' => ($inv->number ?? 'INV') . ($inv->contact ? ' — ' . $inv->contact->name : ''), + 'status' => $inv->status, + 'created_at' => $inv->created_at, + 'url' => '/finance/invoices/' . $inv->id, + ]); + + $recentLeads = CrmLead::where('tenant_id', $tenantId) + ->latest() + ->take(3) + ->get() + ->map(fn ($lead) => [ + 'type' => 'Lead', + 'title' => $lead->title ?? ($lead->contact_name ?? 'Lead'), + 'status' => $lead->status, + 'created_at' => $lead->created_at, + 'url' => '/crm/leads/' . $lead->id, + ]); + + $recentTickets = HelpdeskTicket::where('tenant_id', $tenantId) + ->latest() + ->take(3) + ->get() + ->map(fn ($t) => [ + 'type' => 'Ticket', + 'title' => $t->subject ?? 'Ticket #' . $t->id, + 'status' => $t->status, + 'created_at' => $t->created_at, + 'url' => '/helpdesk/tickets/' . $t->id, + ]); + + $recentOrders = ManufacturingOrder::where('tenant_id', $tenantId) + ->latest() + ->take(1) + ->get() + ->map(fn ($mo) => [ + 'type' => 'Order', + 'title' => $mo->mo_number ?? 'MO #' . $mo->id, + 'status' => $mo->status, + 'created_at' => $mo->created_at, + 'url' => '/manufacturing/manufacturing-orders/' . $mo->id, + ]); + + $recentActivity = $recentInvoices + ->concat($recentLeads) + ->concat($recentTickets) + ->concat($recentOrders) + ->sortByDesc('created_at') + ->take(10) + ->values() + ->map(fn ($item) => array_merge($item, [ + 'created_at' => $item['created_at'] ? $item['created_at']->toIso8601String() : null, + ])); + + return Inertia::render('Dashboard/Executive', [ + // Financial + 'monthly_revenue' => round($monthlyRevenue, 2), + 'monthly_expenses' => round($monthlyExpenses, 2), + 'outstanding_invoices_count' => $outstandingInvoicesCount, + 'outstanding_invoices_total' => round($outstandingInvoicesTotal, 2), + 'overdue_invoices_count' => $overdueInvoicesCount, + // Operations + 'low_stock_count' => $lowStockCount, + 'open_purchase_orders' => $openPurchaseOrders, + 'active_manufacturing_orders' => $activeManufacturingOrders, + // People + 'total_employees' => $totalEmployees, + 'pending_leave_requests' => $pendingLeaveRequests, + 'open_helpdesk_tickets' => $openHelpdeskTickets, + // CRM + 'open_leads' => $openLeads, + 'open_opportunities' => $openOpportunities, + 'pipeline_value' => round($pipelineValue, 2), + // Charts + 'revenue_trend' => $revenueTrend->values(), + 'recent_activity' => $recentActivity, + ]); + } +} diff --git a/erp/app/Http/Controllers/ExportController.php b/erp/app/Http/Controllers/ExportController.php new file mode 100644 index 00000000000..df48177b3db --- /dev/null +++ b/erp/app/Http/Controllers/ExportController.php @@ -0,0 +1,94 @@ +streamDownload(function () { + $out = fopen('php://output', 'w'); + fputcsv($out, ['SKU', 'Name', 'Category', 'Cost Price', 'Selling Price', 'Reorder Point', 'Active']); + + Product::with('category')->chunk(200, function ($products) use ($out) { + foreach ($products as $p) { + fputcsv($out, [ + $p->sku ?? '', + $p->name, + $p->category?->name ?? '', + $p->cost_price, + $p->sale_price, + $p->reorder_point, + $p->is_active ? 'Yes' : 'No', + ]); + } + }); + + fclose($out); + }, 'products-' . now()->format('Y-m-d') . '.csv', ['Content-Type' => 'text/csv']); + } + + public function invoices(): StreamedResponse + { + Gate::authorize('viewAny', Invoice::class); + + return response()->streamDownload(function () { + $out = fopen('php://output', 'w'); + fputcsv($out, ['Number', 'Contact', 'Status', 'Issue Date', 'Due Date', 'Total', 'Amount Due']); + + Invoice::with(['contact', 'items', 'payments'])->chunk(200, function ($invoices) use ($out) { + foreach ($invoices as $inv) { + fputcsv($out, [ + $inv->number ?? '', + $inv->contact?->name ?? '', + $inv->status, + $inv->issue_date?->toDateString() ?? '', + $inv->due_date?->toDateString() ?? '', + $inv->total, + $inv->amount_due, + ]); + } + }); + + fclose($out); + }, 'invoices-' . now()->format('Y-m-d') . '.csv', ['Content-Type' => 'text/csv']); + } + + public function employees(): StreamedResponse + { + // Export requires create permission (manager+) to protect sensitive salary data + Gate::authorize('create', Employee::class); + + return response()->streamDownload(function () { + $out = fopen('php://output', 'w'); + fputcsv($out, ['Employee #', 'First Name', 'Last Name', 'Email', 'Position', 'Department', 'Type', 'Status', 'Start Date', 'Salary']); + + Employee::with('department')->chunk(200, function ($employees) use ($out) { + foreach ($employees as $emp) { + fputcsv($out, [ + $emp->employee_number ?? '', + $emp->first_name, + $emp->last_name, + $emp->email ?? '', + $emp->position ?? '', + $emp->department?->name ?? '', + $emp->employment_type, + $emp->status, + $emp->start_date?->toDateString() ?? '', + $emp->salary_amount, + ]); + } + }); + + fclose($out); + }, 'employees-' . now()->format('Y-m-d') . '.csv', ['Content-Type' => 'text/csv']); + } +} diff --git a/erp/app/Http/Controllers/GlobalSearchController.php b/erp/app/Http/Controllers/GlobalSearchController.php new file mode 100644 index 00000000000..dfe9fa78609 --- /dev/null +++ b/erp/app/Http/Controllers/GlobalSearchController.php @@ -0,0 +1,189 @@ +get('q', '')); + + if (strlen($q) < 2) { + return response()->json(['results' => []]); + } + + $tenantId = auth()->user()->tenant_id; + $like = "%{$q}%"; + $results = []; + + // Products (name, sku) + $products = Product::where('tenant_id', $tenantId) + ->where(function ($query) use ($like) { + $query->where('name', 'like', $like) + ->orWhere('sku', 'like', $like); + }) + ->limit(self::LIMIT) + ->get(); + + foreach ($products as $r) { + $results[] = [ + 'id' => $r->id, + 'title' => $r->name, + 'subtitle' => $r->sku ?? '', + 'url' => "/inventory/products/{$r->id}", + 'type' => 'Product', + ]; + } + + // Invoices (number, customer name via contact) + $invoices = Invoice::where('tenant_id', $tenantId) + ->where(function ($query) use ($like) { + $query->where('number', 'like', $like) + ->orWhereHas('contact', fn ($q) => $q->where('name', 'like', $like)); + }) + ->with('contact') + ->limit(self::LIMIT) + ->get(); + + foreach ($invoices as $r) { + $results[] = [ + 'id' => $r->id, + 'title' => $r->number ?? "Invoice #{$r->id}", + 'subtitle' => $r->contact?->name ?? '', + 'url' => "/finance/invoices/{$r->id}", + 'type' => 'Invoice', + ]; + } + + // Contacts (name, email, phone) + $contacts = Contact::where('tenant_id', $tenantId) + ->where(function ($query) use ($like) { + $query->where('name', 'like', $like) + ->orWhere('email', 'like', $like) + ->orWhere('phone', 'like', $like); + }) + ->limit(self::LIMIT) + ->get(); + + foreach ($contacts as $r) { + $results[] = [ + 'id' => $r->id, + 'title' => $r->name, + 'subtitle' => $r->email ?? '', + 'url' => "/finance/contacts/{$r->id}", + 'type' => 'Contact', + ]; + } + + // CRM Leads (title, contact_name, company_name) + $leads = CrmLead::where('tenant_id', $tenantId) + ->where(function ($query) use ($like) { + $query->where('title', 'like', $like) + ->orWhere('contact_name', 'like', $like) + ->orWhere('company_name', 'like', $like); + }) + ->limit(self::LIMIT) + ->get(); + + foreach ($leads as $r) { + $results[] = [ + 'id' => $r->id, + 'title' => $r->title, + 'subtitle' => $r->company_name ?? $r->contact_name ?? '', + 'url' => "/crm/leads/{$r->id}", + 'type' => 'Lead', + ]; + } + + // Helpdesk Tickets (ticket_number, subject, customer_name) + $tickets = HelpdeskTicket::where('tenant_id', $tenantId) + ->where(function ($query) use ($like) { + $query->where('ticket_number', 'like', $like) + ->orWhere('subject', 'like', $like) + ->orWhere('customer_name', 'like', $like); + }) + ->limit(self::LIMIT) + ->get(); + + foreach ($tickets as $r) { + $results[] = [ + 'id' => $r->id, + 'title' => $r->subject, + 'subtitle' => $r->ticket_number ?? '', + 'url' => "/helpdesk/tickets/{$r->id}", + 'type' => 'Ticket', + ]; + } + + // Employees (first_name/last_name combined, employee_number, email) + $employees = Employee::where('tenant_id', $tenantId) + ->where(function ($query) use ($like) { + $query->where('first_name', 'like', $like) + ->orWhere('last_name', 'like', $like) + ->orWhere('email', 'like', $like) + ->orWhere('employee_number', 'like', $like); + }) + ->limit(self::LIMIT) + ->get(); + + foreach ($employees as $r) { + $results[] = [ + 'id' => $r->id, + 'title' => $r->full_name, + 'subtitle' => $r->employee_number ?? '', + 'url' => "/hr/employees/{$r->id}", + 'type' => 'Employee', + ]; + } + + // PM Projects (name) + $projects = Project::where('tenant_id', $tenantId) + ->where('name', 'like', $like) + ->limit(self::LIMIT) + ->get(); + + foreach ($projects as $r) { + $results[] = [ + 'id' => $r->id, + 'title' => $r->name, + 'subtitle' => $r->code ?? '', + 'url' => "/pm/projects/{$r->id}", + 'type' => 'Project', + ]; + } + + // Store Orders (order_number, customer_name) + $orders = StoreOrder::where('tenant_id', $tenantId) + ->where(function ($query) use ($like) { + $query->where('order_number', 'like', $like) + ->orWhere('customer_name', 'like', $like); + }) + ->limit(self::LIMIT) + ->get(); + + foreach ($orders as $r) { + $results[] = [ + 'id' => $r->id, + 'title' => $r->order_number ?? "Order #{$r->id}", + 'subtitle' => $r->customer_name ?? '', + 'url' => "/ecommerce/orders/{$r->id}", + 'type' => 'Order', + ]; + } + + return response()->json(['results' => $results]); + } +} diff --git a/erp/app/Http/Controllers/ImportController.php b/erp/app/Http/Controllers/ImportController.php new file mode 100644 index 00000000000..b095989cc3d --- /dev/null +++ b/erp/app/Http/Controllers/ImportController.php @@ -0,0 +1,296 @@ +all(), [ + 'file' => ['required', 'file', 'mimes:csv,txt', 'max:2048'], + ]); + + if ($validator->fails()) { + return back()->withErrors($validator)->withInput(); + } + + $tenantId = $request->user()->tenant_id; + $path = $request->file('file')->getRealPath(); + $handle = fopen($path, 'r'); + + $created = 0; + $updated = 0; + $skipped = 0; + $isFirst = true; + + while (($row = fgetcsv($handle)) !== false) { + // Skip header + if ($isFirst) { + $isFirst = false; + continue; + } + + // Skip empty rows + if (count(array_filter($row)) === 0) { + continue; + } + + try { + $name = trim($row[0] ?? ''); + $sku = trim($row[1] ?? ''); + $salePrice = isset($row[2]) && $row[2] !== '' ? (float) $row[2] : 0; + $costPrice = isset($row[3]) && $row[3] !== '' ? (float) $row[3] : 0; + + if ($name === '') { + $skipped++; + continue; + } + + // If SKU provided, match on SKU; otherwise generate one + if ($sku !== '') { + $existing = Product::where('tenant_id', $tenantId) + ->where('sku', $sku) + ->first(); + } else { + $sku = strtoupper(substr(preg_replace('/[^A-Za-z0-9]/', '', $name), 0, 10)) . '-' . time(); + $existing = null; + } + + if ($existing) { + $existing->update([ + 'name' => $name, + 'sale_price' => $salePrice, + 'cost_price' => $costPrice, + ]); + $updated++; + } else { + Product::create([ + 'tenant_id' => $tenantId, + 'name' => $name, + 'sku' => $sku, + 'sale_price' => $salePrice, + 'cost_price' => $costPrice, + 'is_active' => true, + ]); + $created++; + } + } catch (\Throwable $e) { + $skipped++; + } + } + + fclose($handle); + + $total = $created + $updated; + return redirect()->route('import.index') + ->with('success', "Imported {$total} records ({$created} created, {$updated} updated, {$skipped} skipped)"); + } + + /** + * Import employees from CSV. + * Columns: first_name, last_name, email, department, position, hire_date + */ + public function employees(Request $request) + { + $validator = Validator::make($request->all(), [ + 'file' => ['required', 'file', 'mimes:csv,txt', 'max:2048'], + ]); + + if ($validator->fails()) { + return back()->withErrors($validator)->withInput(); + } + + $tenantId = $request->user()->tenant_id; + $path = $request->file('file')->getRealPath(); + $handle = fopen($path, 'r'); + + $created = 0; + $updated = 0; + $skipped = 0; + $isFirst = true; + + while (($row = fgetcsv($handle)) !== false) { + if ($isFirst) { + $isFirst = false; + continue; + } + + if (count(array_filter($row)) === 0) { + continue; + } + + try { + $firstName = trim($row[0] ?? ''); + $lastName = trim($row[1] ?? ''); + $email = trim($row[2] ?? ''); + $position = trim($row[3] ?? ''); // department column mapped to position + $jobTitle = trim($row[4] ?? ''); + $hireDate = trim($row[5] ?? ''); + + if ($firstName === '' || $lastName === '') { + $skipped++; + continue; + } + + // If email provided, try to match + if ($email !== '') { + $existing = Employee::where('tenant_id', $tenantId) + ->where('email', $email) + ->first(); + } else { + $existing = null; + } + + $data = [ + 'first_name' => $firstName, + 'last_name' => $lastName, + 'position' => $jobTitle ?: $position, + 'status' => 'active', + ]; + + if ($email !== '') { + $data['email'] = $email; + } + + if ($hireDate !== '') { + // hire_date is an accessor that maps to start_date + $data['start_date'] = $hireDate; + } + + if ($existing) { + $existing->update($data); + $updated++; + } else { + if (empty($data['start_date'])) { + $data['start_date'] = now()->toDateString(); + } + $data['tenant_id'] = $tenantId; + Employee::create($data); + $created++; + } + } catch (\Throwable $e) { + $skipped++; + } + } + + fclose($handle); + + $total = $created + $updated; + return redirect()->route('import.index') + ->with('success', "Imported {$total} records ({$created} created, {$updated} updated, {$skipped} skipped)"); + } + + /** + * Import contacts from CSV. + * Columns: name, email, phone, type (customer/supplier/both) + */ + public function contacts(Request $request) + { + $validator = Validator::make($request->all(), [ + 'file' => ['required', 'file', 'mimes:csv,txt', 'max:2048'], + ]); + + if ($validator->fails()) { + return back()->withErrors($validator)->withInput(); + } + + $tenantId = $request->user()->tenant_id; + $path = $request->file('file')->getRealPath(); + $handle = fopen($path, 'r'); + + $created = 0; + $updated = 0; + $skipped = 0; + $isFirst = true; + + $validTypes = ['customer', 'vendor', 'both']; + + while (($row = fgetcsv($handle)) !== false) { + if ($isFirst) { + $isFirst = false; + continue; + } + + if (count(array_filter($row)) === 0) { + continue; + } + + try { + $name = trim($row[0] ?? ''); + $email = trim($row[1] ?? ''); + $phone = trim($row[2] ?? ''); + $type = strtolower(trim($row[3] ?? 'customer')); + + if ($name === '') { + $skipped++; + continue; + } + + // Map "supplier" -> "vendor" + if ($type === 'supplier') { + $type = 'vendor'; + } + + if (!in_array($type, $validTypes)) { + $type = 'customer'; + } + + // Match on email if provided + if ($email !== '') { + $existing = Contact::where('tenant_id', $tenantId) + ->where('email', $email) + ->first(); + } else { + $existing = null; + } + + $data = [ + 'name' => $name, + 'type' => $type, + 'phone' => $phone ?: null, + ]; + + if ($email !== '') { + $data['email'] = $email; + } + + if ($existing) { + $existing->update($data); + $updated++; + } else { + $data['tenant_id'] = $tenantId; + $data['is_active'] = true; + Contact::create($data); + $created++; + } + } catch (\Throwable $e) { + $skipped++; + } + } + + fclose($handle); + + $total = $created + $updated; + return redirect()->route('import.index') + ->with('success', "Imported {$total} records ({$created} created, {$updated} updated, {$skipped} skipped)"); + } +} diff --git a/erp/app/Http/Controllers/NotificationController.php b/erp/app/Http/Controllers/NotificationController.php new file mode 100644 index 00000000000..814b4ee336f --- /dev/null +++ b/erp/app/Http/Controllers/NotificationController.php @@ -0,0 +1,54 @@ +user() + ->notifications() + ->latest() + ->paginate(25) + ->through(fn ($n) => [ + 'id' => $n->id, + 'type' => $n->data['type'] ?? 'info', + 'title' => $n->data['title'] ?? '', + 'message' => $n->data['message'] ?? '', + 'link' => $n->data['link'] ?? null, + 'read' => ! is_null($n->read_at), + 'created_at' => $n->created_at->diffForHumans(), + ]); + + return Inertia::render('Notifications/Index', [ + 'notifications' => $notifications, + 'breadcrumbs' => [['label' => 'Notifications']], + ]); + } + + public function markRead(string $id): RedirectResponse + { + auth()->user()->notifications()->findOrFail($id)->markAsRead(); + + return back()->with('success', 'Notification marked as read.'); + } + + public function markAllRead(): RedirectResponse + { + auth()->user()->unreadNotifications->markAsRead(); + + return back()->with('success', 'All notifications marked as read.'); + } + + public function destroy(string $id): RedirectResponse + { + auth()->user()->notifications()->findOrFail($id)->delete(); + + return back()->with('success', 'Notification deleted.'); + } +} diff --git a/erp/app/Http/Controllers/ProfileController.php b/erp/app/Http/Controllers/ProfileController.php new file mode 100644 index 00000000000..873b4f7d7e0 --- /dev/null +++ b/erp/app/Http/Controllers/ProfileController.php @@ -0,0 +1,63 @@ + $request->user() instanceof MustVerifyEmail, + 'status' => session('status'), + ]); + } + + /** + * Update the user's profile information. + */ + public function update(ProfileUpdateRequest $request): RedirectResponse + { + $request->user()->fill($request->validated()); + + if ($request->user()->isDirty('email')) { + $request->user()->email_verified_at = null; + } + + $request->user()->save(); + + return Redirect::route('profile.edit'); + } + + /** + * Delete the user's account. + */ + public function destroy(Request $request): RedirectResponse + { + $request->validate([ + 'password' => ['required', 'current_password'], + ]); + + $user = $request->user(); + + Auth::logout(); + + $user->delete(); + + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + return Redirect::to('/'); + } +} diff --git a/erp/app/Http/Controllers/QueueMonitorController.php b/erp/app/Http/Controllers/QueueMonitorController.php new file mode 100644 index 00000000000..a36e04b5d66 --- /dev/null +++ b/erp/app/Http/Controllers/QueueMonitorController.php @@ -0,0 +1,35 @@ +count(); + $failed = DB::table('failed_jobs')->count(); + $byQueue = DB::table('jobs')->select('queue', DB::raw('count(*) as count'))->groupBy('queue')->get(); + $recentFailed = DB::table('failed_jobs')->orderByDesc('failed_at')->limit(10)->get(); + + return Inertia::render('Queue/Monitor', compact('pending', 'failed', 'byQueue', 'recentFailed')); + } + + public function retryFailed(string $uuid): RedirectResponse + { + DB::table('failed_jobs')->where('uuid', $uuid)->delete(); + + return redirect()->back()->with('success', 'Job removed from failed queue.'); + } + + public function clearFailed(): RedirectResponse + { + DB::table('failed_jobs')->truncate(); + + return redirect()->back()->with('success', 'Failed jobs cleared.'); + } +} diff --git a/erp/app/Http/Controllers/SearchController.php b/erp/app/Http/Controllers/SearchController.php new file mode 100644 index 00000000000..4838c495724 --- /dev/null +++ b/erp/app/Http/Controllers/SearchController.php @@ -0,0 +1,56 @@ +get('q', '')); + + if (strlen($query) < 2) { + return response()->json(['results' => []]); + } + + $user = auth()->user(); + $results = []; + + if ($user->can('finance.view')) { + foreach (Invoice::where('number', 'like', "%{$query}%")->with('contact')->limit(self::LIMIT)->get() as $inv) { + $results[] = ['type' => 'Invoice', 'label' => $inv->number ?? "Invoice #{$inv->id}", 'sub' => $inv->contact?->name ?? '', 'href' => "/finance/invoices/{$inv->id}"]; + } + + foreach (Contact::search($query)->limit(self::LIMIT)->get() as $c) { + $results[] = ['type' => 'Contact', 'label' => $c->name, 'sub' => $c->email ?? '', 'href' => "/finance/contacts"]; + } + } + + if ($user->can('inventory.view')) { + foreach (Product::search($query)->limit(self::LIMIT)->get() as $p) { + $results[] = ['type' => 'Product', 'label' => $p->name, 'sub' => $p->sku ?? '', 'href' => "/inventory/products/{$p->id}"]; + } + + foreach (PurchaseOrder::with('supplier')->where('id', 'like', "%{$query}%")->orWhereHas('supplier', fn ($q) => $q->where('name', 'like', "%{$query}%"))->limit(self::LIMIT)->get() as $po) { + $results[] = ['type' => 'Purchase Order', 'label' => "PO #{$po->id}", 'sub' => $po->supplier?->name ?? '', 'href' => "/inventory/purchase-orders/{$po->id}"]; + } + } + + if ($user->can('hr.view')) { + foreach (Employee::search($query)->limit(self::LIMIT)->get() as $emp) { + $results[] = ['type' => 'Employee', 'label' => $emp->full_name, 'sub' => $emp->position ?? '', 'href' => "/hr/employees/{$emp->id}"]; + } + } + + return response()->json(['results' => $results]); + } +} diff --git a/erp/app/Http/Controllers/SettingController.php b/erp/app/Http/Controllers/SettingController.php new file mode 100644 index 00000000000..4cbcc7d1338 --- /dev/null +++ b/erp/app/Http/Controllers/SettingController.php @@ -0,0 +1,59 @@ + '', + 'currency' => 'USD', + 'timezone' => 'UTC', + 'fiscal_year_start' => '01-01', + ]; + + public function edit(): Response + { + if (! auth()->user()->hasAnyRole(['super-admin', 'admin'])) { + abort(403); + } + + $tenantId = auth()->user()->tenant_id; + $settings = collect(self::KEYS)->mapWithKeys(fn ($key) => [ + $key => TenantSetting::getValue($tenantId, $key, self::DEFAULTS[$key]), + ])->all(); + + return Inertia::render('Settings/Index', [ + 'settings' => $settings, + 'breadcrumbs' => [['label' => 'Settings', 'href' => route('settings.edit')]], + ]); + } + + public function update(Request $request): RedirectResponse + { + if (! auth()->user()->hasAnyRole(['super-admin', 'admin'])) { + abort(403); + } + + $data = $request->validate([ + 'company_name' => ['required', 'string', 'max:255'], + 'currency' => ['required', 'string', 'size:3'], + 'timezone' => ['required', 'string', 'max:64'], + 'fiscal_year_start' => ['required', 'regex:/^\d{2}-\d{2}$/'], + ]); + + $tenantId = auth()->user()->tenant_id; + foreach ($data as $key => $value) { + TenantSetting::setValue($tenantId, $key, $value); + } + + return back()->with('success', 'Settings saved.'); + } +} diff --git a/erp/app/Http/Controllers/TwoFactorController.php b/erp/app/Http/Controllers/TwoFactorController.php new file mode 100644 index 00000000000..6f9675df09d --- /dev/null +++ b/erp/app/Http/Controllers/TwoFactorController.php @@ -0,0 +1,155 @@ +session()->has('2fa_setup_secret')) { + $secret = $google2fa->generateSecretKey(); + $request->session()->put('2fa_setup_secret', $secret); + } + + $secret = $request->session()->get('2fa_setup_secret'); + $user = $request->user(); + + $qrCodeUrl = $google2fa->getQRCodeUrl( + config('app.name'), + $user->email, + $secret + ); + + return Inertia::render('Auth/TwoFactor/Setup', [ + 'qrCodeUrl' => $qrCodeUrl, + 'secret' => $secret, + 'enabled' => (bool) $user->two_factor_enabled, + ]); + } + + /** + * Enable 2FA after verifying the TOTP code. + */ + public function enable(Request $request): RedirectResponse + { + $request->validate([ + 'code' => ['required', 'string', 'size:6'], + ]); + + $secret = $request->session()->get('2fa_setup_secret'); + + if (! $secret) { + return back()->withErrors(['code' => 'Setup session expired. Please try again.']); + } + + $google2fa = app('pragmarx.google2fa'); + + if (! $google2fa->verifyKey($secret, $request->code)) { + return back()->withErrors(['code' => 'Invalid verification code. Please try again.']); + } + + // Generate recovery codes + $recoveryCodes = collect(range(1, 8))->map(fn () => Str::random(10))->all(); + + $request->user()->update([ + 'two_factor_secret' => encrypt($secret), + 'two_factor_enabled' => true, + 'two_factor_recovery_codes' => encrypt(json_encode($recoveryCodes)), + ]); + + $request->session()->forget('2fa_setup_secret'); + $request->session()->put('2fa_verified', true); + + return redirect()->route('profile.edit')->with('success', 'Two-factor authentication enabled successfully.'); + } + + /** + * Disable 2FA after verifying the user's password. + */ + public function disable(Request $request): RedirectResponse + { + $request->validate([ + 'password' => ['required', 'current_password'], + ]); + + $request->user()->update([ + 'two_factor_secret' => null, + 'two_factor_enabled' => false, + 'two_factor_recovery_codes' => null, + ]); + + $request->session()->forget('2fa_verified'); + + return redirect()->route('profile.edit')->with('success', 'Two-factor authentication disabled.'); + } + + /** + * Show the 2FA challenge page (after login, if 2FA enabled). + */ + public function challenge(Request $request): Response|RedirectResponse + { + if (! $request->user()?->two_factor_enabled) { + return redirect()->route('dashboard'); + } + + if ($request->session()->get('2fa_verified')) { + return redirect()->intended(route('dashboard')); + } + + return Inertia::render('Auth/TwoFactor/Challenge'); + } + + /** + * Verify the TOTP code during login challenge. + */ + public function verify(Request $request): RedirectResponse + { + $request->validate([ + 'code' => ['required', 'string'], + ]); + + $user = $request->user(); + + if (! $user?->two_factor_enabled || ! $user->two_factor_secret) { + return redirect()->route('dashboard'); + } + + $secret = decrypt($user->two_factor_secret); + $google2fa = app('pragmarx.google2fa'); + $code = preg_replace('/\s/', '', $request->code); + + // Try TOTP first + if (strlen($code) === 6 && $google2fa->verifyKey($secret, $code)) { + $request->session()->put('2fa_verified', true); + return redirect()->intended(route('dashboard')); + } + + // Try recovery codes + if (strlen($code) === 10 && $user->two_factor_recovery_codes) { + $recoveryCodes = json_decode(decrypt($user->two_factor_recovery_codes), true) ?? []; + if (in_array($code, $recoveryCodes, true)) { + // Remove used recovery code + $remaining = array_values(array_filter($recoveryCodes, fn ($c) => $c !== $code)); + $user->update(['two_factor_recovery_codes' => encrypt(json_encode($remaining))]); + + $request->session()->put('2fa_verified', true); + return redirect()->intended(route('dashboard')); + } + } + + return back()->withErrors(['code' => 'Invalid code. Please try again.']); + } +} diff --git a/erp/app/Http/Controllers/UserManagementController.php b/erp/app/Http/Controllers/UserManagementController.php new file mode 100644 index 00000000000..5b30639045e --- /dev/null +++ b/erp/app/Http/Controllers/UserManagementController.php @@ -0,0 +1,116 @@ +user(); + if (! $user->hasAnyRole(['super-admin', 'admin'])) { + abort(403); + } + } + + public function index(Request $request) + { + $this->authorizeAdmin($request); + + $users = User::where('tenant_id', $request->user()->tenant_id) + ->with('roles') + ->orderBy('name') + ->get() + ->map(fn ($u) => [ + 'id' => $u->id, + 'name' => $u->name, + 'email' => $u->email, + 'is_active' => (bool) $u->is_active, + 'roles' => $u->roles->pluck('name'), + 'created_at' => $u->created_at?->toDateString(), + ]); + + $roles = Role::whereIn('name', ['admin', 'manager', 'staff'])->pluck('name'); + + return Inertia::render('Settings/Users/Index', [ + 'users' => $users, + 'roles' => $roles, + ]); + } + + public function invite(Request $request) + { + $this->authorizeAdmin($request); + + $data = $request->validate([ + 'name' => 'required|string|max:191', + 'email' => 'required|email|unique:users,email', + 'role' => 'required|in:admin,manager,staff', + ]); + + $user = User::create([ + 'tenant_id' => $request->user()->tenant_id, + 'name' => $data['name'], + 'email' => $data['email'], + 'password' => Hash::make(Str::random(16)), + 'is_active' => true, + ]); + + $user->assignRole($data['role']); + + return back()->with('success', "User {$user->name} invited successfully."); + } + + public function updateRole(Request $request, User $user) + { + $this->authorizeAdmin($request); + $this->ensureSameTenant($request, $user); + + $data = $request->validate(['role' => 'required|in:admin,manager,staff']); + + $user->syncRoles([$data['role']]); + + return back()->with('success', 'Role updated.'); + } + + public function toggleActive(Request $request, User $user) + { + $this->authorizeAdmin($request); + $this->ensureSameTenant($request, $user); + + if ($user->id === $request->user()->id) { + return back()->withErrors(['user' => 'You cannot deactivate yourself.']); + } + + $user->update(['is_active' => ! $user->is_active]); + + return back()->with('success', $user->is_active ? 'User reactivated.' : 'User deactivated.'); + } + + public function destroy(Request $request, User $user) + { + $this->authorizeAdmin($request); + $this->ensureSameTenant($request, $user); + + if ($user->id === $request->user()->id) { + return back()->withErrors(['user' => 'You cannot remove yourself.']); + } + + $user->delete(); + + return back()->with('success', 'User removed.'); + } + + private function ensureSameTenant(Request $request, User $user): void + { + if ($user->tenant_id !== $request->user()->tenant_id) { + abort(403); + } + } +} diff --git a/erp/app/Http/Controllers/WebhookController.php b/erp/app/Http/Controllers/WebhookController.php new file mode 100644 index 00000000000..9632ff419f0 --- /dev/null +++ b/erp/app/Http/Controllers/WebhookController.php @@ -0,0 +1,126 @@ +where('tenant_id', auth()->user()->tenant_id) + ->withCount('deliveries') + ->latest() + ->get(); + + return Inertia::render('Settings/Webhooks/Index', [ + 'webhooks' => $webhooks, + 'availableEvents' => self::AVAILABLE_EVENTS, + ]); + } + + public function create(): Response + { + return Inertia::render('Settings/Webhooks/Create', [ + 'availableEvents' => self::AVAILABLE_EVENTS, + ]); + } + + public function store(Request $request): RedirectResponse + { + $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'url' => ['required', 'url', 'max:2048'], + 'events' => ['nullable', 'array'], + 'events.*' => ['string', 'in:' . implode(',', self::AVAILABLE_EVENTS)], + 'secret' => ['nullable', 'string', 'max:255'], + 'is_active' => ['boolean'], + ]); + + Webhook::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'name' => $request->name, + 'url' => $request->url, + 'events' => $request->events ?? [], + 'secret' => $request->secret, + 'is_active' => $request->boolean('is_active', true), + ]); + + return redirect()->route('webhooks.index')->with('success', 'Webhook created successfully.'); + } + + public function edit(Webhook $webhook): Response + { + return Inertia::render('Settings/Webhooks/Edit', [ + 'webhook' => $webhook, + 'availableEvents' => self::AVAILABLE_EVENTS, + ]); + } + + public function update(Request $request, Webhook $webhook): RedirectResponse + { + $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'url' => ['required', 'url', 'max:2048'], + 'events' => ['nullable', 'array'], + 'events.*' => ['string', 'in:' . implode(',', self::AVAILABLE_EVENTS)], + 'secret' => ['nullable', 'string', 'max:255'], + 'is_active' => ['boolean'], + ]); + + $webhook->update([ + 'name' => $request->name, + 'url' => $request->url, + 'events' => $request->events ?? [], + 'secret' => $request->secret, + 'is_active' => $request->boolean('is_active', true), + ]); + + return redirect()->route('webhooks.index')->with('success', 'Webhook updated successfully.'); + } + + public function destroy(Webhook $webhook): RedirectResponse + { + $webhook->delete(); + return redirect()->route('webhooks.index')->with('success', 'Webhook deleted.'); + } + + public function deliveries(Webhook $webhook): Response + { + $deliveries = $webhook->deliveries() + ->latest() + ->limit(50) + ->get(); + + return Inertia::render('Settings/Webhooks/Deliveries', [ + 'webhook' => $webhook, + 'deliveries' => $deliveries, + ]); + } + + public function test(Webhook $webhook): RedirectResponse + { + WebhookService::send($webhook, 'ping', [ + 'event' => 'ping', + 'message' => 'This is a test webhook delivery.', + 'timestamp' => now()->toIso8601String(), + ]); + + return back()->with('success', 'Test ping sent to webhook.'); + } +} diff --git a/erp/app/Http/Middleware/HandleInertiaRequests.php b/erp/app/Http/Middleware/HandleInertiaRequests.php new file mode 100644 index 00000000000..46b6d582f0c --- /dev/null +++ b/erp/app/Http/Middleware/HandleInertiaRequests.php @@ -0,0 +1,67 @@ + */ + public function share(Request $request): array + { + $user = $request->user(); + + return [ + ...parent::share($request), + 'auth' => [ + 'user' => $user ? [ + 'id' => $user->id, + 'name' => $user->name, + 'email' => $user->email, + 'avatar' => $user->avatar, + 'initials' => $user->initials, + 'roles' => $user->getRoleNames(), + 'permissions' => $user->getAllPermissions()->pluck('name'), + ] : null, + 'tenant' => $request->attributes->get('tenant') instanceof Tenant + ? [ + 'id' => $request->attributes->get('tenant')->id, + 'name' => $request->attributes->get('tenant')->name, + 'slug' => $request->attributes->get('tenant')->slug, + ] + : null, + ], + 'ziggy' => fn () => [ + ...(new Ziggy)->toArray(), + 'location' => $request->url(), + ], + 'flash' => [ + 'success' => $request->session()->get('success'), + 'error' => $request->session()->get('error'), + ], + 'notifications_count' => fn () => $user + ? $user->unreadNotifications()->count() + : 0, + 'notifications' => function () use ($user) { + if (! $user) { + return []; + } + try { + return \App\Services\NotificationService::forUser($user); + } catch (\Throwable) { + return []; + } + }, + ]; + } +} diff --git a/erp/app/Http/Middleware/RequiresTwoFactor.php b/erp/app/Http/Middleware/RequiresTwoFactor.php new file mode 100644 index 00000000000..e4067609dab --- /dev/null +++ b/erp/app/Http/Middleware/RequiresTwoFactor.php @@ -0,0 +1,27 @@ +user(); + + if ( + $user + && $user->two_factor_enabled + && ! $request->session()->get('2fa_verified') + && ! $request->routeIs('2fa.*') + && ! $request->routeIs('logout') + ) { + return redirect()->route('2fa.challenge'); + } + + return $next($request); + } +} diff --git a/erp/app/Http/Middleware/SecurityHeaders.php b/erp/app/Http/Middleware/SecurityHeaders.php new file mode 100644 index 00000000000..1d51c5ad0c7 --- /dev/null +++ b/erp/app/Http/Middleware/SecurityHeaders.php @@ -0,0 +1,19 @@ +headers->set('X-Content-Type-Options', 'nosniff'); + $response->headers->set('X-Frame-Options', 'SAMEORIGIN'); + $response->headers->set('X-XSS-Protection', '1; mode=block'); + $response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin'); + return $response; + } +} diff --git a/erp/app/Http/Middleware/TenantMiddleware.php b/erp/app/Http/Middleware/TenantMiddleware.php new file mode 100644 index 00000000000..40a91237ac9 --- /dev/null +++ b/erp/app/Http/Middleware/TenantMiddleware.php @@ -0,0 +1,48 @@ +resolveTenant($request); + + if ($tenant === null) { + abort(404, 'Tenant not found.'); + } + + if (! $tenant->is_active) { + abort(403, 'Tenant is inactive.'); + } + + app()->instance('tenant', $tenant); + $request->attributes->set('tenant', $tenant); + + return $next($request); + } + + private function resolveTenant(Request $request): ?Tenant + { + // 1. Try X-Tenant header (API clients) + if ($slug = $request->header('X-Tenant')) { + return Tenant::where('slug', $slug)->first(); + } + + // 2. Try subdomain + $host = $request->getHost(); + $parts = explode('.', $host); + if (count($parts) >= 3) { + $subdomain = $parts[0]; + return Tenant::where('slug', $subdomain)->first(); + } + + // 3. Try full domain match + return Tenant::where('domain', $host)->first(); + } +} diff --git a/erp/app/Http/Policies/UserPolicy.php b/erp/app/Http/Policies/UserPolicy.php new file mode 100644 index 00000000000..4f1d3d67cf1 --- /dev/null +++ b/erp/app/Http/Policies/UserPolicy.php @@ -0,0 +1,14 @@ +can('users.view'); } + public function view(User $user, User $model): bool { return $user->can('users.view'); } + public function create(User $user): bool { return $user->can('users.create'); } + public function update(User $user, User $model): bool { return $user->can('users.update'); } + public function delete(User $user, User $model): bool { return $user->can('users.delete'); } +} diff --git a/erp/app/Http/Requests/Auth/LoginRequest.php b/erp/app/Http/Requests/Auth/LoginRequest.php new file mode 100644 index 00000000000..711e0a16902 --- /dev/null +++ b/erp/app/Http/Requests/Auth/LoginRequest.php @@ -0,0 +1,86 @@ +|string> + */ + public function rules(): array + { + return [ + 'email' => ['required', 'string', 'email'], + 'password' => ['required', 'string'], + ]; + } + + /** + * Attempt to authenticate the request's credentials. + * + * @throws ValidationException + */ + public function authenticate(): void + { + $this->ensureIsNotRateLimited(); + + if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) { + RateLimiter::hit($this->throttleKey()); + + throw ValidationException::withMessages([ + 'email' => trans('auth.failed'), + ]); + } + + RateLimiter::clear($this->throttleKey()); + } + + /** + * Ensure the login request is not rate limited. + * + * @throws ValidationException + */ + public function ensureIsNotRateLimited(): void + { + if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) { + return; + } + + event(new Lockout($this)); + + $seconds = RateLimiter::availableIn($this->throttleKey()); + + throw ValidationException::withMessages([ + 'email' => trans('auth.throttle', [ + 'seconds' => $seconds, + 'minutes' => ceil($seconds / 60), + ]), + ]); + } + + /** + * Get the rate limiting throttle key for the request. + */ + public function throttleKey(): string + { + return Str::transliterate(Str::lower($this->string('email')).'|'.$this->ip()); + } +} diff --git a/erp/app/Http/Requests/ProfileUpdateRequest.php b/erp/app/Http/Requests/ProfileUpdateRequest.php new file mode 100644 index 00000000000..e2202dd0aa6 --- /dev/null +++ b/erp/app/Http/Requests/ProfileUpdateRequest.php @@ -0,0 +1,31 @@ +|string> + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'email' => [ + 'required', + 'string', + 'lowercase', + 'email', + 'max:255', + Rule::unique(User::class)->ignore($this->user()->id), + ], + ]; + } +} diff --git a/erp/app/Imports/ContactsImport.php b/erp/app/Imports/ContactsImport.php new file mode 100644 index 00000000000..06839f39412 --- /dev/null +++ b/erp/app/Imports/ContactsImport.php @@ -0,0 +1,26 @@ + $this->tenantId, + 'name' => $row['name'] ?? null, + 'email' => $row['email'] ?? null, + 'type' => $row['type'] ?? 'customer', + ]); + } +} diff --git a/erp/app/Imports/ProductsImport.php b/erp/app/Imports/ProductsImport.php new file mode 100644 index 00000000000..ccb51f59e8e --- /dev/null +++ b/erp/app/Imports/ProductsImport.php @@ -0,0 +1,25 @@ + $this->tenantId, + 'sku' => $row['sku'] ?? null, + 'name' => $row['name'] ?? null, + ]); + } +} diff --git a/erp/app/Jobs/GeneratePayslipJob.php b/erp/app/Jobs/GeneratePayslipJob.php new file mode 100644 index 00000000000..2a050bb6bdd --- /dev/null +++ b/erp/app/Jobs/GeneratePayslipJob.php @@ -0,0 +1,23 @@ +payrollRun->id}] period: {$this->payrollRun->period_label}"); + } +} diff --git a/erp/app/Jobs/ProcessBulkImportJob.php b/erp/app/Jobs/ProcessBulkImportJob.php new file mode 100644 index 00000000000..9980af78d41 --- /dev/null +++ b/erp/app/Jobs/ProcessBulkImportJob.php @@ -0,0 +1,31 @@ +importType}, file={$this->filePath}, tenant={$this->tenantId}, user={$this->userId}"); + } +} diff --git a/erp/app/Jobs/ProcessLowStockAlertJob.php b/erp/app/Jobs/ProcessLowStockAlertJob.php new file mode 100644 index 00000000000..2028574bc3b --- /dev/null +++ b/erp/app/Jobs/ProcessLowStockAlertJob.php @@ -0,0 +1,26 @@ +product->name}] has quantity {$this->quantity}"); + } +} diff --git a/erp/app/Jobs/ProcessPayrollRun.php b/erp/app/Jobs/ProcessPayrollRun.php new file mode 100644 index 00000000000..14b2e4096d1 --- /dev/null +++ b/erp/app/Jobs/ProcessPayrollRun.php @@ -0,0 +1,62 @@ +queue = 'payroll'; + } + + public function handle(): void + { + $tenant = Tenant::find($this->tenantId); + + if (! $tenant) { + return; + } + + app()->instance('tenant', $tenant); + + $run = PayrollRun::find($this->payrollRunId); + + if (! $run) { + return; + } + + $run->update(['status' => 'processing']); + + $employees = Employee::where('tenant_id', $this->tenantId)->get(); + + foreach ($employees as $employee) { + $salary = $employee->salary_amount ?? 0; + + Payslip::create([ + 'tenant_id' => $this->tenantId, + 'payroll_run_id' => $run->id, + 'employee_id' => $employee->id, + 'gross_amount' => $salary, + 'net_amount' => $salary, + 'total_deductions' => 0, + 'tax_amount' => 0, + ]); + } + + $run->update(['status' => 'completed']); + } +} diff --git a/erp/app/Jobs/RecalculateLeadScores.php b/erp/app/Jobs/RecalculateLeadScores.php new file mode 100644 index 00000000000..832ff07b815 --- /dev/null +++ b/erp/app/Jobs/RecalculateLeadScores.php @@ -0,0 +1,44 @@ +queue = 'default'; + } + + public function handle(): void + { + $leads = CrmLead::where('tenant_id', $this->tenantId)->get(); + + foreach ($leads as $lead) { + try { + $score = LeadScoringRule::scoreForLead($lead); + + if (in_array('score', $lead->getFillable(), true)) { + $lead->update(['score' => $score]); + } + } catch (\Throwable $e) { + Log::warning('RecalculateLeadScores: Failed to score lead', [ + 'lead_id' => $lead->id, + 'tenant_id' => $this->tenantId, + 'error' => $e->getMessage(), + ]); + } + } + } +} diff --git a/erp/app/Jobs/SendEmailSequenceStep.php b/erp/app/Jobs/SendEmailSequenceStep.php new file mode 100644 index 00000000000..646a45b9a61 --- /dev/null +++ b/erp/app/Jobs/SendEmailSequenceStep.php @@ -0,0 +1,47 @@ +queue = 'email'; + } + + public function handle(): void + { + $enrollment = EmailSequenceEnrollment::with(['lead', 'sequence'])->find($this->enrollmentId); + + if (! $enrollment) { + Log::warning('SendEmailSequenceStep: Enrollment not found', [ + 'enrollment_id' => $this->enrollmentId, + 'step_id' => $this->stepId, + ]); + return; + } + + $enrollment->advance(); + + Log::info('SendEmailSequenceStep: Advanced enrollment', [ + 'enrollment_id' => $this->enrollmentId, + 'step_id' => $this->stepId, + 'lead_id' => $enrollment->lead_id, + 'current_step' => $enrollment->current_step, + ]); + } +} diff --git a/erp/app/Jobs/SendInvoiceNotificationJob.php b/erp/app/Jobs/SendInvoiceNotificationJob.php new file mode 100644 index 00000000000..57932066e08 --- /dev/null +++ b/erp/app/Jobs/SendInvoiceNotificationJob.php @@ -0,0 +1,23 @@ +invoice->id} notification queued"); + } +} diff --git a/erp/app/Jobs/SendScheduledReportJob.php b/erp/app/Jobs/SendScheduledReportJob.php new file mode 100644 index 00000000000..621eabf059b --- /dev/null +++ b/erp/app/Jobs/SendScheduledReportJob.php @@ -0,0 +1,114 @@ +schedule->report_type) { + 'financial' => $this->buildFinancialReport(), + 'inventory' => $this->buildInventoryReport(), + 'hr' => $this->buildHrReport(), + default => [], + }; + + foreach ($this->schedule->recipients as $email) { + Mail::to($email)->send(new ScheduledReportMail($this->schedule, $data)); + } + + $this->schedule->update([ + 'last_sent_at' => now(), + 'next_run_at' => $this->schedule->computeNextRunAt(), + ]); + } + + private function buildFinancialReport(): array + { + $tenantId = $this->schedule->tenant_id; + $year = now()->year; + + $invoiceSummary = Invoice::where('tenant_id', $tenantId) + ->whereYear('created_at', $year) + ->selectRaw('SUM(total) as total_invoiced, SUM(CASE WHEN status = \'paid\' THEN total ELSE 0 END) as total_paid, SUM(CASE WHEN status != \'paid\' THEN total ELSE 0 END) as total_outstanding, SUM(CASE WHEN status != \'paid\' AND due_date < datetime(\'now\') THEN total ELSE 0 END) as total_overdue') + ->first(); + + $monthlyRevenue = DB::table('invoices') + ->join('invoice_items', 'invoices.id', '=', 'invoice_items.invoice_id') + ->where('invoices.tenant_id', $tenantId) + ->where('invoices.status', 'paid') + ->whereYear('invoices.created_at', $year) + ->selectRaw("strftime('%m', invoices.created_at) as month, SUM(invoice_items.quantity * invoice_items.unit_price) as revenue") + ->groupBy('month') + ->orderBy('month') + ->get() + ->toArray(); + + return [ + 'invoice_summary' => $invoiceSummary ? $invoiceSummary->toArray() : [], + 'monthly_revenue' => $monthlyRevenue, + ]; + } + + private function buildInventoryReport(): array + { + $tenantId = $this->schedule->tenant_id; + $stockStats = Product::where('tenant_id', $tenantId) + ->selectRaw('COUNT(*) as total_products, SUM(stock_quantity * cost_price) as stock_value') + ->first(); + + $lowStockCount = Product::where('tenant_id', $tenantId) + ->whereColumn('stock_quantity', '<=', 'reorder_point') + ->where('reorder_point', '>', 0) + ->count(); + + return [ + 'total_products' => $stockStats?->total_products ?? 0, + 'stock_value' => $stockStats?->stock_value ?? 0, + 'low_stock_count' => $lowStockCount, + ]; + } + + private function buildHrReport(): array + { + $tenantId = $this->schedule->tenant_id; + + $headcount = Employee::where('tenant_id', $tenantId) + ->selectRaw('status, COUNT(*) as count') + ->groupBy('status') + ->get() + ->toArray(); + + $payrollSummary = PayrollRun::where('tenant_id', $tenantId) + ->whereYear('created_at', now()->year) + ->selectRaw('SUM(total_gross) as total_gross, SUM(total_net) as total_net, COUNT(*) as run_count') + ->first(); + + return [ + 'headcount' => $headcount, + 'payroll_summary' => $payrollSummary ? $payrollSummary->toArray() : [], + ]; + } +} diff --git a/erp/app/Listeners/CRM/CreateFinanceInvoiceFromDeal.php b/erp/app/Listeners/CRM/CreateFinanceInvoiceFromDeal.php new file mode 100644 index 00000000000..9384e384ef5 --- /dev/null +++ b/erp/app/Listeners/CRM/CreateFinanceInvoiceFromDeal.php @@ -0,0 +1,32 @@ +lead; + + $invoice = Invoice::create([ + 'tenant_id' => $lead->tenant_id, + 'number' => 'INV-CRM-' . $lead->id . '-' . uniqid(), + 'issue_date' => now()->toDateString(), + 'due_date' => now()->addDays(30)->toDateString(), + 'status' => 'draft', + 'notes' => 'Auto-created from CRM deal: ' . $lead->title, + ]); + + InvoiceItem::create([ + 'invoice_id' => $invoice->id, + 'description' => $lead->title, + 'quantity' => 1, + 'unit_price' => $lead->expected_revenue ?? 0, + 'tax_rate' => 0, + ]); + } +} diff --git a/erp/app/Listeners/HR/CreatePayrollJournalEntry.php b/erp/app/Listeners/HR/CreatePayrollJournalEntry.php new file mode 100644 index 00000000000..353ba359e5c --- /dev/null +++ b/erp/app/Listeners/HR/CreatePayrollJournalEntry.php @@ -0,0 +1,66 @@ +payrollRun; + + $entry = JournalEntry::create([ + 'tenant_id' => $payrollRun->tenant_id, + 'reference' => 'PAYROLL-' . $payrollRun->period_label, + 'description' => 'Payroll journal entry for ' . $payrollRun->period_label, + 'entry_date' => now()->toDateString(), + 'status' => 'draft', + ]); + + $entry->entry_number = $entry->generateEntryNumber(); + $entry->save(); + + // Find or create placeholder accounts for salary expense and payable + $expenseAccount = $this->findOrCreateAccount($payrollRun->tenant_id, '6100', 'Salary Expense', 'expense', 'debit'); + $payableAccount = $this->findOrCreateAccount($payrollRun->tenant_id, '2100', 'Salary Payable', 'liability', 'credit'); + + JournalEntryLine::create([ + 'journal_entry_id' => $entry->id, + 'account_id' => $expenseAccount->id, + 'description' => 'Salary Expense', + 'debit' => $payrollRun->total_gross, + 'credit' => 0, + ]); + + JournalEntryLine::create([ + 'journal_entry_id' => $entry->id, + 'account_id' => $payableAccount->id, + 'description' => 'Salary Payable', + 'debit' => 0, + 'credit' => $payrollRun->total_net, + ]); + + GeneratePayslipJob::dispatch($payrollRun); + + Mail::to('payroll@example.com')->queue(new PayrollApprovedMail($payrollRun)); + } + + private function findOrCreateAccount(int $tenantId, string $code, string $name, string $type, string $normalBalance): \App\Modules\Accounting\Models\Account + { + return \App\Modules\Accounting\Models\Account::firstOrCreate( + ['tenant_id' => $tenantId, 'code' => $code], + [ + 'name' => $name, + 'type' => $type, + 'normal_balance' => $normalBalance, + 'is_active' => true, + ] + ); + } +} diff --git a/erp/app/Listeners/Inventory/CreatePurchaseRfqFromLowStock.php b/erp/app/Listeners/Inventory/CreatePurchaseRfqFromLowStock.php new file mode 100644 index 00000000000..8bb89c7e2b9 --- /dev/null +++ b/erp/app/Listeners/Inventory/CreatePurchaseRfqFromLowStock.php @@ -0,0 +1,60 @@ +product; + $reorderRule = $event->reorderRule; + + $vendor = \App\Modules\Purchase\Models\PurchaseVendor::where('tenant_id', $product->tenant_id) + ->where('is_active', true) + ->first(); + + if (! $vendor) { + $vendor = \App\Modules\Purchase\Models\PurchaseVendor::create([ + 'tenant_id' => $product->tenant_id, + 'name' => 'Default Vendor', + 'currency' => 'USD', + 'is_active' => true, + ]); + } + + $rfq = PurchaseRfq::create([ + 'tenant_id' => $product->tenant_id, + 'rfq_number' => 'RFQ-AUTO-' . now()->format('YmdHis') . '-' . uniqid(), + 'po_vendor_id' => $vendor->id, + 'status' => 'draft', + 'currency' => 'USD', + 'notes' => 'Auto-created from low stock alert for: ' . $product->name, + ]); + + PurchaseRfqLine::create([ + 'tenant_id' => $product->tenant_id, + 'po_rfq_id' => $rfq->id, + 'product_name' => $product->name, + 'quantity' => $reorderRule->reorder_quantity, + 'unit_price' => 0, + 'subtotal' => 0, + ]); + + $reorderRule->trigger(); + + ProcessLowStockAlertJob::dispatch($product, (int) ($event->stockLevel->quantity ?? 0)); + + Mail::to('purchasing@example.com')->queue(new LowStockAlertMail( + $product->name, + (float) ($event->stockLevel->quantity ?? 0), + (float) $reorderRule->reorder_point, + )); + } +} diff --git a/erp/app/Listeners/Manufacturing/UpdateInventoryForManufacturingOrder.php b/erp/app/Listeners/Manufacturing/UpdateInventoryForManufacturingOrder.php new file mode 100644 index 00000000000..b148dd9d680 --- /dev/null +++ b/erp/app/Listeners/Manufacturing/UpdateInventoryForManufacturingOrder.php @@ -0,0 +1,28 @@ +order; + + if (! $order->warehouse_id) { + return; + } + + StockMovement::create([ + 'tenant_id' => $order->tenant_id, + 'product_id' => $order->product_id, + 'warehouse_id' => $order->warehouse_id, + 'type' => 'in', + 'quantity' => $order->qty_produced, + 'reference' => 'MO-COMPLETION-' . $order->mo_number, + 'notes' => 'Finished goods from manufacturing order ' . $order->mo_number, + ]); + } +} diff --git a/erp/app/Listeners/Purchase/CreateInventoryGoodsReceipt.php b/erp/app/Listeners/Purchase/CreateInventoryGoodsReceipt.php new file mode 100644 index 00000000000..6283288e0cb --- /dev/null +++ b/erp/app/Listeners/Purchase/CreateInventoryGoodsReceipt.php @@ -0,0 +1,37 @@ +po; + + $vendorName = $po->relationLoaded('vendor') && $po->vendor + ? $po->vendor->name + : 'Purchase Order'; + + $receipt = GoodsReceipt::create([ + 'tenant_id' => $po->tenant_id, + 'receipt_number' => 'GR-' . $po->po_number, + 'supplier_name' => $vendorName, + 'receipt_date' => now()->toDateString(), + 'status' => 'draft', + ]); + + foreach ($po->lines as $line) { + GoodsReceiptItem::create([ + 'goods_receipt_id' => $receipt->id, + 'quantity_expected' => $line->quantity, + 'quantity_received' => 0, + 'unit_cost' => $line->unit_price, + 'notes' => $line->product_name, + ]); + } + } +} diff --git a/erp/app/Listeners/Subscriptions/CreateSubscriptionFinanceInvoice.php b/erp/app/Listeners/Subscriptions/CreateSubscriptionFinanceInvoice.php new file mode 100644 index 00000000000..79e4322af79 --- /dev/null +++ b/erp/app/Listeners/Subscriptions/CreateSubscriptionFinanceInvoice.php @@ -0,0 +1,33 @@ +subscription; + $plan = $subscription->plan; + + $invoice = Invoice::create([ + 'tenant_id' => $subscription->tenant_id, + 'number' => 'INV-SUB-' . $subscription->id . '-' . now()->format('Ymd') . '-' . uniqid(), + 'issue_date' => now()->toDateString(), + 'due_date' => now()->addDays(30)->toDateString(), + 'status' => 'draft', + 'notes' => 'Auto-created from subscription renewal', + ]); + + InvoiceItem::create([ + 'invoice_id' => $invoice->id, + 'description' => ($plan->name ?? 'Subscription') . ' - ' . $subscription->current_period_start . ' to ' . $subscription->current_period_end, + 'quantity' => 1, + 'unit_price' => $plan->price ?? 0, + 'tax_rate' => 0, + ]); + } +} diff --git a/erp/app/Mail/ApprovalRequestMail.php b/erp/app/Mail/ApprovalRequestMail.php new file mode 100644 index 00000000000..e22beda7f7e --- /dev/null +++ b/erp/app/Mail/ApprovalRequestMail.php @@ -0,0 +1,29 @@ +requestTitle}"); + } + + public function content(): Content + { + return new Content(view: 'emails.approval-request'); + } +} diff --git a/erp/app/Mail/InvoiceCreatedMail.php b/erp/app/Mail/InvoiceCreatedMail.php new file mode 100644 index 00000000000..81ae08ad732 --- /dev/null +++ b/erp/app/Mail/InvoiceCreatedMail.php @@ -0,0 +1,26 @@ +invoice->number . ' Created'); + } + + public function content(): Content + { + return new Content(view: 'emails.invoice-created'); + } +} diff --git a/erp/app/Mail/LowStockAlertMail.php b/erp/app/Mail/LowStockAlertMail.php new file mode 100644 index 00000000000..39035d76eb6 --- /dev/null +++ b/erp/app/Mail/LowStockAlertMail.php @@ -0,0 +1,29 @@ +productName}"); + } + + public function content(): Content + { + return new Content(view: 'emails.low-stock-alert'); + } +} diff --git a/erp/app/Mail/PayrollApprovedMail.php b/erp/app/Mail/PayrollApprovedMail.php new file mode 100644 index 00000000000..22b44a53099 --- /dev/null +++ b/erp/app/Mail/PayrollApprovedMail.php @@ -0,0 +1,26 @@ +payrollRun->period_label}"); + } + + public function content(): Content + { + return new Content(view: 'emails.payroll-approved'); + } +} diff --git a/erp/app/Mail/ScheduledReportMail.php b/erp/app/Mail/ScheduledReportMail.php new file mode 100644 index 00000000000..c66008ddf3e --- /dev/null +++ b/erp/app/Mail/ScheduledReportMail.php @@ -0,0 +1,37 @@ +schedule->report_type}] {$this->schedule->name} Report" + ); + } + + public function content(): Content + { + return new Content( + view: 'emails.scheduled-report', + with: [ + 'schedule' => $this->schedule, + 'reportData' => $this->reportData, + ] + ); + } +} diff --git a/erp/app/Models/AlertEvent.php b/erp/app/Models/AlertEvent.php new file mode 100644 index 00000000000..287d78c9926 --- /dev/null +++ b/erp/app/Models/AlertEvent.php @@ -0,0 +1,28 @@ + 'array', + 'triggered_at' => 'datetime', + ]; + + public function rule(): BelongsTo + { + return $this->belongsTo(AlertRule::class); + } +} diff --git a/erp/app/Models/AlertRule.php b/erp/app/Models/AlertRule.php new file mode 100644 index 00000000000..942f49b358f --- /dev/null +++ b/erp/app/Models/AlertRule.php @@ -0,0 +1,42 @@ + 'array', + 'notification_targets' => 'array', + 'is_active' => 'boolean', + 'last_triggered_at' => 'datetime', + ]; + + public static array $supportedTypes = [ + 'overdue_invoice' => 'Invoice overdue by N days', + 'low_stock' => 'Product stock below threshold', + 'high_receivables' => 'Outstanding receivables above amount', + 'unresolved_ticket' => 'Helpdesk ticket unresolved for N hours', + 'payroll_pending' => 'Payroll run awaiting approval', + ]; + + public function events(): HasMany + { + return $this->hasMany(AlertEvent::class); + } +} diff --git a/erp/app/Models/DashboardWidget.php b/erp/app/Models/DashboardWidget.php new file mode 100644 index 00000000000..980863bc523 --- /dev/null +++ b/erp/app/Models/DashboardWidget.php @@ -0,0 +1,37 @@ + 'array', + 'position' => 'integer', + 'is_visible' => 'boolean', + ]; + + public static array $validTypes = ['kpi', 'chart', 'table', 'activity']; + public static array $validSizes = ['sm', 'md', 'lg', 'xl']; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/erp/app/Models/EmailTemplate.php b/erp/app/Models/EmailTemplate.php new file mode 100644 index 00000000000..74116f82b12 --- /dev/null +++ b/erp/app/Models/EmailTemplate.php @@ -0,0 +1,84 @@ + 'array', + 'is_active' => 'boolean', + ]; + + public static array $defaultTemplates = [ + 'invoice_created' => [ + 'name' => 'Invoice Created', + 'subject' => 'Invoice #{{ invoice_number }} from {{ company_name }}', + 'body_html' => '

Dear {{ customer_name }},

Please find attached your invoice #{{ invoice_number }} for {{ total }}.

Due date: {{ due_date }}

', + 'variables' => ['invoice_number', 'customer_name', 'total', 'due_date', 'company_name'], + ], + 'low_stock_alert' => [ + 'name' => 'Low Stock Alert', + 'subject' => 'Low Stock Alert: {{ product_name }}', + 'body_html' => '

Product {{ product_name }} has fallen below reorder point.

Current quantity: {{ quantity }}

Reorder point: {{ reorder_point }}

', + 'variables' => ['product_name', 'quantity', 'reorder_point'], + ], + 'payroll_approved' => [ + 'name' => 'Payroll Approved', + 'subject' => 'Payroll Run Approved – {{ period }}', + 'body_html' => '

Dear {{ employee_name }},

Your payslip for {{ period }} has been approved. Net pay: {{ net_pay }}.

', + 'variables' => ['employee_name', 'period', 'net_pay', 'gross_pay'], + ], + 'approval_request' => [ + 'name' => 'Approval Request', + 'subject' => 'Approval Required: {{ document_type }} #{{ document_ref }}', + 'body_html' => '

You have a pending approval for {{ document_type }} #{{ document_ref }}.
Submitted by {{ requester_name }}.

', + 'variables' => ['document_type', 'document_ref', 'requester_name'], + ], + ]; + + /** + * Render the subject and body with provided variables. + * + * @param array $vars + * @return array{subject: string, body_html: string} + */ + public function render(array $vars): array + { + $replace = function (string $template) use ($vars): string { + foreach ($vars as $key => $value) { + $template = str_replace("{{ {$key} }}", (string) $value, $template); + $template = str_replace("{{$key}}", (string) $value, $template); + } + return $template; + }; + + return [ + 'subject' => $replace($this->subject), + 'body_html' => $replace($this->body_html), + ]; + } + + public static function forTenant(int $tenantId, string $key): ?self + { + return static::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where('key', $key) + ->where('is_active', true) + ->first(); + } +} diff --git a/erp/app/Models/ReportSchedule.php b/erp/app/Models/ReportSchedule.php new file mode 100644 index 00000000000..ae896c89132 --- /dev/null +++ b/erp/app/Models/ReportSchedule.php @@ -0,0 +1,51 @@ + 'array', + 'filters' => 'array', + 'is_active' => 'boolean', + 'last_sent_at' => 'datetime', + 'next_run_at' => 'datetime', + ]; + + public static array $validFrequencies = ['daily', 'weekly', 'monthly']; + public static array $validReportTypes = ['financial', 'inventory', 'hr']; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function computeNextRunAt(): \Carbon\Carbon + { + return match ($this->frequency) { + 'daily' => now()->addDay()->startOfDay(), + 'weekly' => now()->addWeek()->startOfWeek(), + 'monthly' => now()->addMonth()->startOfMonth(), + default => now()->addDay()->startOfDay(), + }; + } +} diff --git a/erp/app/Models/TenantFeature.php b/erp/app/Models/TenantFeature.php new file mode 100644 index 00000000000..3516bb520b8 --- /dev/null +++ b/erp/app/Models/TenantFeature.php @@ -0,0 +1,58 @@ + 'boolean', + 'config' => 'array', + ]; + + public static array $availableFeatures = [ + 'recurring_invoices' => ['description' => 'Auto-generate invoices on a schedule'], + 'customer_portal' => ['description' => 'Customer self-service portal'], + 'webhooks' => ['description' => 'Outbound webhook integrations'], + 'two_factor_auth' => ['description' => 'Two-factor authentication for users'], + 'audit_log' => ['description' => 'Track all model changes'], + 'email_templates' => ['description' => 'Customise email templates'], + 'report_schedules' => ['description' => 'Schedule automatic report delivery'], + 'api_access' => ['description' => 'REST API access for external apps'], + 'sso' => ['description' => 'Single sign-on via SAML/OAuth'], + 'advanced_analytics' => ['description' => 'Enhanced analytics and reporting'], + ]; + + public static function isEnabled(int $tenantId, string $feature): bool + { + return Cache::remember("tenant_feature_{$tenantId}_{$feature}", 300, function () use ($tenantId, $feature) { + $record = static::where('tenant_id', $tenantId) + ->where('feature', $feature) + ->first(); + + // Default: enabled if no explicit record (opt-in by default) + return $record === null ? true : $record->is_enabled; + }); + } + + public static function toggle(int $tenantId, string $feature, bool $enabled, ?array $config = null): self + { + $instance = static::updateOrCreate( + ['tenant_id' => $tenantId, 'feature' => $feature], + ['is_enabled' => $enabled, 'config' => $config] + ); + + Cache::forget("tenant_feature_{$tenantId}_{$feature}"); + + return $instance; + } +} diff --git a/erp/app/Models/User.php b/erp/app/Models/User.php new file mode 100644 index 00000000000..90ad9cb5b6e --- /dev/null +++ b/erp/app/Models/User.php @@ -0,0 +1,74 @@ + */ + use HasApiTokens; + use HasFactory; + use HasRoles; + use HasAuditLog; + use Notifiable; + + protected $fillable = [ + 'name', + 'email', + 'password', + 'tenant_id', + 'avatar', + 'last_login_at', + 'is_active', + 'two_factor_secret', + 'two_factor_enabled', + 'two_factor_recovery_codes', + ]; + + protected $hidden = [ + 'password', + 'remember_token', + 'two_factor_secret', + 'two_factor_recovery_codes', + ]; + + protected function casts(): array + { + return [ + 'email_verified_at' => 'datetime', + 'last_login_at' => 'datetime', + 'password' => 'hashed', + 'is_active' => 'boolean', + 'two_factor_enabled' => 'boolean', + ]; + } + + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + public function assignedServiceOrders(): HasMany + { + return $this->hasMany(\App\Modules\FieldService\Models\ServiceOrder::class, 'assigned_to'); + } + + public function getInitialsAttribute(): string + { + return collect(explode(' ', $this->name)) + ->map(fn (string $word) => strtoupper($word[0])) + ->take(2) + ->implode(''); + } +} diff --git a/erp/app/Models/UserPreference.php b/erp/app/Models/UserPreference.php new file mode 100644 index 00000000000..efd89ad8fe5 --- /dev/null +++ b/erp/app/Models/UserPreference.php @@ -0,0 +1,53 @@ + 'UTC', + 'date_format' => 'Y-m-d', + 'time_format' => 'H:i', + 'language' => 'en', + 'currency_display' => 'code', // code | symbol + 'items_per_page' => '25', + 'compact_mode' => 'false', + 'sidebar_collapsed' => 'false', + 'notifications_email' => 'true', + 'notifications_push' => 'true', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public static function getForUser(int $userId, string $key): mixed + { + $pref = static::where('user_id', $userId)->where('key', $key)->first(); + return $pref ? $pref->value : (static::$defaults[$key] ?? null); + } + + public static function getAllForUser(int $userId): array + { + $stored = static::where('user_id', $userId) + ->get() + ->pluck('value', 'key') + ->toArray(); + + return array_merge(static::$defaults, $stored); + } + + public static function setForUser(int $userId, string $key, string $value): self + { + return static::updateOrCreate( + ['user_id' => $userId, 'key' => $key], + ['value' => $value] + ); + } +} diff --git a/erp/app/Models/Webhook.php b/erp/app/Models/Webhook.php new file mode 100644 index 00000000000..0363aeeae22 --- /dev/null +++ b/erp/app/Models/Webhook.php @@ -0,0 +1,53 @@ + 'array', + 'is_active' => 'boolean', + ]; + + public function deliveries(): HasMany + { + return $this->hasMany(WebhookDelivery::class); + } + + public function subscribesTo(string $event): bool + { + return in_array($event, $this->events ?? [], true); + } + + /** + * Dispatch a webhook event to all active webhooks for a tenant that subscribe to the event. + */ + public static function dispatch(string $event, array $payload, int $tenantId): void + { + $webhooks = static::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where('is_active', true) + ->get(); + + foreach ($webhooks as $webhook) { + if ($webhook->subscribesTo($event)) { + \App\Services\WebhookService::send($webhook, $event, $payload); + } + } + } +} diff --git a/erp/app/Models/WebhookDelivery.php b/erp/app/Models/WebhookDelivery.php new file mode 100644 index 00000000000..cf975535784 --- /dev/null +++ b/erp/app/Models/WebhookDelivery.php @@ -0,0 +1,31 @@ + 'array', + 'delivered_at' => 'datetime', + 'failed_at' => 'datetime', + ]; + + public function webhook(): BelongsTo + { + return $this->belongsTo(Webhook::class); + } +} diff --git a/erp/app/Modules/Accounting/Http/Controllers/AccountController.php b/erp/app/Modules/Accounting/Http/Controllers/AccountController.php new file mode 100644 index 00000000000..a5a1612aacb --- /dev/null +++ b/erp/app/Modules/Accounting/Http/Controllers/AccountController.php @@ -0,0 +1,113 @@ +where('tenant_id', auth()->user()->tenant_id) + ->with('parent') + ->orderBy('code') + ->get(); + + $grouped = $accounts->groupBy('type'); + + return Inertia::render('Accounting/Accounts/Index', [ + 'accounts' => $accounts, + 'grouped' => $grouped, + ]); + } + + public function create(): Response + { + $parentOptions = Account::withoutGlobalScopes() + ->where('tenant_id', auth()->user()->tenant_id) + ->orderBy('code') + ->get(['id', 'code', 'name', 'type']); + + return Inertia::render('Accounting/Accounts/Create', [ + 'parentOptions' => $parentOptions, + ]); + } + + public function store(Request $request): RedirectResponse + { + $data = $request->validate([ + 'code' => 'required|string|max:20', + 'name' => 'required|string|max:255', + 'type' => 'required|in:asset,liability,equity,revenue,expense', + 'sub_type' => 'nullable|string|max:100', + 'parent_id' => 'nullable|exists:chart_of_accounts,id', + 'normal_balance' => 'required|in:debit,credit', + 'description' => 'nullable|string', + 'is_active' => 'boolean', + ]); + + $data['tenant_id'] = auth()->user()->tenant_id; + + Account::create($data); + + return redirect()->route('accounting.accounts.index') + ->with('success', 'Account created successfully.'); + } + + public function edit(Account $account): Response + { + $parentOptions = Account::withoutGlobalScopes() + ->where('tenant_id', auth()->user()->tenant_id) + ->where('id', '!=', $account->id) + ->orderBy('code') + ->get(['id', 'code', 'name', 'type']); + + return Inertia::render('Accounting/Accounts/Edit', [ + 'account' => $account, + 'parentOptions' => $parentOptions, + ]); + } + + public function update(Request $request, Account $account): RedirectResponse + { + $data = $request->validate([ + 'code' => 'required|string|max:20', + 'name' => 'required|string|max:255', + 'type' => 'required|in:asset,liability,equity,revenue,expense', + 'sub_type' => 'nullable|string|max:100', + 'parent_id' => 'nullable|exists:chart_of_accounts,id', + 'normal_balance' => 'required|in:debit,credit', + 'description' => 'nullable|string', + 'is_active' => 'boolean', + ]); + + $account->update($data); + + return redirect()->route('accounting.accounts.index') + ->with('success', 'Account updated successfully.'); + } + + public function destroy(Account $account): RedirectResponse + { + if ($account->lines()->exists()) { + return back()->with('error', 'Cannot delete account with journal entry lines.'); + } + + $account->delete(); + + return back()->with('success', 'Account deleted.'); + } + + public function seedDefaults(): RedirectResponse + { + Account::seedDefaults(auth()->user()->tenant_id); + + return back()->with('success', 'Default chart of accounts seeded successfully.'); + } +} diff --git a/erp/app/Modules/Accounting/Http/Controllers/AccountingPeriodController.php b/erp/app/Modules/Accounting/Http/Controllers/AccountingPeriodController.php new file mode 100644 index 00000000000..87ea3a18326 --- /dev/null +++ b/erp/app/Modules/Accounting/Http/Controllers/AccountingPeriodController.php @@ -0,0 +1,51 @@ +where('tenant_id', auth()->user()->tenant_id) + ->orderByDesc('start_date') + ->get(); + + return Inertia::render('Accounting/Periods/Index', [ + 'periods' => $periods, + ]); + } + + public function store(Request $request): RedirectResponse + { + $data = $request->validate([ + 'name' => 'required|string|max:100', + 'start_date' => 'required|date', + 'end_date' => 'required|date|after_or_equal:start_date', + 'fiscal_year' => 'required|integer|min:2000|max:2100', + 'quarter' => 'nullable|integer|min:1|max:4', + 'status' => 'in:open,closed,locked', + ]); + + $data['tenant_id'] = auth()->user()->tenant_id; + $data['status'] = $data['status'] ?? 'open'; + + AccountingPeriod::create($data); + + return back()->with('success', 'Accounting period created.'); + } + + public function close(AccountingPeriod $period): RedirectResponse + { + $period->close(); + + return back()->with('success', 'Period closed successfully.'); + } +} diff --git a/erp/app/Modules/Accounting/Http/Controllers/AccountingReportController.php b/erp/app/Modules/Accounting/Http/Controllers/AccountingReportController.php new file mode 100644 index 00000000000..2d78d058258 --- /dev/null +++ b/erp/app/Modules/Accounting/Http/Controllers/AccountingReportController.php @@ -0,0 +1,171 @@ +user()->tenant_id; + $asOf = $request->as_of ?? now()->toDateString(); + + $accounts = Account::withoutGlobalScopes() + ->where('chart_of_accounts.tenant_id', $tenantId) + ->select('chart_of_accounts.*') + ->selectRaw('COALESCE(SUM(accounting_journal_entry_lines.debit), 0) as total_debit') + ->selectRaw('COALESCE(SUM(accounting_journal_entry_lines.credit), 0) as total_credit') + ->leftJoin('accounting_journal_entry_lines', 'accounting_journal_entry_lines.account_id', '=', 'chart_of_accounts.id') + ->leftJoin('accounting_journal_entries', function ($join) use ($asOf) { + $join->on('accounting_journal_entries.id', '=', 'accounting_journal_entry_lines.journal_entry_id') + ->where('accounting_journal_entries.status', 'posted') + ->where('accounting_journal_entries.entry_date', '<=', $asOf); + }) + ->groupBy('chart_of_accounts.id') + ->havingRaw('total_debit != 0 OR total_credit != 0') + ->orderBy('code') + ->get(); + + $totalDebits = $accounts->sum('total_debit'); + $totalCredits = $accounts->sum('total_credit'); + $isBalanced = abs($totalDebits - $totalCredits) < 0.01; + + return Inertia::render('Accounting/Reports/TrialBalance', [ + 'accounts' => $accounts, + 'totalDebits' => $totalDebits, + 'totalCredits' => $totalCredits, + 'isBalanced' => $isBalanced, + 'asOf' => $asOf, + ]); + } + + public function balanceSheet(Request $request): Response + { + $tenantId = auth()->user()->tenant_id; + $asOf = $request->as_of ?? now()->toDateString(); + + $accounts = $this->getAccountBalances($tenantId, $asOf); + + $assets = $accounts->where('type', 'asset'); + $liabilities = $accounts->where('type', 'liability'); + $equity = $accounts->where('type', 'equity'); + + $totalAssets = $assets->sum(fn ($a) => $a->total_debit - $a->total_credit); + $totalLiabilities = $liabilities->sum(fn ($a) => $a->total_credit - $a->total_debit); + $totalEquity = $equity->sum(fn ($a) => $a->total_credit - $a->total_debit); + + return Inertia::render('Accounting/Reports/BalanceSheet', [ + 'assets' => $assets->values(), + 'liabilities' => $liabilities->values(), + 'equity' => $equity->values(), + 'totalAssets' => $totalAssets, + 'totalLiabilities' => $totalLiabilities, + 'totalEquity' => $totalEquity, + 'asOf' => $asOf, + ]); + } + + public function incomeStatement(Request $request): Response + { + $tenantId = auth()->user()->tenant_id; + $startDate = $request->start_date ?? now()->startOfYear()->toDateString(); + $endDate = $request->end_date ?? now()->toDateString(); + + $accounts = Account::withoutGlobalScopes() + ->where('chart_of_accounts.tenant_id', $tenantId) + ->whereIn('chart_of_accounts.type', ['revenue', 'expense']) + ->select('chart_of_accounts.*') + ->selectRaw('COALESCE(SUM(accounting_journal_entry_lines.debit), 0) as total_debit') + ->selectRaw('COALESCE(SUM(accounting_journal_entry_lines.credit), 0) as total_credit') + ->leftJoin('accounting_journal_entry_lines', 'accounting_journal_entry_lines.account_id', '=', 'chart_of_accounts.id') + ->leftJoin('accounting_journal_entries', function ($join) use ($startDate, $endDate) { + $join->on('accounting_journal_entries.id', '=', 'accounting_journal_entry_lines.journal_entry_id') + ->where('accounting_journal_entries.status', 'posted') + ->whereBetween('accounting_journal_entries.entry_date', [$startDate, $endDate]); + }) + ->groupBy('chart_of_accounts.id') + ->orderBy('code') + ->get(); + + $revenue = $accounts->where('type', 'revenue'); + $expenses = $accounts->where('type', 'expense'); + + $totalRevenue = $revenue->sum(fn ($a) => $a->total_credit - $a->total_debit); + $totalExpenses = $expenses->sum(fn ($a) => $a->total_debit - $a->total_credit); + $netIncome = $totalRevenue - $totalExpenses; + + return Inertia::render('Accounting/Reports/IncomeStatement', [ + 'revenue' => $revenue->values(), + 'expenses' => $expenses->values(), + 'totalRevenue' => $totalRevenue, + 'totalExpenses' => $totalExpenses, + 'netIncome' => $netIncome, + 'startDate' => $startDate, + 'endDate' => $endDate, + ]); + } + + public function generalLedger(Request $request, Account $account): Response + { + $tenantId = auth()->user()->tenant_id; + + $lines = JournalEntryLine::with('journalEntry') + ->where('accounting_journal_entry_lines.account_id', $account->id) + ->whereHas('journalEntry', fn ($q) => $q->where('status', 'posted') + ->where('tenant_id', $tenantId)) + ->join('accounting_journal_entries', 'accounting_journal_entries.id', '=', 'accounting_journal_entry_lines.journal_entry_id') + ->orderBy('accounting_journal_entries.entry_date') + ->orderBy('accounting_journal_entries.id') + ->select('accounting_journal_entry_lines.*') + ->get(); + + // Compute running balance + $runningBalance = 0.0; + $linesWithBalance = $lines->map(function ($line) use (&$runningBalance, $account) { + if ($account->isDebitNormal()) { + $runningBalance += $line->debit - $line->credit; + } else { + $runningBalance += $line->credit - $line->debit; + } + return array_merge($line->toArray(), ['running_balance' => $runningBalance]); + }); + + $allAccounts = Account::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where('is_active', true) + ->orderBy('code') + ->get(['id', 'code', 'name']); + + return Inertia::render('Accounting/Reports/GeneralLedger', [ + 'account' => $account, + 'lines' => $linesWithBalance, + 'allAccounts' => $allAccounts, + ]); + } + + private function getAccountBalances(int $tenantId, string $asOf) + { + return Account::withoutGlobalScopes() + ->where('chart_of_accounts.tenant_id', $tenantId) + ->select('chart_of_accounts.*') + ->selectRaw('COALESCE(SUM(accounting_journal_entry_lines.debit), 0) as total_debit') + ->selectRaw('COALESCE(SUM(accounting_journal_entry_lines.credit), 0) as total_credit') + ->leftJoin('accounting_journal_entry_lines', 'accounting_journal_entry_lines.account_id', '=', 'chart_of_accounts.id') + ->leftJoin('accounting_journal_entries', function ($join) use ($asOf) { + $join->on('accounting_journal_entries.id', '=', 'accounting_journal_entry_lines.journal_entry_id') + ->where('accounting_journal_entries.status', 'posted') + ->where('accounting_journal_entries.entry_date', '<=', $asOf); + }) + ->groupBy('chart_of_accounts.id') + ->orderBy('code') + ->get(); + } +} diff --git a/erp/app/Modules/Accounting/Http/Controllers/AutoPostingRuleController.php b/erp/app/Modules/Accounting/Http/Controllers/AutoPostingRuleController.php new file mode 100644 index 00000000000..c23ef0f1e68 --- /dev/null +++ b/erp/app/Modules/Accounting/Http/Controllers/AutoPostingRuleController.php @@ -0,0 +1,53 @@ +autoPostingRules()->with(['debitAccount', 'creditAccount'])->get(); + $accounts = Account::orderBy('name')->get(['id', 'name', 'code', 'type']); + + return Inertia::render('Accounting/AutoPostingRules/Index', [ + 'bankAccount' => $bankAccount, + 'rules' => $rules, + 'accounts' => $accounts, + ]); + } + + public function store(Request $request, BankAccount $bankAccount): RedirectResponse + { + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'match_keyword' => 'nullable|string|max:255', + 'match_type' => 'required|in:description,reference,amount', + 'debit_account_id' => 'required|exists:chart_of_accounts,id', + 'credit_account_id' => 'required|exists:chart_of_accounts,id', + ]); + + AutoPostingRule::create([ + ...$validated, + 'tenant_id' => auth()->user()->tenant_id, + 'bank_account_id' => $bankAccount->id, + ]); + + return back()->with('success', 'Rule created.'); + } + + public function destroy(BankAccount $bankAccount, AutoPostingRule $rule): RedirectResponse + { + $rule->delete(); + + return back()->with('success', 'Rule deleted.'); + } +} diff --git a/erp/app/Modules/Accounting/Http/Controllers/BankAccountController.php b/erp/app/Modules/Accounting/Http/Controllers/BankAccountController.php new file mode 100644 index 00000000000..a6c2889e834 --- /dev/null +++ b/erp/app/Modules/Accounting/Http/Controllers/BankAccountController.php @@ -0,0 +1,61 @@ +orderBy('name')->get(); + + return Inertia::render('Accounting/BankAccounts/Index', [ + 'bankAccounts' => $accounts, + ]); + } + + public function store(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'bank_name' => 'required|string|max:255', + 'account_number' => 'required|string|max:255', + 'currency' => 'required|string|size:3', + 'account_id' => 'nullable|exists:chart_of_accounts,id', + ]); + + BankAccount::create([ + ...$validated, + 'tenant_id' => auth()->user()->tenant_id, + ]); + + return back()->with('success', 'Bank account created.'); + } + + public function update(Request $request, BankAccount $bankAccount): RedirectResponse + { + $validated = $request->validate([ + 'name' => 'sometimes|string|max:255', + 'bank_name' => 'sometimes|string|max:255', + 'is_active' => 'sometimes|boolean', + 'account_id' => 'nullable|exists:chart_of_accounts,id', + ]); + + $bankAccount->update($validated); + + return back()->with('success', 'Bank account updated.'); + } + + public function destroy(BankAccount $bankAccount): RedirectResponse + { + $bankAccount->delete(); + + return back()->with('success', 'Bank account deleted.'); + } +} diff --git a/erp/app/Modules/Accounting/Http/Controllers/BankReconciliationController.php b/erp/app/Modules/Accounting/Http/Controllers/BankReconciliationController.php new file mode 100644 index 00000000000..ebf5c704030 --- /dev/null +++ b/erp/app/Modules/Accounting/Http/Controllers/BankReconciliationController.php @@ -0,0 +1,90 @@ +unreconciledTransactions() + ->orderBy('transaction_date') + ->get(); + + return Inertia::render('Accounting/Reconciliation/Index', [ + 'bankAccount' => $bankAccount, + 'transactions' => $unreconciled, + 'reconciledBalance' => $bankAccount->reconciledBalance(), + ]); + } + + public function transactions(Request $request, BankAccount $bankAccount): Response + { + $transactions = $bankAccount->transactions() + ->when($request->status, fn ($q) => $q->where('status', $request->status)) + ->orderByDesc('transaction_date') + ->paginate(50) + ->withQueryString(); + + return Inertia::render('Accounting/BankTransactions/Index', [ + 'bankAccount' => $bankAccount, + 'transactions' => $transactions, + 'filters' => $request->only(['status']), + ]); + } + + public function importTransaction(Request $request, BankAccount $bankAccount): RedirectResponse + { + $validated = $request->validate([ + 'transaction_date' => 'required|date', + 'description' => 'nullable|string', + 'reference' => 'nullable|string', + 'type' => 'required|in:debit,credit', + 'amount' => 'required|numeric|min:0.01', + ]); + + $txn = BankTransaction::create([ + ...$validated, + 'tenant_id' => auth()->user()->tenant_id, + 'bank_account_id' => $bankAccount->id, + 'status' => 'unreconciled', + ]); + + // Try auto-posting rules + $rules = AutoPostingRule::where('bank_account_id', $bankAccount->id) + ->where('is_active', true) + ->get(); + + foreach ($rules as $rule) { + if ($rule->matches($txn)) { + $txn->reconcile(); + break; + } + } + + return back()->with('success', 'Transaction imported.'); + } + + public function reconcile(BankAccount $bankAccount, BankTransaction $transaction): JsonResponse + { + $transaction->reconcile(); + + return response()->json(['ok' => true, 'reconciled_balance' => $bankAccount->fresh()->reconciledBalance()]); + } + + public function unreconcile(BankAccount $bankAccount, BankTransaction $transaction): JsonResponse + { + $transaction->unreconcile(); + + return response()->json(['ok' => true]); + } +} diff --git a/erp/app/Modules/Accounting/Http/Controllers/JournalEntryController.php b/erp/app/Modules/Accounting/Http/Controllers/JournalEntryController.php new file mode 100644 index 00000000000..01b90d476bc --- /dev/null +++ b/erp/app/Modules/Accounting/Http/Controllers/JournalEntryController.php @@ -0,0 +1,146 @@ +where('accounting_journal_entries.tenant_id', auth()->user()->tenant_id) + ->withCount('lines') + ->when($request->status, fn ($q) => $q->where('status', $request->status)) + ->when($request->period_id, fn ($q) => $q->where('period_id', $request->period_id)) + ->latest('entry_date') + ->paginate(25) + ->withQueryString(); + + return Inertia::render('Accounting/JournalEntries/Index', [ + 'entries' => $entries, + 'filters' => $request->only(['status', 'period_id']), + ]); + } + + public function create(): Response + { + $accounts = Account::withoutGlobalScopes() + ->where('tenant_id', auth()->user()->tenant_id) + ->where('is_active', true) + ->orderBy('code') + ->get(['id', 'code', 'name', 'type']); + + $periods = AccountingPeriod::withoutGlobalScopes() + ->where('tenant_id', auth()->user()->tenant_id) + ->where('status', 'open') + ->orderByDesc('start_date') + ->get(['id', 'name', 'start_date', 'end_date']); + + return Inertia::render('Accounting/JournalEntries/Create', [ + 'accounts' => $accounts, + 'periods' => $periods, + ]); + } + + public function store(Request $request): RedirectResponse + { + $data = $request->validate([ + 'reference' => 'nullable|string|max:100', + 'description' => 'nullable|string', + 'entry_date' => 'required|date', + 'period_id' => 'nullable|exists:accounting_periods,id', + 'is_adjusting' => 'boolean', + 'lines' => 'required|array|min:2', + 'lines.*.account_id' => 'required|exists:chart_of_accounts,id', + 'lines.*.description' => 'nullable|string', + 'lines.*.debit' => 'required|numeric|min:0', + 'lines.*.credit' => 'required|numeric|min:0', + ]); + + $entry = DB::transaction(function () use ($data) { + $entry = JournalEntry::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'reference' => $data['reference'] ?? null, + 'description' => $data['description'] ?? null, + 'entry_date' => $data['entry_date'], + 'period_id' => $data['period_id'] ?? null, + 'is_adjusting' => $data['is_adjusting'] ?? false, + 'created_by' => auth()->id(), + 'status' => 'draft', + ]); + + $entry->entry_number = $entry->generateEntryNumber(); + $entry->save(); + + foreach ($data['lines'] as $line) { + JournalEntryLine::create([ + 'journal_entry_id' => $entry->id, + 'account_id' => $line['account_id'], + 'description' => $line['description'] ?? null, + 'debit' => $line['debit'], + 'credit' => $line['credit'], + ]); + } + + return $entry; + }); + + return redirect()->route('accounting.journal-entries.show', $entry) + ->with('success', 'Journal entry created.'); + } + + public function show(JournalEntry $journalEntry): Response + { + $journalEntry->load('lines.account', 'period', 'creator', 'poster'); + + return Inertia::render('Accounting/JournalEntries/Show', [ + 'entry' => $journalEntry, + ]); + } + + public function post(JournalEntry $journalEntry): RedirectResponse + { + try { + $journalEntry->post(); + } catch (\DomainException $e) { + return back()->with('error', $e->getMessage()); + } + + return back()->with('success', 'Journal entry posted successfully.'); + } + + public function reverse(JournalEntry $journalEntry): RedirectResponse + { + try { + $newEntry = $journalEntry->reverse(); + } catch (\DomainException $e) { + return back()->with('error', $e->getMessage()); + } + + return redirect()->route('accounting.journal-entries.show', $newEntry) + ->with('success', 'Journal entry reversed successfully.'); + } + + public function destroy(JournalEntry $journalEntry): RedirectResponse + { + if ($journalEntry->status !== 'draft') { + return back()->with('error', 'Only draft journal entries can be deleted.'); + } + + $journalEntry->lines()->delete(); + $journalEntry->delete(); + + return redirect()->route('accounting.journal-entries.index') + ->with('success', 'Journal entry deleted.'); + } +} diff --git a/erp/app/Modules/Accounting/Models/Account.php b/erp/app/Modules/Accounting/Models/Account.php new file mode 100644 index 00000000000..5e2356b325e --- /dev/null +++ b/erp/app/Modules/Accounting/Models/Account.php @@ -0,0 +1,126 @@ + 'boolean', + ]; + + // ─── Relations ─────────────────────────────────────────────────────────── + + public function parent(): BelongsTo + { + return $this->belongsTo(Account::class, 'parent_id'); + } + + public function children(): HasMany + { + return $this->hasMany(Account::class, 'parent_id'); + } + + public function lines(): HasMany + { + return $this->hasMany(JournalEntryLine::class, 'account_id'); + } + + public function balances(): HasMany + { + return $this->hasMany(AccountBalance::class, 'account_id'); + } + + // ─── Business Logic ─────────────────────────────────────────────────────── + + public function isDebitNormal(): bool + { + return $this->normal_balance === 'debit'; + } + + public function getBalance(?int $periodId = null): float + { + $query = $this->lines() + ->whereHas('journalEntry', fn ($q) => $q->where('status', 'posted')); + + if ($periodId !== null) { + $query->whereHas('journalEntry', fn ($q) => $q->where('period_id', $periodId)); + } + + $debits = (float) $query->sum('debit'); + $credits = (float) $query->sum('credit'); + + return $this->isDebitNormal() + ? $debits - $credits + : $credits - $debits; + } + + // ─── Default Chart of Accounts ───────────────────────────────────────────── + + public static function defaultChart(int $tenantId): array + { + return [ + // Assets + ['tenant_id' => $tenantId, 'code' => '1000', 'name' => 'Cash', 'type' => 'asset', 'sub_type' => 'cash', 'normal_balance' => 'debit'], + ['tenant_id' => $tenantId, 'code' => '1100', 'name' => 'Accounts Receivable', 'type' => 'asset', 'sub_type' => 'accounts_receivable', 'normal_balance' => 'debit'], + ['tenant_id' => $tenantId, 'code' => '1200', 'name' => 'Inventory', 'type' => 'asset', 'sub_type' => 'inventory', 'normal_balance' => 'debit'], + ['tenant_id' => $tenantId, 'code' => '1500', 'name' => 'Fixed Assets', 'type' => 'asset', 'sub_type' => 'fixed_asset', 'normal_balance' => 'debit'], + ['tenant_id' => $tenantId, 'code' => '1600', 'name' => 'Accumulated Depreciation', 'type' => 'asset', 'sub_type' => 'accumulated_depreciation', 'normal_balance' => 'credit'], + // Liabilities + ['tenant_id' => $tenantId, 'code' => '2000', 'name' => 'Accounts Payable', 'type' => 'liability', 'sub_type' => 'accounts_payable', 'normal_balance' => 'credit'], + ['tenant_id' => $tenantId, 'code' => '2100', 'name' => 'Accrued Liabilities', 'type' => 'liability', 'sub_type' => 'accrued_liabilities', 'normal_balance' => 'credit'], + ['tenant_id' => $tenantId, 'code' => '2200', 'name' => 'Sales Tax Payable', 'type' => 'liability', 'sub_type' => 'tax_payable', 'normal_balance' => 'credit'], + ['tenant_id' => $tenantId, 'code' => '2300', 'name' => 'Short-term Loans', 'type' => 'liability', 'sub_type' => 'short_term_debt', 'normal_balance' => 'credit'], + // Equity + ['tenant_id' => $tenantId, 'code' => '3000', 'name' => "Owner's Equity", 'type' => 'equity', 'sub_type' => 'owners_equity', 'normal_balance' => 'credit'], + ['tenant_id' => $tenantId, 'code' => '3100', 'name' => 'Retained Earnings', 'type' => 'equity', 'sub_type' => 'retained_earnings', 'normal_balance' => 'credit'], + ['tenant_id' => $tenantId, 'code' => '3200', 'name' => 'Common Stock', 'type' => 'equity', 'sub_type' => 'common_stock', 'normal_balance' => 'credit'], + // Revenue + ['tenant_id' => $tenantId, 'code' => '4000', 'name' => 'Sales Revenue', 'type' => 'revenue', 'sub_type' => 'sales', 'normal_balance' => 'credit'], + ['tenant_id' => $tenantId, 'code' => '4100', 'name' => 'Service Revenue', 'type' => 'revenue', 'sub_type' => 'service', 'normal_balance' => 'credit'], + ['tenant_id' => $tenantId, 'code' => '4200', 'name' => 'Other Revenue', 'type' => 'revenue', 'sub_type' => 'other_revenue', 'normal_balance' => 'credit'], + // Expenses + ['tenant_id' => $tenantId, 'code' => '5000', 'name' => 'Cost of Goods Sold', 'type' => 'expense', 'sub_type' => 'cogs', 'normal_balance' => 'debit'], + ['tenant_id' => $tenantId, 'code' => '5100', 'name' => 'Salaries Expense', 'type' => 'expense', 'sub_type' => 'salaries', 'normal_balance' => 'debit'], + ['tenant_id' => $tenantId, 'code' => '5200', 'name' => 'Rent Expense', 'type' => 'expense', 'sub_type' => 'rent', 'normal_balance' => 'debit'], + ['tenant_id' => $tenantId, 'code' => '5300', 'name' => 'Utilities Expense', 'type' => 'expense', 'sub_type' => 'utilities', 'normal_balance' => 'debit'], + ['tenant_id' => $tenantId, 'code' => '5400', 'name' => 'Marketing Expense', 'type' => 'expense', 'sub_type' => 'marketing', 'normal_balance' => 'debit'], + ['tenant_id' => $tenantId, 'code' => '5500', 'name' => 'Depreciation Expense', 'type' => 'expense', 'sub_type' => 'depreciation', 'normal_balance' => 'debit'], + ['tenant_id' => $tenantId, 'code' => '5900', 'name' => 'Other Expenses', 'type' => 'expense', 'sub_type' => 'other_expense', 'normal_balance' => 'debit'], + ]; + } + + public static function seedDefaults(int $tenantId): void + { + $existing = static::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->pluck('code') + ->toArray(); + + foreach (static::defaultChart($tenantId) as $account) { + if (! in_array($account['code'], $existing, true)) { + static::create($account); + } + } + } + + // ─── Scopes ────────────────────────────────────────────────────────────── + + public function scopeActive($query) + { + return $query->where('is_active', true); + } +} diff --git a/erp/app/Modules/Accounting/Models/AccountBalance.php b/erp/app/Modules/Accounting/Models/AccountBalance.php new file mode 100644 index 00000000000..bf027f0630b --- /dev/null +++ b/erp/app/Modules/Accounting/Models/AccountBalance.php @@ -0,0 +1,38 @@ + 'float', + 'debit_total' => 'float', + 'credit_total' => 'float', + 'closing_balance' => 'float', + ]; + + // ─── Relations ─────────────────────────────────────────────────────────── + + public function account(): BelongsTo + { + return $this->belongsTo(Account::class, 'account_id'); + } + + public function period(): BelongsTo + { + return $this->belongsTo(AccountingPeriod::class, 'period_id'); + } +} diff --git a/erp/app/Modules/Accounting/Models/AccountingPeriod.php b/erp/app/Modules/Accounting/Models/AccountingPeriod.php new file mode 100644 index 00000000000..a9b923f2eb9 --- /dev/null +++ b/erp/app/Modules/Accounting/Models/AccountingPeriod.php @@ -0,0 +1,48 @@ + 'date', + 'end_date' => 'date', + ]; + + // ─── Relations ─────────────────────────────────────────────────────────── + + public function journalEntries(): HasMany + { + return $this->hasMany(JournalEntry::class, 'period_id'); + } + + public function balances(): HasMany + { + return $this->hasMany(AccountBalance::class, 'period_id'); + } + + // ─── Business Logic ─────────────────────────────────────────────────────── + + public function isClosed(): bool + { + return $this->status === 'closed' || $this->status === 'locked'; + } + + public function close(): void + { + $this->status = 'closed'; + $this->save(); + } +} diff --git a/erp/app/Modules/Accounting/Models/AutoPostingRule.php b/erp/app/Modules/Accounting/Models/AutoPostingRule.php new file mode 100644 index 00000000000..165458ee386 --- /dev/null +++ b/erp/app/Modules/Accounting/Models/AutoPostingRule.php @@ -0,0 +1,63 @@ + true, + ]; + + protected $casts = [ + 'is_active' => 'boolean', + ]; + + public function bankAccount(): BelongsTo + { + return $this->belongsTo(BankAccount::class, 'bank_account_id'); + } + + public function debitAccount(): BelongsTo + { + return $this->belongsTo(Account::class, 'debit_account_id'); + } + + public function creditAccount(): BelongsTo + { + return $this->belongsTo(Account::class, 'credit_account_id'); + } + + public function matches(BankTransaction $transaction): bool + { + if (!$this->is_active || !$this->match_keyword) { + return false; + } + + $keyword = strtolower($this->match_keyword); + $value = match ($this->match_type) { + 'description' => strtolower($transaction->description ?? ''), + 'reference' => strtolower($transaction->reference ?? ''), + 'amount' => (string) $transaction->amount, + default => '', + }; + + return str_contains($value, $keyword); + } +} diff --git a/erp/app/Modules/Accounting/Models/BankAccount.php b/erp/app/Modules/Accounting/Models/BankAccount.php new file mode 100644 index 00000000000..f173cd55aea --- /dev/null +++ b/erp/app/Modules/Accounting/Models/BankAccount.php @@ -0,0 +1,59 @@ + 'float', + 'is_active' => 'boolean', + 'last_reconciled_at' => 'date', + ]; + + public function account(): BelongsTo + { + return $this->belongsTo(Account::class, 'account_id'); + } + + public function transactions(): HasMany + { + return $this->hasMany(BankTransaction::class, 'bank_account_id'); + } + + public function autoPostingRules(): HasMany + { + return $this->hasMany(AutoPostingRule::class, 'bank_account_id'); + } + + public function unreconciledTransactions(): HasMany + { + return $this->transactions()->where('status', 'unreconciled'); + } + + public function reconciledBalance(): float + { + $debits = $this->transactions()->where('status', 'reconciled')->where('type', 'debit')->sum('amount'); + $credits = $this->transactions()->where('status', 'reconciled')->where('type', 'credit')->sum('amount'); + + return (float) ($credits - $debits); + } +} diff --git a/erp/app/Modules/Accounting/Models/BankTransaction.php b/erp/app/Modules/Accounting/Models/BankTransaction.php new file mode 100644 index 00000000000..e7f80f9dc7f --- /dev/null +++ b/erp/app/Modules/Accounting/Models/BankTransaction.php @@ -0,0 +1,58 @@ + 'date', + 'reconciled_at' => 'datetime', + 'amount' => 'float', + ]; + + public function bankAccount(): BelongsTo + { + return $this->belongsTo(BankAccount::class, 'bank_account_id'); + } + + public function journalEntry(): BelongsTo + { + return $this->belongsTo(JournalEntry::class, 'journal_entry_id'); + } + + public function reconcile(): void + { + $this->status = 'reconciled'; + $this->reconciled_at = now(); + $this->save(); + + $this->bankAccount->last_reconciled_at = now()->toDateString(); + $this->bankAccount->save(); + } + + public function unreconcile(): void + { + $this->status = 'unreconciled'; + $this->reconciled_at = null; + $this->save(); + } +} diff --git a/erp/app/Modules/Accounting/Models/JournalEntry.php b/erp/app/Modules/Accounting/Models/JournalEntry.php new file mode 100644 index 00000000000..665058fa33c --- /dev/null +++ b/erp/app/Modules/Accounting/Models/JournalEntry.php @@ -0,0 +1,140 @@ + 'date', + 'posted_at' => 'datetime', + 'is_adjusting' => 'boolean', + ]; + + // ─── Relations ─────────────────────────────────────────────────────────── + + public function lines(): HasMany + { + return $this->hasMany(JournalEntryLine::class, 'journal_entry_id'); + } + + public function period(): BelongsTo + { + return $this->belongsTo(AccountingPeriod::class, 'period_id'); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function poster(): BelongsTo + { + return $this->belongsTo(User::class, 'posted_by'); + } + + public function reversedBy(): BelongsTo + { + return $this->belongsTo(JournalEntry::class, 'reversed_by'); + } + + // ─── Business Logic ─────────────────────────────────────────────────────── + + public function generateEntryNumber(): string + { + return 'JE-' . date('Y') . '-' . str_pad((string) $this->id, 5, '0', STR_PAD_LEFT); + } + + public function isBalanced(): bool + { + return abs($this->lines->sum('debit') - $this->lines->sum('credit')) < 0.01; + } + + public function totalDebits(): float + { + return (float) $this->lines->sum('debit'); + } + + public function totalCredits(): float + { + return (float) $this->lines->sum('credit'); + } + + public function post(): void + { + if ($this->status === 'posted') { + throw new \DomainException('Journal entry is already posted.'); + } + + $this->load('lines'); + + if (! $this->isBalanced()) { + throw new \DomainException( + sprintf( + 'Journal entry is not balanced (debits %.2f ≠ credits %.2f).', + $this->totalDebits(), + $this->totalCredits() + ) + ); + } + + $this->status = 'posted'; + $this->posted_at = now(); + $this->posted_by = auth()->id(); + $this->save(); + } + + public function reverse(string $description = ''): self + { + $this->load('lines'); + + $newEntry = static::create([ + 'tenant_id' => $this->tenant_id, + 'reference' => $this->reference, + 'description' => $description ?: 'Reversal of ' . ($this->entry_number ?? $this->id), + 'entry_date' => now()->toDateString(), + 'period_id' => $this->period_id, + 'is_adjusting' => $this->is_adjusting, + 'created_by' => auth()->id(), + 'status' => 'draft', + ]); + + $newEntry->entry_number = $newEntry->generateEntryNumber(); + $newEntry->save(); + + foreach ($this->lines as $line) { + JournalEntryLine::create([ + 'journal_entry_id' => $newEntry->id, + 'account_id' => $line->account_id, + 'description' => $line->description, + 'debit' => $line->credit, + 'credit' => $line->debit, + ]); + } + + $newEntry->load('lines'); + $newEntry->post(); + + // Mark original as reversed + $this->status = 'reversed'; + $this->reversed_by = $newEntry->id; + $this->save(); + + return $newEntry; + } +} diff --git a/erp/app/Modules/Accounting/Models/JournalEntryLine.php b/erp/app/Modules/Accounting/Models/JournalEntryLine.php new file mode 100644 index 00000000000..151ea775d74 --- /dev/null +++ b/erp/app/Modules/Accounting/Models/JournalEntryLine.php @@ -0,0 +1,32 @@ + 'float', + 'credit' => 'float', + ]; + + // ─── Relations ─────────────────────────────────────────────────────────── + + public function journalEntry(): BelongsTo + { + return $this->belongsTo(JournalEntry::class, 'journal_entry_id'); + } + + public function account(): BelongsTo + { + return $this->belongsTo(Account::class, 'account_id'); + } +} diff --git a/erp/app/Modules/Accounting/Policies/AccountingPolicy.php b/erp/app/Modules/Accounting/Policies/AccountingPolicy.php new file mode 100644 index 00000000000..da6febddf35 --- /dev/null +++ b/erp/app/Modules/Accounting/Policies/AccountingPolicy.php @@ -0,0 +1,33 @@ +can('finance.create'); + } + + public function update(User $user): bool + { + return $user->can('finance.create'); + } + + public function delete(User $user): bool + { + return $user->can('finance.create'); + } +} diff --git a/erp/app/Modules/Accounting/Providers/AccountingServiceProvider.php b/erp/app/Modules/Accounting/Providers/AccountingServiceProvider.php new file mode 100644 index 00000000000..efb59b75d34 --- /dev/null +++ b/erp/app/Modules/Accounting/Providers/AccountingServiceProvider.php @@ -0,0 +1,26 @@ +loadRoutesFrom(__DIR__ . '/../routes/accounting.php'); + + Gate::policy(Account::class, AccountingPolicy::class); + Gate::policy(AccountingPeriod::class, AccountingPolicy::class); + Gate::policy(JournalEntry::class, AccountingPolicy::class); + Gate::policy(AccountBalance::class, AccountingPolicy::class); + } +} diff --git a/erp/app/Modules/Accounting/routes/accounting.php b/erp/app/Modules/Accounting/routes/accounting.php new file mode 100644 index 00000000000..6f9d74c21df --- /dev/null +++ b/erp/app/Modules/Accounting/routes/accounting.php @@ -0,0 +1,46 @@ +prefix('accounting')->name('accounting.')->group(function () { + // Reports + Route::get('reports/trial-balance', [AccountingReportController::class, 'trialBalance'])->name('reports.trial-balance'); + Route::get('reports/balance-sheet', [AccountingReportController::class, 'balanceSheet'])->name('reports.balance-sheet'); + Route::get('reports/income-statement', [AccountingReportController::class, 'incomeStatement'])->name('reports.income-statement'); + Route::get('reports/general-ledger/{account}', [AccountingReportController::class, 'generalLedger'])->name('reports.general-ledger'); + + // Account actions BEFORE resource + Route::post('accounts/seed-defaults', [AccountController::class, 'seedDefaults'])->name('accounts.seed-defaults'); + Route::resource('accounts', AccountController::class)->except(['show']); + + // Journal entry actions BEFORE resource + Route::post('journal-entries/{journalEntry}/post', [JournalEntryController::class, 'post'])->name('journal-entries.post'); + Route::post('journal-entries/{journalEntry}/reverse', [JournalEntryController::class, 'reverse'])->name('journal-entries.reverse'); + Route::resource('journal-entries', JournalEntryController::class)->except(['edit', 'update']); + + // Periods + Route::post('periods/{period}/close', [AccountingPeriodController::class, 'close'])->name('periods.close'); + Route::resource('periods', AccountingPeriodController::class)->except(['show', 'edit', 'update']); + + // Bank Accounts + Route::resource('bank-accounts', BankAccountController::class)->except(['show', 'create', 'edit']); + + // Reconciliation + Route::get('bank-accounts/{bankAccount}/reconcile', [BankReconciliationController::class, 'index'])->name('bank-accounts.reconcile'); + Route::get('bank-accounts/{bankAccount}/transactions', [BankReconciliationController::class, 'transactions'])->name('bank-accounts.transactions'); + Route::post('bank-accounts/{bankAccount}/transactions', [BankReconciliationController::class, 'importTransaction'])->name('bank-accounts.transactions.import'); + Route::post('bank-accounts/{bankAccount}/transactions/{transaction}/reconcile', [BankReconciliationController::class, 'reconcile'])->name('bank-accounts.transactions.reconcile'); + Route::post('bank-accounts/{bankAccount}/transactions/{transaction}/unreconcile', [BankReconciliationController::class, 'unreconcile'])->name('bank-accounts.transactions.unreconcile'); + + // Auto-posting Rules + Route::get('bank-accounts/{bankAccount}/rules', [AutoPostingRuleController::class, 'index'])->name('bank-accounts.rules.index'); + Route::post('bank-accounts/{bankAccount}/rules', [AutoPostingRuleController::class, 'store'])->name('bank-accounts.rules.store'); + Route::delete('bank-accounts/{bankAccount}/rules/{rule}', [AutoPostingRuleController::class, 'destroy'])->name('bank-accounts.rules.destroy'); +}); diff --git a/erp/app/Modules/Appointments/Http/Controllers/AppointmentController.php b/erp/app/Modules/Appointments/Http/Controllers/AppointmentController.php new file mode 100644 index 00000000000..a048a81d960 --- /dev/null +++ b/erp/app/Modules/Appointments/Http/Controllers/AppointmentController.php @@ -0,0 +1,178 @@ +addDays(7); + + $stats = [ + 'today_count' => Appointment::whereDate('created_at', $today)->count(), + 'pending_count' => Appointment::where('status', 'pending')->count(), + 'confirmed_count' => Appointment::where('status', 'confirmed')->count(), + 'upcoming_week' => AppointmentSlot::whereBetween('start_at', [$today, $nextWeek])->count(), + ]; + + return Inertia::render('Appointments/Dashboard', [ + 'stats' => $stats, + ]); + } + + public function types(): Response + { + $types = AppointmentType::orderBy('name')->paginate(20); + + return Inertia::render('Appointments/Types/Index', [ + 'types' => $types, + ]); + } + + public function storeType(Request $request): RedirectResponse + { + $request->validate([ + 'name' => 'required|string|max:255', + 'description' => 'nullable|string', + 'duration_minutes' => 'nullable|integer|min:15', + 'location' => 'nullable|string|max:255', + 'max_capacity' => 'nullable|integer|min:1', + 'is_active' => 'nullable|boolean', + 'color' => 'nullable|string|max:50', + ]); + + AppointmentType::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'name' => $request->name, + 'description' => $request->description, + 'duration_minutes' => $request->input('duration_minutes', 60), + 'location' => $request->location, + 'max_capacity' => $request->input('max_capacity', 1), + 'is_active' => $request->input('is_active', true), + 'color' => $request->color, + ]); + + return redirect()->back()->with('success', 'Appointment type created.'); + } + + public function slots(): Response + { + $slots = AppointmentSlot::with('type') + ->orderBy('start_at') + ->paginate(20); + + return Inertia::render('Appointments/Slots/Index', [ + 'slots' => $slots, + 'types' => AppointmentType::where('is_active', true)->orderBy('name')->get(['id', 'name']), + ]); + } + + public function storeSlot(Request $request): RedirectResponse + { + $request->validate([ + 'appointment_type_id' => 'required|exists:appointment_types,id', + 'start_at' => 'required|date', + 'end_at' => 'required|date|after:start_at', + 'capacity' => 'nullable|integer|min:1', + 'staff_user_id' => 'nullable|exists:users,id', + 'is_available' => 'nullable|boolean', + ]); + + AppointmentSlot::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'appointment_type_id' => $request->appointment_type_id, + 'start_at' => $request->start_at, + 'end_at' => $request->end_at, + 'capacity' => $request->input('capacity', 1), + 'staff_user_id' => $request->staff_user_id, + 'is_available' => $request->input('is_available', true), + ]); + + return redirect()->back()->with('success', 'Appointment slot created.'); + } + + public function index(): Response + { + $appointments = Appointment::with(['slot.type']) + ->orderByDesc('created_at') + ->paginate(20); + + return Inertia::render('Appointments/Index', [ + 'appointments' => $appointments, + 'slots' => AppointmentSlot::with('type')->where('is_available', true)->orderBy('start_at')->get(), + 'types' => AppointmentType::where('is_active', true)->orderBy('name')->get(['id', 'name']), + ]); + } + + public function book(Request $request): JsonResponse + { + $request->validate([ + 'appointment_slot_id' => 'required|exists:appointment_slots,id', + 'appointment_type_id' => 'required|exists:appointment_types,id', + 'customer_name' => 'required|string|max:255', + 'customer_email' => 'required|email|max:255', + 'customer_phone' => 'nullable|string|max:50', + 'notes' => 'nullable|string', + ]); + + $slot = AppointmentSlot::findOrFail($request->appointment_slot_id); + + if ($slot->isFull()) { + return response()->json(['message' => 'This slot is fully booked.'], 422); + } + + $appointment = Appointment::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'appointment_slot_id' => $request->appointment_slot_id, + 'appointment_type_id' => $request->appointment_type_id, + 'customer_name' => $request->customer_name, + 'customer_email' => $request->customer_email, + 'customer_phone' => $request->customer_phone, + 'notes' => $request->notes, + 'status' => 'pending', + ]); + + $slot->increment('booked_count'); + + return response()->json([ + 'success' => true, + 'appointment_id' => $appointment->id, + ]); + } + + public function confirm(Appointment $appointment): JsonResponse + { + $appointment->confirm(); + + return response()->json(['success' => true]); + } + + public function cancel(Request $request, Appointment $appointment): JsonResponse + { + $request->validate([ + 'cancellation_reason' => 'nullable|string', + ]); + + $appointment->cancel($request->input('cancellation_reason', '')); + + return response()->json(['success' => true]); + } + + public function complete(Appointment $appointment): JsonResponse + { + $appointment->complete(); + + return response()->json(['success' => true]); + } +} diff --git a/erp/app/Modules/Appointments/Models/Appointment.php b/erp/app/Modules/Appointments/Models/Appointment.php new file mode 100644 index 00000000000..c95fb231948 --- /dev/null +++ b/erp/app/Modules/Appointments/Models/Appointment.php @@ -0,0 +1,72 @@ + 'datetime', + 'cancelled_at' => 'datetime', + ]; + + public function slot(): BelongsTo + { + return $this->belongsTo(AppointmentSlot::class, 'appointment_slot_id'); + } + + public function type(): BelongsTo + { + return $this->belongsTo(AppointmentType::class, 'appointment_type_id'); + } + + public function confirm(): void + { + $this->update([ + 'status' => 'confirmed', + 'confirmed_at' => now(), + ]); + } + + public function cancel(string $reason = ''): void + { + $this->update([ + 'status' => 'cancelled', + 'cancelled_at' => now(), + 'cancellation_reason' => $reason, + ]); + } + + public function complete(): void + { + $this->update([ + 'status' => 'completed', + ]); + } + + public function markNoShow(): void + { + $this->update([ + 'status' => 'no_show', + ]); + } +} diff --git a/erp/app/Modules/Appointments/Models/AppointmentSlot.php b/erp/app/Modules/Appointments/Models/AppointmentSlot.php new file mode 100644 index 00000000000..8fa2b5a7208 --- /dev/null +++ b/erp/app/Modules/Appointments/Models/AppointmentSlot.php @@ -0,0 +1,47 @@ + 'datetime', + 'end_at' => 'datetime', + 'is_available' => 'boolean', + 'capacity' => 'integer', + 'booked_count' => 'integer', + ]; + + public function type(): BelongsTo + { + return $this->belongsTo(AppointmentType::class, 'appointment_type_id'); + } + + public function appointments(): HasMany + { + return $this->hasMany(Appointment::class); + } + + public function isFull(): bool + { + return $this->booked_count >= $this->capacity; + } +} diff --git a/erp/app/Modules/Appointments/Models/AppointmentType.php b/erp/app/Modules/Appointments/Models/AppointmentType.php new file mode 100644 index 00000000000..9ef40e1d74f --- /dev/null +++ b/erp/app/Modules/Appointments/Models/AppointmentType.php @@ -0,0 +1,39 @@ + 'boolean', + 'duration_minutes' => 'integer', + 'max_capacity' => 'integer', + ]; + + public function slots(): HasMany + { + return $this->hasMany(AppointmentSlot::class); + } + + public function appointments(): HasMany + { + return $this->hasMany(Appointment::class); + } +} diff --git a/erp/app/Modules/Appointments/Providers/AppointmentsServiceProvider.php b/erp/app/Modules/Appointments/Providers/AppointmentsServiceProvider.php new file mode 100644 index 00000000000..5ffc3201c73 --- /dev/null +++ b/erp/app/Modules/Appointments/Providers/AppointmentsServiceProvider.php @@ -0,0 +1,15 @@ +loadRoutesFrom(__DIR__ . '/../routes/appointments.php'); + } +} diff --git a/erp/app/Modules/Appointments/routes/appointments.php b/erp/app/Modules/Appointments/routes/appointments.php new file mode 100644 index 00000000000..6b3cef90473 --- /dev/null +++ b/erp/app/Modules/Appointments/routes/appointments.php @@ -0,0 +1,17 @@ +prefix('appointments')->name('appointments.')->group(function () { + Route::get('dashboard', [AppointmentController::class, 'dashboard'])->name('dashboard'); + Route::get('types', [AppointmentController::class, 'types'])->name('types'); + Route::post('types', [AppointmentController::class, 'storeType'])->name('types.store'); + Route::get('slots', [AppointmentController::class, 'slots'])->name('slots'); + Route::post('slots', [AppointmentController::class, 'storeSlot'])->name('slots.store'); + Route::get('/', [AppointmentController::class, 'index'])->name('index'); + Route::post('book', [AppointmentController::class, 'book'])->name('book'); + Route::post('{appointment}/confirm', [AppointmentController::class, 'confirm'])->name('confirm'); + Route::post('{appointment}/cancel', [AppointmentController::class, 'cancel'])->name('cancel'); + Route::post('{appointment}/complete', [AppointmentController::class, 'complete'])->name('complete'); +}); diff --git a/erp/app/Modules/Approvals/Http/Controllers/ApprovalRequestController.php b/erp/app/Modules/Approvals/Http/Controllers/ApprovalRequestController.php new file mode 100644 index 00000000000..e3e84c30638 --- /dev/null +++ b/erp/app/Modules/Approvals/Http/Controllers/ApprovalRequestController.php @@ -0,0 +1,123 @@ +where('approval_requests.tenant_id', auth()->user()->tenant_id) + ->with(['workflow', 'requestedBy']) + ->orderByDesc('created_at'); + + if ($request->filled('status')) { + $query->where('status', $request->status); + } + + if ($request->filled('entity_type')) { + $query->where('entity_type', $request->entity_type); + } + + $requests = $query->paginate(25)->withQueryString(); + + return Inertia::render('Approvals/Requests/Index', [ + 'requests' => $requests, + 'filters' => $request->only(['status', 'entity_type']), + ]); + } + + public function show(ApprovalRequest $approvalRequest): Response + { + $approvalRequest->load([ + 'workflow.steps.approver', + 'requestedBy', + 'actions.actor', + ]); + + $canApprove = $approvalRequest->canApprove(auth()->user()); + + return Inertia::render('Approvals/Requests/Show', [ + 'approvalRequest' => $approvalRequest, + 'canApprove' => $canApprove, + ]); + } + + public function approve(Request $request, ApprovalRequest $approvalRequest): RedirectResponse + { + $data = $request->validate([ + 'comments' => 'nullable|string|max:1000', + ]); + + if (! $approvalRequest->canApprove(auth()->user())) { + return back()->with('error', 'You are not authorized to approve this request.'); + } + + $approvalRequest->approve(auth()->user(), $data['comments'] ?? ''); + + return back()->with('success', 'Request approved successfully.'); + } + + public function reject(Request $request, ApprovalRequest $approvalRequest): RedirectResponse + { + $data = $request->validate([ + 'reason' => 'required|string|max:1000', + ]); + + if (! $approvalRequest->canApprove(auth()->user())) { + return back()->with('error', 'You are not authorized to reject this request.'); + } + + $approvalRequest->reject(auth()->user(), $data['reason']); + + return back()->with('success', 'Request rejected.'); + } + + public function cancel(ApprovalRequest $approvalRequest): RedirectResponse + { + if (! $approvalRequest->isPending()) { + return back()->with('error', 'Only pending requests can be cancelled.'); + } + + $approvalRequest->cancel(); + + return back()->with('success', 'Request cancelled.'); + } + + public function myPending(): Response + { + $user = auth()->user(); + + // Find approval steps where this user is the approver by ID or by role + $userRoles = $user->getRoleNames()->toArray(); + + $requests = ApprovalRequest::withoutGlobalScopes() + ->where('approval_requests.tenant_id', $user->tenant_id) + ->where('approval_requests.status', 'pending') + ->with(['workflow', 'requestedBy']) + ->whereHas('workflow.steps', function ($query) use ($user, $userRoles) { + $query->whereColumn('approval_steps.step_number', 'approval_requests.current_step') + ->where('approval_steps.workflow_id', \DB::raw('approval_requests.workflow_id')) + ->where(function ($q) use ($user, $userRoles) { + $q->where('approval_steps.approver_id', $user->id); + if (! empty($userRoles)) { + $q->orWhereIn('approval_steps.approver_role', $userRoles); + } + }); + }) + ->orderByDesc('created_at') + ->get(); + + return Inertia::render('Approvals/Requests/MyPending', [ + 'requests' => $requests, + ]); + } +} diff --git a/erp/app/Modules/Approvals/Http/Controllers/ApprovalWorkflowController.php b/erp/app/Modules/Approvals/Http/Controllers/ApprovalWorkflowController.php new file mode 100644 index 00000000000..138774a00c9 --- /dev/null +++ b/erp/app/Modules/Approvals/Http/Controllers/ApprovalWorkflowController.php @@ -0,0 +1,159 @@ +where('tenant_id', auth()->user()->tenant_id) + ->withCount('steps') + ->orderBy('name') + ->get(); + + return Inertia::render('Approvals/Workflows/Index', [ + 'workflows' => $workflows, + ]); + } + + public function create(): Response + { + $users = User::where('tenant_id', auth()->user()->tenant_id) + ->where('is_active', true) + ->orderBy('name') + ->get(['id', 'name', 'email']); + + $roles = Role::orderBy('name')->pluck('name'); + + return Inertia::render('Approvals/Workflows/Create', [ + 'users' => $users, + 'roles' => $roles, + ]); + } + + public function store(Request $request): RedirectResponse + { + $data = $request->validate([ + 'name' => 'required|string|max:255', + 'entity_type' => 'required|string|in:purchase_order,expense,leave_request,bill,manufacturing_order', + 'min_amount' => 'nullable|numeric|min:0', + 'max_amount' => 'nullable|numeric|min:0', + 'is_active' => 'boolean', + 'steps' => 'array|min:1', + 'steps.*.name' => 'required|string|max:255', + 'steps.*.approver_id' => 'nullable|exists:users,id', + 'steps.*.approver_role' => 'nullable|string|max:100', + 'steps.*.is_required' => 'boolean', + ]); + + $workflow = ApprovalWorkflow::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'name' => $data['name'], + 'entity_type' => $data['entity_type'], + 'min_amount' => $data['min_amount'] ?? null, + 'max_amount' => $data['max_amount'] ?? null, + 'is_active' => $data['is_active'] ?? true, + ]); + + foreach ($data['steps'] ?? [] as $index => $stepData) { + ApprovalStep::create([ + 'workflow_id' => $workflow->id, + 'step_number' => $index + 1, + 'name' => $stepData['name'], + 'approver_id' => $stepData['approver_id'] ?? null, + 'approver_role' => $stepData['approver_role'] ?? null, + 'is_required' => $stepData['is_required'] ?? true, + ]); + } + + return redirect()->route('approvals.workflows.index') + ->with('success', 'Workflow created successfully.'); + } + + public function show(ApprovalWorkflow $workflow): Response + { + $workflow->load('steps.approver'); + + return Inertia::render('Approvals/Workflows/Show', [ + 'workflow' => $workflow, + ]); + } + + public function edit(ApprovalWorkflow $workflow): Response + { + $workflow->load('steps'); + + $users = User::where('tenant_id', auth()->user()->tenant_id) + ->where('is_active', true) + ->orderBy('name') + ->get(['id', 'name', 'email']); + + $roles = Role::orderBy('name')->pluck('name'); + + return Inertia::render('Approvals/Workflows/Edit', [ + 'workflow' => $workflow, + 'users' => $users, + 'roles' => $roles, + ]); + } + + public function update(Request $request, ApprovalWorkflow $workflow): RedirectResponse + { + $data = $request->validate([ + 'name' => 'required|string|max:255', + 'entity_type' => 'required|string|in:purchase_order,expense,leave_request,bill,manufacturing_order', + 'min_amount' => 'nullable|numeric|min:0', + 'max_amount' => 'nullable|numeric|min:0', + 'is_active' => 'boolean', + 'steps' => 'array|min:1', + 'steps.*.name' => 'required|string|max:255', + 'steps.*.approver_id' => 'nullable|exists:users,id', + 'steps.*.approver_role' => 'nullable|string|max:100', + 'steps.*.is_required' => 'boolean', + ]); + + $workflow->update([ + 'name' => $data['name'], + 'entity_type' => $data['entity_type'], + 'min_amount' => $data['min_amount'] ?? null, + 'max_amount' => $data['max_amount'] ?? null, + 'is_active' => $data['is_active'] ?? true, + ]); + + // Sync steps: delete existing, recreate + $workflow->steps()->delete(); + + foreach ($data['steps'] ?? [] as $index => $stepData) { + ApprovalStep::create([ + 'workflow_id' => $workflow->id, + 'step_number' => $index + 1, + 'name' => $stepData['name'], + 'approver_id' => $stepData['approver_id'] ?? null, + 'approver_role' => $stepData['approver_role'] ?? null, + 'is_required' => $stepData['is_required'] ?? true, + ]); + } + + return redirect()->route('approvals.workflows.show', $workflow) + ->with('success', 'Workflow updated successfully.'); + } + + public function destroy(ApprovalWorkflow $workflow): RedirectResponse + { + $workflow->delete(); + + return redirect()->route('approvals.workflows.index') + ->with('success', 'Workflow deleted successfully.'); + } +} diff --git a/erp/app/Modules/Approvals/Http/Controllers/ApprovalsDashboardController.php b/erp/app/Modules/Approvals/Http/Controllers/ApprovalsDashboardController.php new file mode 100644 index 00000000000..7b695f0ddaf --- /dev/null +++ b/erp/app/Modules/Approvals/Http/Controllers/ApprovalsDashboardController.php @@ -0,0 +1,69 @@ +user(); + $tenantId = $user->tenant_id; + + $pendingRequests = ApprovalRequest::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where('status', 'pending') + ->count(); + + $approvedToday = ApprovalRequest::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where('status', 'approved') + ->whereDate('approved_at', today()) + ->count(); + + $rejectedToday = ApprovalRequest::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where('status', 'rejected') + ->whereDate('rejected_at', today()) + ->count(); + + $userRoles = $user->getRoleNames()->toArray(); + + $myPendingCount = ApprovalRequest::withoutGlobalScopes() + ->where('approval_requests.tenant_id', $tenantId) + ->where('approval_requests.status', 'pending') + ->whereHas('workflow.steps', function ($query) use ($user, $userRoles) { + $query->whereColumn('approval_steps.step_number', 'approval_requests.current_step') + ->where('approval_steps.workflow_id', DB::raw('approval_requests.workflow_id')) + ->where(function ($q) use ($user, $userRoles) { + $q->where('approval_steps.approver_id', $user->id); + if (! empty($userRoles)) { + $q->orWhereIn('approval_steps.approver_role', $userRoles); + } + }); + }) + ->count(); + + $recentRequests = ApprovalRequest::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->with(['workflow', 'requestedBy']) + ->orderByDesc('created_at') + ->limit(10) + ->get(); + + return Inertia::render('Approvals/Dashboard', [ + 'stats' => [ + 'pending_requests' => $pendingRequests, + 'approved_today' => $approvedToday, + 'rejected_today' => $rejectedToday, + 'my_pending' => $myPendingCount, + ], + 'recentRequests' => $recentRequests, + ]); + } +} diff --git a/erp/app/Modules/Approvals/Models/ApprovalAction.php b/erp/app/Modules/Approvals/Models/ApprovalAction.php new file mode 100644 index 00000000000..be01e8149f6 --- /dev/null +++ b/erp/app/Modules/Approvals/Models/ApprovalAction.php @@ -0,0 +1,35 @@ + 'datetime', + ]; + + public function request(): BelongsTo + { + return $this->belongsTo(ApprovalRequest::class, 'request_id'); + } + + public function actor(): BelongsTo + { + return $this->belongsTo(User::class, 'actor_id'); + } +} diff --git a/erp/app/Modules/Approvals/Models/ApprovalRequest.php b/erp/app/Modules/Approvals/Models/ApprovalRequest.php new file mode 100644 index 00000000000..7af06234715 --- /dev/null +++ b/erp/app/Modules/Approvals/Models/ApprovalRequest.php @@ -0,0 +1,164 @@ + 'datetime', + 'rejected_at' => 'datetime', + 'current_step' => 'integer', + 'total_steps' => 'integer', + ]; + + public function workflow(): BelongsTo + { + return $this->belongsTo(ApprovalWorkflow::class, 'workflow_id'); + } + + public function requestedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'requested_by'); + } + + public function actions(): HasMany + { + return $this->hasMany(ApprovalAction::class, 'request_id')->orderBy('step_number'); + } + + public function isPending(): bool + { + return $this->status === 'pending'; + } + + public function isApproved(): bool + { + return $this->status === 'approved'; + } + + public function currentStepObject(): ?ApprovalStep + { + if (! $this->workflow) { + return null; + } + + return $this->workflow->steps->where('step_number', $this->current_step)->first(); + } + + public function canApprove(User $user): bool + { + if (! $this->isPending()) { + return false; + } + + $step = $this->currentStepObject(); + + if (! $step) { + return false; + } + + if ($step->approver_id && $step->approver_id === $user->id) { + return true; + } + + if ($step->approver_role && $user->hasRole($step->approver_role)) { + return true; + } + + return false; + } + + public function approve(User $user, string $comments = ''): void + { + $this->actions()->create([ + 'step_number' => $this->current_step, + 'action' => 'approved', + 'actor_id' => $user->id, + 'comments' => $comments ?: null, + 'acted_at' => now(), + ]); + + if ($this->current_step < $this->total_steps) { + $this->current_step += 1; + $this->save(); + } else { + $this->status = 'approved'; + $this->approved_at = now(); + $this->save(); + } + } + + public function reject(User $user, string $reason = ''): void + { + $this->actions()->create([ + 'step_number' => $this->current_step, + 'action' => 'rejected', + 'actor_id' => $user->id, + 'comments' => $reason ?: null, + 'acted_at' => now(), + ]); + + $this->status = 'rejected'; + $this->rejected_at = now(); + $this->rejection_reason = $reason ?: null; + $this->save(); + } + + public function cancel(): void + { + $this->status = 'cancelled'; + $this->save(); + } + + public static function createFor( + string $entityType, + int $entityId, + string $entityTitle, + int $requestedBy, + int $tenantId, + float $amount = 0 + ): ?self { + $workflow = ApprovalWorkflow::findFor($entityType, $amount, $tenantId); + + if (! $workflow) { + return null; + } + + return self::create([ + 'tenant_id' => $tenantId, + 'workflow_id' => $workflow->id, + 'entity_type' => $entityType, + 'entity_id' => $entityId, + 'entity_title' => $entityTitle, + 'status' => 'pending', + 'current_step' => 1, + 'total_steps' => $workflow->stepCount(), + 'requested_by' => $requestedBy, + ]); + } +} diff --git a/erp/app/Modules/Approvals/Models/ApprovalStep.php b/erp/app/Modules/Approvals/Models/ApprovalStep.php new file mode 100644 index 00000000000..f1772cfdbe8 --- /dev/null +++ b/erp/app/Modules/Approvals/Models/ApprovalStep.php @@ -0,0 +1,35 @@ + 'boolean', + ]; + + public function workflow(): BelongsTo + { + return $this->belongsTo(ApprovalWorkflow::class, 'workflow_id'); + } + + public function approver(): BelongsTo + { + return $this->belongsTo(User::class, 'approver_id'); + } +} diff --git a/erp/app/Modules/Approvals/Models/ApprovalWorkflow.php b/erp/app/Modules/Approvals/Models/ApprovalWorkflow.php new file mode 100644 index 00000000000..430fcfdbb4c --- /dev/null +++ b/erp/app/Modules/Approvals/Models/ApprovalWorkflow.php @@ -0,0 +1,61 @@ + 'float', + 'max_amount' => 'float', + 'is_active' => 'boolean', + ]; + + public function steps(): HasMany + { + return $this->hasMany(ApprovalStep::class, 'workflow_id')->orderBy('step_number'); + } + + public function requests(): HasMany + { + return $this->hasMany(ApprovalRequest::class, 'workflow_id'); + } + + public function stepCount(): int + { + return $this->steps()->count(); + } + + public static function findFor(string $entityType, float $amount = 0, int $tenantId): ?self + { + return self::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where('entity_type', $entityType) + ->where('is_active', true) + ->where(function ($query) use ($amount) { + $query->whereNull('min_amount') + ->orWhere('min_amount', '<=', $amount); + }) + ->where(function ($query) use ($amount) { + $query->whereNull('max_amount') + ->orWhere('max_amount', '>=', $amount); + }) + ->first(); + } +} diff --git a/erp/app/Modules/Approvals/Policies/ApprovalsPolicy.php b/erp/app/Modules/Approvals/Policies/ApprovalsPolicy.php new file mode 100644 index 00000000000..7a630d47b8b --- /dev/null +++ b/erp/app/Modules/Approvals/Policies/ApprovalsPolicy.php @@ -0,0 +1,33 @@ +can('finance.create'); + } + + public function update(User $user): bool + { + return $user->can('finance.create'); + } + + public function delete(User $user): bool + { + return $user->can('finance.delete'); + } +} diff --git a/erp/app/Modules/Approvals/Providers/ApprovalsServiceProvider.php b/erp/app/Modules/Approvals/Providers/ApprovalsServiceProvider.php new file mode 100644 index 00000000000..0bc5c350c51 --- /dev/null +++ b/erp/app/Modules/Approvals/Providers/ApprovalsServiceProvider.php @@ -0,0 +1,22 @@ +loadRoutesFrom(__DIR__ . '/../routes/approvals.php'); + + Gate::policy(ApprovalWorkflow::class, ApprovalsPolicy::class); + Gate::policy(ApprovalRequest::class, ApprovalsPolicy::class); + } +} diff --git a/erp/app/Modules/Approvals/routes/approvals.php b/erp/app/Modules/Approvals/routes/approvals.php new file mode 100644 index 00000000000..1aa66603ec0 --- /dev/null +++ b/erp/app/Modules/Approvals/routes/approvals.php @@ -0,0 +1,19 @@ +prefix('approvals')->name('approvals.')->group(function () { + Route::get('dashboard', [ApprovalsDashboardController::class, 'index'])->name('dashboard'); + Route::get('my-pending', [ApprovalRequestController::class, 'myPending'])->name('requests.my-pending'); + + // Request actions BEFORE resource + Route::post('requests/{approvalRequest}/approve', [ApprovalRequestController::class, 'approve'])->name('requests.approve'); + Route::post('requests/{approvalRequest}/reject', [ApprovalRequestController::class, 'reject'])->name('requests.reject'); + Route::post('requests/{approvalRequest}/cancel', [ApprovalRequestController::class, 'cancel'])->name('requests.cancel'); + Route::resource('requests', ApprovalRequestController::class)->only(['index', 'show'])->parameters(['requests' => 'approvalRequest']); + + Route::resource('workflows', ApprovalWorkflowController::class); +}); diff --git a/erp/app/Modules/CRM/Http/Controllers/CrmActivityController.php b/erp/app/Modules/CRM/Http/Controllers/CrmActivityController.php new file mode 100644 index 00000000000..fb5e6cb4381 --- /dev/null +++ b/erp/app/Modules/CRM/Http/Controllers/CrmActivityController.php @@ -0,0 +1,45 @@ +validate([ + 'type' => 'required|in:call,meeting,email,task,note', + 'subject' => 'required|string|max:255', + 'description' => 'nullable|string', + 'scheduled_at' => 'nullable|date', + 'assigned_to' => 'nullable|exists:users,id', + ]); + + $lead->activities()->create([ + ...$validated, + 'tenant_id' => auth()->user()->tenant_id, + 'created_by' => auth()->id(), + ]); + + return back()->with('success', 'Activity added.'); + } + + public function markDone(CrmActivity $activity): RedirectResponse + { + $activity->markDone(); + + return back()->with('success', 'Activity marked as done.'); + } + + public function destroy(CrmActivity $activity): RedirectResponse + { + $activity->delete(); + + return back()->with('success', 'Activity deleted.'); + } +} diff --git a/erp/app/Modules/CRM/Http/Controllers/CrmDashboardController.php b/erp/app/Modules/CRM/Http/Controllers/CrmDashboardController.php new file mode 100644 index 00000000000..c5d139e7bd5 --- /dev/null +++ b/erp/app/Modules/CRM/Http/Controllers/CrmDashboardController.php @@ -0,0 +1,77 @@ +copy()->startOfMonth(); + $thirtyDaysAgo = $now->copy()->subDays(30); + + $totalLeads = CrmLead::where('type', 'lead') + ->where('status', 'open') + ->count(); + + $totalOpportunities = CrmLead::where('type', 'opportunity') + ->where('status', 'open') + ->count(); + + $totalExpectedRevenue = CrmLead::where('status', 'open') + ->sum('expected_revenue'); + + $wonThisMonth = CrmLead::where('status', 'won') + ->where('won_at', '>=', $startOfMonth) + ->count(); + + $wonRevenueThisMonth = CrmLead::where('status', 'won') + ->where('won_at', '>=', $startOfMonth) + ->sum('expected_revenue'); + + $wonLast30 = CrmLead::where('status', 'won') + ->where('won_at', '>=', $thirtyDaysAgo) + ->count(); + + $lostLast30 = CrmLead::where('status', 'lost') + ->where('lost_at', '>=', $thirtyDaysAgo) + ->count(); + + $total30 = $wonLast30 + $lostLast30; + $conversionRate = $total30 > 0 + ? round(($wonLast30 / $total30) * 100, 1) + : 0; + + $pipelineByStage = CrmStage::withCount([ + 'leads as lead_count' => fn ($q) => $q->where('status', 'open'), + ])->withSum( + ['leads as expected_revenue_sum' => fn ($q) => $q->where('status', 'open')], + 'expected_revenue' + )->orderBy('sequence')->get(['id', 'name', 'color', 'sequence']); + + $recentLeads = CrmLead::with(['stage', 'assignee']) + ->orderByDesc('created_at') + ->limit(5) + ->get(['id', 'reference', 'title', 'stage_id', 'assigned_to', 'priority', 'expected_close_date', 'status']); + + return Inertia::render('CRM/Dashboard', [ + 'stats' => [ + 'totalLeads' => $totalLeads, + 'totalOpportunities' => $totalOpportunities, + 'totalExpectedRevenue' => (float) $totalExpectedRevenue, + 'wonThisMonth' => $wonThisMonth, + 'wonRevenueThisMonth' => (float) $wonRevenueThisMonth, + 'conversionRate' => $conversionRate, + ], + 'pipelineByStage' => $pipelineByStage, + 'recentLeads' => $recentLeads, + ]); + } +} diff --git a/erp/app/Modules/CRM/Http/Controllers/CrmLeadController.php b/erp/app/Modules/CRM/Http/Controllers/CrmLeadController.php new file mode 100644 index 00000000000..39c92e64137 --- /dev/null +++ b/erp/app/Modules/CRM/Http/Controllers/CrmLeadController.php @@ -0,0 +1,189 @@ +when($request->type, fn ($q) => $q->where('type', $request->type)) + ->when($request->status, fn ($q) => $q->where('status', $request->status)) + ->when($request->stage_id, fn ($q) => $q->where('stage_id', $request->stage_id)) + ->when($request->assigned_to, fn ($q) => $q->where('assigned_to', $request->assigned_to)) + ->when($request->search, fn ($q) => $q->where(function ($q2) use ($request) { + $q2->where('title', 'like', "%{$request->search}%") + ->orWhere('contact_name', 'like', "%{$request->search}%"); + })) + ->orderByDesc('created_at') + ->paginate(25) + ->withQueryString(); + + return Inertia::render('CRM/Leads/Index', [ + 'leads' => $leads, + 'filters' => $request->only(['type', 'status', 'stage_id', 'assigned_to', 'search']), + ]); + } + + public function create(): Response + { + return Inertia::render('CRM/Leads/Create', [ + 'stages' => CrmStage::where('is_active', true)->orderBy('sequence')->get(['id', 'name', 'type']), + 'users' => User::orderBy('name')->get(['id', 'name']), + ]); + } + + public function store(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'title' => 'required|string|max:255', + 'type' => 'required|in:lead,opportunity', + 'stage_id' => 'nullable|exists:crm_stages,id', + 'contact_name' => 'nullable|string|max:255', + 'company_name' => 'nullable|string|max:255', + 'email' => 'nullable|email|max:255', + 'phone' => 'nullable|string|max:50', + 'website' => 'nullable|string|max:255', + 'source' => 'nullable|string|max:50', + 'expected_revenue' => 'nullable|numeric|min:0', + 'probability' => 'nullable|numeric|min:0|max:100', + 'expected_close_date' => 'nullable|date', + 'priority' => 'required|in:low,normal,high,urgent', + 'description' => 'nullable|string', + 'assigned_to' => 'nullable|exists:users,id', + ]); + + $lead = CrmLead::create([ + ...$validated, + 'tenant_id' => auth()->user()->tenant_id, + 'created_by' => auth()->id(), + ]); + + if (! $lead->reference) { + $lead->reference = $lead->generateReference(); + $lead->saveQuietly(); + } + + return redirect()->route('crm.leads.show', $lead)->with('success', 'Lead created.'); + } + + public function show(CrmLead $lead): Response + { + $lead->load(['stage', 'assignee', 'activities.assignee']); + + return Inertia::render('CRM/Leads/Show', [ + 'lead' => $lead, + 'stages' => CrmStage::where('is_active', true)->orderBy('sequence')->get(['id', 'name']), + 'users' => User::orderBy('name')->get(['id', 'name']), + ]); + } + + public function edit(CrmLead $lead): Response + { + return Inertia::render('CRM/Leads/Edit', [ + 'lead' => $lead, + 'stages' => CrmStage::where('is_active', true)->orderBy('sequence')->get(['id', 'name', 'type']), + 'users' => User::orderBy('name')->get(['id', 'name']), + ]); + } + + public function update(Request $request, CrmLead $lead): RedirectResponse + { + $validated = $request->validate([ + 'title' => 'required|string|max:255', + 'type' => 'required|in:lead,opportunity', + 'stage_id' => 'nullable|exists:crm_stages,id', + 'contact_name' => 'nullable|string|max:255', + 'company_name' => 'nullable|string|max:255', + 'email' => 'nullable|email|max:255', + 'phone' => 'nullable|string|max:50', + 'website' => 'nullable|string|max:255', + 'source' => 'nullable|string|max:50', + 'expected_revenue' => 'nullable|numeric|min:0', + 'probability' => 'nullable|numeric|min:0|max:100', + 'expected_close_date' => 'nullable|date', + 'priority' => 'required|in:low,normal,high,urgent', + 'description' => 'nullable|string', + 'assigned_to' => 'nullable|exists:users,id', + ]); + + $lead->update($validated); + + return redirect()->route('crm.leads.show', $lead)->with('success', 'Lead updated.'); + } + + public function destroy(CrmLead $lead): RedirectResponse + { + $lead->delete(); + + return redirect()->route('crm.leads.index')->with('success', 'Lead deleted.'); + } + + public function markWon(CrmLead $lead): RedirectResponse + { + $lead->markWon(); + + return redirect()->route('crm.leads.show', $lead)->with('success', 'Lead marked as won.'); + } + + public function markLost(Request $request, CrmLead $lead): RedirectResponse + { + $request->validate([ + 'lost_reason' => 'nullable|string|max:1000', + ]); + + $lead->markLost($request->lost_reason ?? ''); + + return redirect()->route('crm.leads.show', $lead)->with('success', 'Lead marked as lost.'); + } + + public function convert(CrmLead $lead): RedirectResponse + { + $lead->convertToOpportunity(); + + return redirect()->route('crm.leads.show', $lead)->with('success', 'Converted to opportunity.'); + } + + public function kanban(Request $request): Response + { + $stages = \App\Modules\CRM\Models\CrmStage::orderBy('sequence')->get(['id', 'name', 'color']); + $leads = CrmLead::with(['stage', 'assignee']) + ->where('type', 'opportunity') + ->whereNotIn('status', ['lost']) + ->get() + ->groupBy('stage_id'); + + $columns = $stages->map(fn ($stage) => [ + 'id' => $stage->id, + 'name' => $stage->name, + 'color' => $stage->color ?? '#6b7280', + 'leads' => ($leads->get($stage->id) ?? collect())->map(fn ($l) => [ + 'id' => $l->id, + 'title' => $l->title, + 'contact_name' => $l->contact_name, + 'expected_revenue' => $l->expected_revenue, + 'probability' => $l->probability, + 'priority' => $l->priority, + 'assignee' => $l->assignee ? ['name' => $l->assignee->name] : null, + ])->values(), + ]); + + return Inertia::render('CRM/Pipeline/Kanban', ['columns' => $columns]); + } + + public function moveStage(Request $request, CrmLead $lead): \Illuminate\Http\JsonResponse + { + $data = $request->validate(['stage_id' => 'required|exists:crm_stages,id']); + $lead->update(['stage_id' => $data['stage_id']]); + return response()->json(['ok' => true]); + } +} diff --git a/erp/app/Modules/CRM/Http/Controllers/CrmReportController.php b/erp/app/Modules/CRM/Http/Controllers/CrmReportController.php new file mode 100644 index 00000000000..238a4cfc245 --- /dev/null +++ b/erp/app/Modules/CRM/Http/Controllers/CrmReportController.php @@ -0,0 +1,97 @@ + fn ($q) => $q->where('type', 'lead'), + 'leads as opportunity_count' => fn ($q) => $q->where('type', 'opportunity'), + ])->withSum( + ['leads as expected_revenue_sum' => fn ($q) => $q->whereIn('status', ['open', 'won'])], + 'expected_revenue' + )->withAvg( + ['leads as avg_probability' => fn ($q) => $q->where('status', 'open')], + 'probability' + )->orderBy('sequence')->get(); + + return Inertia::render('CRM/Reports/Pipeline', [ + 'stages' => $stages, + ]); + } + + public function winLoss(): Response + { + $months = collect(range(5, 0))->map(function ($i) { + $month = Carbon::now()->subMonths($i); + + $won = CrmLead::where('status', 'won') + ->whereYear('won_at', $month->year) + ->whereMonth('won_at', $month->month) + ->count(); + + $wonRevenue = CrmLead::where('status', 'won') + ->whereYear('won_at', $month->year) + ->whereMonth('won_at', $month->month) + ->sum('expected_revenue'); + + $lost = CrmLead::where('status', 'lost') + ->whereYear('lost_at', $month->year) + ->whereMonth('lost_at', $month->month) + ->count(); + + $total = $won + $lost; + + return [ + 'month' => $month->format('M Y'), + 'won' => $won, + 'won_revenue' => (float) $wonRevenue, + 'lost' => $lost, + 'conversion_rate'=> $total > 0 ? round(($won / $total) * 100, 1) : 0, + ]; + }); + + return Inertia::render('CRM/Reports/WinLoss', [ + 'months' => $months, + 'totals' => [ + 'won' => $months->sum('won'), + 'lost' => $months->sum('lost'), + 'won_revenue' => $months->sum('won_revenue'), + ], + ]); + } + + public function source(): Response + { + $sources = CrmLead::selectRaw( + "COALESCE(source, 'Unknown') as source_name, + COUNT(*) as total_count, + SUM(CASE WHEN type = 'lead' THEN 1 ELSE 0 END) as lead_count, + SUM(CASE WHEN type = 'opportunity' THEN 1 ELSE 0 END) as opportunity_count, + SUM(CASE WHEN status = 'won' THEN 1 ELSE 0 END) as won_count, + SUM(CASE WHEN status = 'won' THEN expected_revenue ELSE 0 END) as total_revenue" + ) + ->groupBy('source_name') + ->orderByDesc('total_count') + ->get() + ->map(function ($row) { + $row->win_rate = $row->total_count > 0 + ? round(($row->won_count / $row->total_count) * 100, 1) + : 0; + return $row; + }); + + return Inertia::render('CRM/Reports/Source', [ + 'sources' => $sources, + ]); + } +} diff --git a/erp/app/Modules/CRM/Http/Controllers/CrmStageController.php b/erp/app/Modules/CRM/Http/Controllers/CrmStageController.php new file mode 100644 index 00000000000..da8339c2047 --- /dev/null +++ b/erp/app/Modules/CRM/Http/Controllers/CrmStageController.php @@ -0,0 +1,64 @@ +get(); + + return Inertia::render('CRM/Pipeline/Stages', [ + 'stages' => $stages, + ]); + } + + public function store(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'sequence' => 'required|integer|min:0', + 'type' => 'required|in:open,won,lost', + 'probability' => 'required|numeric|min:0|max:100', + 'color' => 'nullable|string|max:20', + 'is_active' => 'boolean', + ]); + + CrmStage::create([ + ...$validated, + 'tenant_id' => auth()->user()->tenant_id, + ]); + + return back()->with('success', 'Stage created.'); + } + + public function update(Request $request, CrmStage $stage): RedirectResponse + { + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'sequence' => 'required|integer|min:0', + 'type' => 'required|in:open,won,lost', + 'probability' => 'required|numeric|min:0|max:100', + 'color' => 'nullable|string|max:20', + 'is_active' => 'boolean', + ]); + + $stage->update($validated); + + return back()->with('success', 'Stage updated.'); + } + + public function destroy(CrmStage $stage): RedirectResponse + { + $stage->delete(); + + return back()->with('success', 'Stage deleted.'); + } +} diff --git a/erp/app/Modules/CRM/Http/Controllers/EmailSequenceController.php b/erp/app/Modules/CRM/Http/Controllers/EmailSequenceController.php new file mode 100644 index 00000000000..357b06567b1 --- /dev/null +++ b/erp/app/Modules/CRM/Http/Controllers/EmailSequenceController.php @@ -0,0 +1,108 @@ +orderByDesc('created_at') + ->get(); + + return Inertia::render('CRM/EmailSequences/Index', [ + 'sequences' => $sequences, + ]); + } + + public function show(EmailSequence $sequence): Response + { + $sequence->load(['steps', 'enrollments.lead']); + + return Inertia::render('CRM/EmailSequences/Show', [ + 'sequence' => $sequence, + 'leads' => CrmLead::where('status', 'open')->orderBy('contact_name')->get(['id', 'contact_name', 'email', 'company_name']), + ]); + } + + public function store(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'description' => 'nullable|string', + ]); + + $sequence = EmailSequence::create([ + ...$validated, + 'tenant_id' => auth()->user()->tenant_id, + ]); + + return redirect()->route('crm.sequences.show', $sequence)->with('success', 'Sequence created.'); + } + + public function storeStep(Request $request, EmailSequence $sequence): RedirectResponse + { + $validated = $request->validate([ + 'subject' => 'required|string|max:255', + 'body' => 'required|string', + 'delay_days' => 'nullable|integer|min:0', + ]); + + $stepNumber = $sequence->steps()->max('step_number') + 1; + + EmailSequenceStep::create([ + ...$validated, + 'tenant_id' => auth()->user()->tenant_id, + 'sequence_id' => $sequence->id, + 'step_number' => $stepNumber, + 'delay_days' => $validated['delay_days'] ?? 1, + ]); + + $sequence->increment('total_steps'); + + return back()->with('success', 'Step added.'); + } + + public function enroll(Request $request, EmailSequence $sequence): RedirectResponse + { + $validated = $request->validate([ + 'lead_id' => 'required|exists:crm_leads,id', + ]); + + $lead = CrmLead::findOrFail($validated['lead_id']); + $sequence->enrollLead($lead); + + return back()->with('success', 'Lead enrolled in sequence.'); + } + + public function pause(EmailSequence $sequence): RedirectResponse + { + $sequence->pause(); + + return back()->with('success', 'Sequence paused.'); + } + + public function activate(EmailSequence $sequence): RedirectResponse + { + $sequence->activate(); + + return back()->with('success', 'Sequence activated.'); + } + + public function unsubscribe(EmailSequenceEnrollment $enrollment): RedirectResponse + { + $enrollment->unsubscribe(); + + return back()->with('success', 'Lead unsubscribed from sequence.'); + } +} diff --git a/erp/app/Modules/CRM/Http/Controllers/LeadScoringController.php b/erp/app/Modules/CRM/Http/Controllers/LeadScoringController.php new file mode 100644 index 00000000000..241bad6620f --- /dev/null +++ b/erp/app/Modules/CRM/Http/Controllers/LeadScoringController.php @@ -0,0 +1,77 @@ +get(); + + return Inertia::render('CRM/LeadScoring/Rules', [ + 'rules' => $rules, + ]); + } + + public function storeRule(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'field' => 'required|in:source,stage,tag,email_open,email_click,website_visit', + 'condition_value' => 'nullable|string|max:255', + 'points' => 'required|integer', + ]); + + LeadScoringRule::create([ + ...$validated, + 'tenant_id' => auth()->user()->tenant_id, + ]); + + return back()->with('success', 'Scoring rule created.'); + } + + public function destroyRule(LeadScoringRule $rule): RedirectResponse + { + $rule->delete(); + + return back()->with('success', 'Rule deleted.'); + } + + public function scores(Request $request): Response + { + $leads = CrmLead::where('status', 'open') + ->orderBy('contact_name') + ->get() + ->map(fn ($lead) => [ + 'id' => $lead->id, + 'contact_name' => $lead->contact_name, + 'company_name' => $lead->company_name, + 'email' => $lead->email, + 'source' => $lead->source, + 'score' => LeadScoringRule::scoreForLead($lead), + ]) + ->sortByDesc('score') + ->values(); + + return Inertia::render('CRM/LeadScoring/Scores', [ + 'leads' => $leads, + ]); + } + + public function scoreForLead(CrmLead $lead): JsonResponse + { + return response()->json([ + 'lead_id' => $lead->id, + 'score' => LeadScoringRule::scoreForLead($lead), + ]); + } +} diff --git a/erp/app/Modules/CRM/Models/CrmActivity.php b/erp/app/Modules/CRM/Models/CrmActivity.php new file mode 100644 index 00000000000..81533615f0b --- /dev/null +++ b/erp/app/Modules/CRM/Models/CrmActivity.php @@ -0,0 +1,45 @@ + 'datetime', + 'completed_at' => 'datetime', + 'is_done' => 'boolean', + ]; + + protected $attributes = ['is_done' => false]; + + public function lead(): BelongsTo + { + return $this->belongsTo(CrmLead::class, 'lead_id'); + } + + public function assignee(): BelongsTo + { + return $this->belongsTo(User::class, 'assigned_to'); + } + + public function markDone(): void + { + $this->is_done = true; + $this->completed_at = now(); + $this->save(); + } +} diff --git a/erp/app/Modules/CRM/Models/CrmLead.php b/erp/app/Modules/CRM/Models/CrmLead.php new file mode 100644 index 00000000000..84275b8223d --- /dev/null +++ b/erp/app/Modules/CRM/Models/CrmLead.php @@ -0,0 +1,118 @@ + 'float', + 'probability' => 'float', + 'expected_close_date' => 'date', + 'won_at' => 'datetime', + 'lost_at' => 'datetime', + ]; + + protected $attributes = [ + 'type' => 'lead', + 'priority' => 'normal', + 'status' => 'open', + 'probability' => 0, + 'expected_revenue' => 0, + ]; + + public function stage(): BelongsTo + { + return $this->belongsTo(CrmStage::class, 'stage_id'); + } + + public function assignee(): BelongsTo + { + return $this->belongsTo(User::class, 'assigned_to'); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function activities(): HasMany + { + return $this->hasMany(CrmActivity::class, 'lead_id')->orderByDesc('scheduled_at'); + } + + public function markWon(): void + { + $this->status = 'won'; + $this->probability = 100; + $this->won_at = now(); + $this->save(); + event(new \App\Events\CRM\CrmDealWon($this)); + } + + public function markLost(string $reason = ''): void + { + $this->status = 'lost'; + $this->probability = 0; + $this->lost_reason = $reason; + $this->lost_at = now(); + $this->save(); + } + + public function convertToOpportunity(): void + { + $this->type = 'opportunity'; + if ($this->probability === 0.0) { + $this->probability = 10; + } + $this->save(); + } + + public function generateReference(): string + { + return 'CRM-' . date('Y') . '-' . str_pad((string) $this->id, 5, '0', STR_PAD_LEFT); + } + + protected function isWon(): Attribute + { + return Attribute::make(get: fn () => $this->status === 'won'); + } + + protected function isLost(): Attribute + { + return Attribute::make(get: fn () => $this->status === 'lost'); + } + + protected function isOpen(): Attribute + { + return Attribute::make(get: fn () => $this->status === 'open'); + } + + protected function priorityLabel(): Attribute + { + return Attribute::make(get: fn () => match ($this->priority) { + 'low' => 'Low', + 'high' => 'High', + 'urgent' => 'Urgent', + default => 'Normal', + }); + } +} diff --git a/erp/app/Modules/CRM/Models/CrmStage.php b/erp/app/Modules/CRM/Models/CrmStage.php new file mode 100644 index 00000000000..a656bbc131d --- /dev/null +++ b/erp/app/Modules/CRM/Models/CrmStage.php @@ -0,0 +1,34 @@ + 'float', + 'sequence' => 'integer', + 'is_active' => 'boolean', + ]; + + protected $attributes = [ + 'type' => 'open', + 'probability' => 0, + 'sequence' => 10, + 'is_active' => true, + ]; + + public function leads(): HasMany + { + return $this->hasMany(CrmLead::class, 'stage_id'); + } +} diff --git a/erp/app/Modules/CRM/Models/EmailSequence.php b/erp/app/Modules/CRM/Models/EmailSequence.php new file mode 100644 index 00000000000..d4459029a0a --- /dev/null +++ b/erp/app/Modules/CRM/Models/EmailSequence.php @@ -0,0 +1,58 @@ +hasMany(EmailSequenceStep::class, 'sequence_id')->orderBy('step_number'); + } + + public function enrollments(): HasMany + { + return $this->hasMany(EmailSequenceEnrollment::class, 'sequence_id'); + } + + public function pause(): void + { + $this->update(['status' => 'paused']); + } + + public function activate(): void + { + $this->update(['status' => 'active']); + } + + public function archive(): void + { + $this->update(['status' => 'archived']); + } + + public function enrollLead(CrmLead $lead): EmailSequenceEnrollment + { + return $this->enrollments()->firstOrCreate( + ['lead_id' => $lead->id], + [ + 'tenant_id' => $this->tenant_id, + 'current_step' => 0, + 'status' => 'active', + 'next_send_at' => now(), + ] + ); + } +} diff --git a/erp/app/Modules/CRM/Models/EmailSequenceEnrollment.php b/erp/app/Modules/CRM/Models/EmailSequenceEnrollment.php new file mode 100644 index 00000000000..f6f1ddecc97 --- /dev/null +++ b/erp/app/Modules/CRM/Models/EmailSequenceEnrollment.php @@ -0,0 +1,60 @@ + 'integer', + 'next_send_at' => 'datetime', + ]; + + public function sequence(): BelongsTo + { + return $this->belongsTo(EmailSequence::class, 'sequence_id'); + } + + public function lead(): BelongsTo + { + return $this->belongsTo(CrmLead::class, 'lead_id'); + } + + public function unsubscribe(): void + { + $this->update(['status' => 'unsubscribed']); + } + + public function advance(): void + { + $nextStep = $this->current_step + 1; + $totalSteps = $this->sequence->total_steps; + + if ($nextStep >= $totalSteps) { + $this->update(['status' => 'completed', 'current_step' => $nextStep]); + return; + } + + $step = $this->sequence->steps()->where('step_number', $nextStep)->first(); + $delay = $step ? $step->delay_days : 1; + + $this->update([ + 'current_step' => $nextStep, + 'next_send_at' => now()->addDays($delay), + ]); + } +} diff --git a/erp/app/Modules/CRM/Models/EmailSequenceStep.php b/erp/app/Modules/CRM/Models/EmailSequenceStep.php new file mode 100644 index 00000000000..94fae393e1f --- /dev/null +++ b/erp/app/Modules/CRM/Models/EmailSequenceStep.php @@ -0,0 +1,31 @@ + 'integer', + 'step_number' => 'integer', + ]; + + public function sequence(): BelongsTo + { + return $this->belongsTo(EmailSequence::class, 'sequence_id'); + } +} diff --git a/erp/app/Modules/CRM/Models/LeadScoringRule.php b/erp/app/Modules/CRM/Models/LeadScoringRule.php new file mode 100644 index 00000000000..bad3c9cd240 --- /dev/null +++ b/erp/app/Modules/CRM/Models/LeadScoringRule.php @@ -0,0 +1,48 @@ + 'integer', + 'is_active' => 'boolean', + ]; + + public function matchesLead(CrmLead $lead): bool + { + if (!$this->is_active) { + return false; + } + + return match ($this->field) { + 'source' => $lead->source === $this->condition_value, + 'stage' => (string) $lead->stage_id === $this->condition_value, + 'tag' => false, + default => false, + }; + } + + public static function scoreForLead(CrmLead $lead): int + { + return static::where('tenant_id', $lead->tenant_id) + ->where('is_active', true) + ->get() + ->filter(fn ($rule) => $rule->matchesLead($lead)) + ->sum('points'); + } +} diff --git a/erp/app/Modules/CRM/Policies/CrmPolicy.php b/erp/app/Modules/CRM/Policies/CrmPolicy.php new file mode 100644 index 00000000000..67e6283a1d2 --- /dev/null +++ b/erp/app/Modules/CRM/Policies/CrmPolicy.php @@ -0,0 +1,33 @@ +can('finance.create'); + } + + public function update(User $user): bool + { + return $user->can('finance.create'); + } + + public function delete(User $user): bool + { + return $user->can('finance.delete'); + } +} diff --git a/erp/app/Modules/CRM/Providers/CRMServiceProvider.php b/erp/app/Modules/CRM/Providers/CRMServiceProvider.php new file mode 100644 index 00000000000..3c6ea655558 --- /dev/null +++ b/erp/app/Modules/CRM/Providers/CRMServiceProvider.php @@ -0,0 +1,23 @@ +loadRoutesFrom(__DIR__ . '/../routes/crm.php'); + Gate::policy(CrmStage::class, CrmPolicy::class); + Gate::policy(CrmLead::class, CrmPolicy::class); + Gate::policy(CrmActivity::class, CrmPolicy::class); + } +} diff --git a/erp/app/Modules/CRM/routes/crm.php b/erp/app/Modules/CRM/routes/crm.php new file mode 100644 index 00000000000..0f53fe053c6 --- /dev/null +++ b/erp/app/Modules/CRM/routes/crm.php @@ -0,0 +1,59 @@ +prefix('crm')->name('crm.')->group(function () { + Route::get('dashboard', [CrmDashboardController::class, 'index'])->name('dashboard'); + + // Stages + Route::post('stages', [CrmStageController::class, 'store'])->name('stages.store'); + Route::put('stages/{stage}', [CrmStageController::class, 'update'])->name('stages.update'); + Route::delete('stages/{stage}', [CrmStageController::class, 'destroy'])->name('stages.destroy'); + Route::get('stages', [CrmStageController::class, 'index'])->name('stages.index'); + + // Pipeline Kanban (before leads resource) + Route::get('pipeline/kanban', [CrmLeadController::class, 'kanban'])->name('pipeline.kanban'); + Route::patch('leads/{lead}/move-stage', [CrmLeadController::class, 'moveStage'])->name('leads.move-stage'); + + // Leads — action routes first + Route::post('leads/{lead}/mark-won', [CrmLeadController::class, 'markWon'])->name('leads.mark-won'); + Route::post('leads/{lead}/mark-lost', [CrmLeadController::class, 'markLost'])->name('leads.mark-lost'); + Route::post('leads/{lead}/convert', [CrmLeadController::class, 'convert'])->name('leads.convert'); + Route::get('pipeline/kanban', [CrmLeadController::class, 'kanban'])->name('pipeline.kanban'); + Route::patch('leads/{lead}/move-stage', [CrmLeadController::class, 'moveStage'])->name('leads.move-stage'); + Route::resource('leads', CrmLeadController::class); + + // Activities (nested under leads + standalone actions) + Route::post('leads/{lead}/activities', [CrmActivityController::class, 'store'])->name('leads.activities.store'); + Route::post('activities/{activity}/mark-done', [CrmActivityController::class, 'markDone'])->name('activities.mark-done'); + Route::delete('activities/{activity}', [CrmActivityController::class, 'destroy'])->name('activities.destroy'); + + // Reports + Route::get('reports/pipeline', [CrmReportController::class, 'pipeline'])->name('reports.pipeline'); + Route::get('reports/win-loss', [CrmReportController::class, 'winLoss'])->name('reports.win-loss'); + Route::get('reports/source', [CrmReportController::class, 'source'])->name('reports.source'); + + // Email Sequences + Route::post('sequences/{sequence}/steps', [EmailSequenceController::class, 'storeStep'])->name('sequences.steps.store'); + Route::post('sequences/{sequence}/enroll', [EmailSequenceController::class, 'enroll'])->name('sequences.enroll'); + Route::post('sequences/{sequence}/pause', [EmailSequenceController::class, 'pause'])->name('sequences.pause'); + Route::post('sequences/{sequence}/activate', [EmailSequenceController::class, 'activate'])->name('sequences.activate'); + Route::post('enrollments/{enrollment}/unsubscribe', [EmailSequenceController::class, 'unsubscribe'])->name('enrollments.unsubscribe'); + Route::get('sequences/{sequence}', [EmailSequenceController::class, 'show'])->name('sequences.show'); + Route::get('sequences', [EmailSequenceController::class, 'index'])->name('sequences.index'); + Route::post('sequences', [EmailSequenceController::class, 'store'])->name('sequences.store'); + + // Lead Scoring + Route::get('scoring/rules', [LeadScoringController::class, 'rules'])->name('scoring.rules'); + Route::post('scoring/rules', [LeadScoringController::class, 'storeRule'])->name('scoring.rules.store'); + Route::delete('scoring/rules/{rule}', [LeadScoringController::class, 'destroyRule'])->name('scoring.rules.destroy'); + Route::get('scoring/scores', [LeadScoringController::class, 'scores'])->name('scoring.scores'); + Route::get('leads/{lead}/score', [LeadScoringController::class, 'scoreForLead'])->name('leads.score'); +}); diff --git a/erp/app/Modules/Core/Http/Controllers/AuditLogController.php b/erp/app/Modules/Core/Http/Controllers/AuditLogController.php new file mode 100644 index 00000000000..a04ed60f08e --- /dev/null +++ b/erp/app/Modules/Core/Http/Controllers/AuditLogController.php @@ -0,0 +1,37 @@ +authorize('viewAny', AuditLog::class); + + $logs = AuditLog::with('user') + ->when($request->action, fn ($q) => $q->where('action', $request->action)) + ->when($request->module, fn ($q) => $q->where('module', $request->module)) + ->when($request->user_id, fn ($q) => $q->where('user_id', $request->user_id)) + ->latest('created_at') + ->paginate(50) + ->withQueryString(); + + return Inertia::render('Core/AuditLogs/Index', [ + 'logs' => $logs, + 'filters' => $request->only(['action', 'module', 'user_id']), + ]); + } + + public function show(AuditLog $auditLog): Response + { + $this->authorize('view', $auditLog); + $auditLog->load('user'); + return Inertia::render('Core/AuditLogs/Show', ['log' => $auditLog]); + } +} diff --git a/erp/app/Modules/Core/Http/Controllers/CompanyController.php b/erp/app/Modules/Core/Http/Controllers/CompanyController.php new file mode 100644 index 00000000000..e654e6459ad --- /dev/null +++ b/erp/app/Modules/Core/Http/Controllers/CompanyController.php @@ -0,0 +1,126 @@ +authorize('viewAny', Company::class); + + $companies = Company::with('parent') + ->withCount('subsidiaries') + ->latest() + ->paginate(20) + ->withQueryString(); + + return Inertia::render('Core/Companies/Index', [ + 'companies' => $companies, + ]); + } + + public function create(): Response + { + $this->authorize('create', Company::class); + + $parents = Company::select('id', 'name', 'code')->get(); + + return Inertia::render('Core/Companies/Create', [ + 'parents' => $parents, + ]); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', Company::class); + + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'code' => 'nullable|string|max:20', + 'tax_id' => 'nullable|string|max:100', + 'currency_code' => 'nullable|string|size:3', + 'fiscal_year_start' => 'nullable|integer|min:1|max:12', + 'address' => 'nullable|string', + 'phone' => 'nullable|string|max:50', + 'email' => 'nullable|email|max:255', + 'website' => 'nullable|string|max:255', + 'industry' => 'nullable|string|max:100', + 'is_active' => 'boolean', + 'parent_company_id' => 'nullable|exists:companies,id', + ]); + + $validated['created_by'] = auth()->id(); + + Company::create($validated); + + return redirect()->route('core.companies.index') + ->with('success', 'Company created successfully.'); + } + + public function show(Company $company): Response + { + $this->authorize('view', $company); + + $company->load(['parent', 'subsidiaries', 'users']); + + return Inertia::render('Core/Companies/Show', [ + 'company' => $company, + ]); + } + + public function edit(Company $company): Response + { + $this->authorize('update', $company); + + $parents = Company::select('id', 'name', 'code') + ->where('id', '!=', $company->id) + ->get(); + + return Inertia::render('Core/Companies/Edit', [ + 'company' => $company, + 'parents' => $parents, + ]); + } + + public function update(Request $request, Company $company): RedirectResponse + { + $this->authorize('update', $company); + + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'code' => 'nullable|string|max:20', + 'tax_id' => 'nullable|string|max:100', + 'currency_code' => 'nullable|string|size:3', + 'fiscal_year_start' => 'nullable|integer|min:1|max:12', + 'address' => 'nullable|string', + 'phone' => 'nullable|string|max:50', + 'email' => 'nullable|email|max:255', + 'website' => 'nullable|string|max:255', + 'industry' => 'nullable|string|max:100', + 'is_active' => 'boolean', + 'parent_company_id' => 'nullable|exists:companies,id', + ]); + + $company->update($validated); + + return redirect()->route('core.companies.show', $company) + ->with('success', 'Company updated successfully.'); + } + + public function destroy(Company $company): RedirectResponse + { + $this->authorize('delete', $company); + + $company->delete(); + + return redirect()->route('core.companies.index') + ->with('success', 'Company deleted successfully.'); + } +} diff --git a/erp/app/Modules/Core/Http/Controllers/NotificationInboxController.php b/erp/app/Modules/Core/Http/Controllers/NotificationInboxController.php new file mode 100644 index 00000000000..7bd80ad3216 --- /dev/null +++ b/erp/app/Modules/Core/Http/Controllers/NotificationInboxController.php @@ -0,0 +1,65 @@ +user(); + + $notifications = NotificationInbox::where('user_id', $user->id) + ->orderByDesc('created_at') + ->paginate(20); + + $unreadCount = NotificationInbox::where('user_id', $user->id) + ->where('is_read', false) + ->count(); + + return Inertia::render('Core/Notifications/Index', [ + 'notifications' => $notifications, + 'unread_count' => $unreadCount, + ]); + } + + public function markRead(Request $request, NotificationInbox $notification): RedirectResponse + { + if ($notification->user_id !== $request->user()->id) { + abort(403); + } + + $notification->markRead(); + + return back()->with('success', 'Notification marked as read.'); + } + + public function markAllRead(Request $request): RedirectResponse + { + NotificationInbox::where('user_id', $request->user()->id) + ->where('is_read', false) + ->update([ + 'is_read' => true, + 'read_at' => now(), + ]); + + return back()->with('success', 'All notifications marked as read.'); + } + + public function destroy(Request $request, NotificationInbox $notification): RedirectResponse + { + if ($notification->user_id !== $request->user()->id) { + abort(403); + } + + $notification->delete(); + + return back()->with('success', 'Notification deleted.'); + } +} diff --git a/erp/app/Modules/Core/Http/Controllers/NotificationRuleController.php b/erp/app/Modules/Core/Http/Controllers/NotificationRuleController.php new file mode 100644 index 00000000000..4df415fffb4 --- /dev/null +++ b/erp/app/Modules/Core/Http/Controllers/NotificationRuleController.php @@ -0,0 +1,68 @@ +user()->id) + ->orderByDesc('created_at') + ->paginate(20); + + return Inertia::render('Core/NotificationRules/Index', [ + 'rules' => $rules, + ]); + } + + public function create(): Response + { + return Inertia::render('Core/NotificationRules/Create'); + } + + public function store(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'event_type' => ['required', 'string', 'max:100'], + 'conditions' => ['nullable', 'array'], + ]); + + NotificationRule::create(array_merge($validated, [ + 'user_id' => auth()->id(), + ])); + + return redirect()->route('notification-rules.index') + ->with('success', 'Notification rule created.'); + } + + public function destroy(Request $request, NotificationRule $notificationRule): RedirectResponse + { + if ($notificationRule->user_id !== $request->user()->id) { + abort(403); + } + + $notificationRule->delete(); + + return redirect()->route('notification-rules.index') + ->with('success', 'Notification rule deleted.'); + } + + public function toggle(Request $request, NotificationRule $notificationRule): RedirectResponse + { + if ($notificationRule->user_id !== $request->user()->id) { + abort(403); + } + + $notificationRule->update(['is_active' => ! $notificationRule->is_active]); + + return back()->with('success', 'Notification rule updated.'); + } +} diff --git a/erp/app/Modules/Core/Http/Controllers/SsoController.php b/erp/app/Modules/Core/Http/Controllers/SsoController.php new file mode 100644 index 00000000000..0d730f9d5d9 --- /dev/null +++ b/erp/app/Modules/Core/Http/Controllers/SsoController.php @@ -0,0 +1,152 @@ +where('tenant_id', app('tenant')->id) + ->get(); + + return Inertia::render('Settings/Sso', ['providers' => $providers]); + } + + // Create/update an SSO provider + public function store(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'provider_type' => 'required|in:saml,oauth2,oidc', + 'is_active' => 'boolean', + 'entity_id' => 'nullable|string|max:500', + 'sso_url' => 'nullable|url|max:500', + 'slo_url' => 'nullable|url|max:500', + 'idp_certificate' => 'nullable|string', + 'client_id' => 'nullable|string|max:500', + 'client_secret' => 'nullable|string|max:500', + 'authorization_url' => 'nullable|url|max:500', + 'token_url' => 'nullable|url|max:500', + 'userinfo_url' => 'nullable|url|max:500', + 'email_attribute' => 'nullable|string|max:100', + 'name_attribute' => 'nullable|string|max:100', + 'metadata_url' => 'nullable|url|max:500', + ]); + + SsoProvider::create(['tenant_id' => app('tenant')->id] + $validated); + + return redirect()->route('sso.configure')->with('success', 'SSO provider saved.'); + } + + public function update(Request $request, SsoProvider $provider): RedirectResponse + { + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'provider_type' => 'required|in:saml,oauth2,oidc', + 'is_active' => 'boolean', + 'entity_id' => 'nullable|string|max:500', + 'sso_url' => 'nullable|url|max:500', + 'slo_url' => 'nullable|url|max:500', + 'idp_certificate' => 'nullable|string', + 'client_id' => 'nullable|string|max:500', + 'client_secret' => 'nullable|string|max:500', + 'authorization_url' => 'nullable|url|max:500', + 'token_url' => 'nullable|url|max:500', + 'userinfo_url' => 'nullable|url|max:500', + 'email_attribute' => 'nullable|string|max:100', + 'name_attribute' => 'nullable|string|max:100', + 'metadata_url' => 'nullable|url|max:500', + ]); + + $provider->update($validated); + + return redirect()->route('sso.configure')->with('success', 'SSO provider updated.'); + } + + public function destroy(SsoProvider $provider): RedirectResponse + { + $provider->delete(); + + return redirect()->route('sso.configure')->with('success', 'SSO provider deleted.'); + } + + // Initiate SAML SSO — redirect to IdP + public function initiate(SsoProvider $provider): \Symfony\Component\HttpFoundation\Response + { + if (!$provider->is_active || $provider->provider_type !== 'saml') { + abort(400, 'This SSO provider is not active or is not a SAML provider.'); + } + + $authnRequest = $provider->buildAuthnRequest(); + $encoded = base64_encode($authnRequest); + $redirectUrl = $provider->sso_url . '?SAMLRequest=' . urlencode($encoded); + + return redirect($redirectUrl); + } + + // SAML Assertion Consumer Service — receive IdP response + public function acs(Request $request, SsoProvider $provider): RedirectResponse + { + if (!$provider->is_active) { + abort(400, 'SSO provider is not active.'); + } + + $samlResponse = $request->input('SAMLResponse'); + if (!$samlResponse) { + return redirect('/login')->withErrors(['sso' => 'No SAML response received.']); + } + + try { + $attributes = $provider->parseSamlResponse($samlResponse); + } catch (\Throwable $e) { + return redirect('/login')->withErrors(['sso' => 'SSO authentication failed: ' . $e->getMessage()]); + } + + // Find or create user + $user = User::firstOrCreate( + ['email' => $attributes['email']], + [ + 'name' => $attributes['name'], + 'tenant_id' => $provider->tenant_id, + 'password' => bcrypt(Str::random(32)), + ] + ); + + Auth::login($user, remember: true); + + return redirect('/dashboard'); + } + + // SP Metadata XML + public function metadata(SsoProvider $provider): Response + { + $entityId = htmlspecialchars($provider->getSpEntityId()); + $acsUrl = htmlspecialchars($provider->getAcsUrl()); + + $xml = << + + + + + +XML; + + return response($xml, 200, ['Content-Type' => 'application/xml']); + } +} diff --git a/erp/app/Modules/Core/Models/AuditLog.php b/erp/app/Modules/Core/Models/AuditLog.php new file mode 100644 index 00000000000..8265fa6aa10 --- /dev/null +++ b/erp/app/Modules/Core/Models/AuditLog.php @@ -0,0 +1,127 @@ + 'array', + 'new_values' => 'array', + 'created_at' => 'datetime', + ]; + + protected $dates = ['created_at']; + + public function auditable(): MorphTo + { + return $this->morphTo(); + } + + public function user(): BelongsTo + { + return $this->belongsTo(\App\Models\User::class); + } + + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + /** + * Record an audit log entry. + * + * Supports two call styles: + * record(string $action, $model, array $old, array $new, string $module) -- new style + * record(string $event, $model, array $old, array $new, int $tenantId) -- legacy style + * + * @param string $action + * @param Model|null $model + * @param array $oldValues + * @param array $newValues + * @param string|int|null $moduleOrTenantId + * @return static + */ + public static function record( + string $action, + $model = null, + array $oldValues = [], + array $newValues = [], + $moduleOrTenantId = null + ): static { + $tenantId = null; + $module = ''; + + if (is_int($moduleOrTenantId)) { + $tenantId = $moduleOrTenantId; + } elseif (is_string($moduleOrTenantId)) { + $module = $moduleOrTenantId; + } + + if ($tenantId === null) { + $tenantId = auth()->user()?->tenant_id ?? 0; + } + + return static::create([ + 'tenant_id' => $tenantId, + 'user_id' => auth()->id(), + 'event' => $action, + 'action' => $action, + 'auditable_type' => $model ? get_class($model) : null, + 'auditable_id' => $model?->getKey(), + 'auditable_label' => $model?->name ?? $model?->title ?? $model?->subject ?? null, + 'old_values' => $oldValues ?: null, + 'new_values' => $newValues ?: null, + 'ip_address' => request()?->ip(), + 'user_agent' => request()?->userAgent(), + 'url' => request()?->fullUrl(), + 'module' => $module, + 'created_at' => now(), + ]); + } + + /** + * Get a human-readable summary of changes. + */ + public function getChangeSummaryAttribute(): string + { + if ($this->old_values && $this->new_values) { + $changedKeys = array_keys(array_diff_assoc( + (array) $this->new_values, + (array) $this->old_values + )); + + if (empty($changedKeys)) { + $changedKeys = array_keys((array) $this->new_values); + } + + return implode(', ', $changedKeys) . ' changed'; + } + + return $this->action ?? $this->event ?? ''; + } +} diff --git a/erp/app/Modules/Core/Models/Company.php b/erp/app/Modules/Core/Models/Company.php new file mode 100644 index 00000000000..5fcf96363ab --- /dev/null +++ b/erp/app/Modules/Core/Models/Company.php @@ -0,0 +1,62 @@ + 'boolean', + 'fiscal_year_start' => 'integer', + ]; + + public function parent(): BelongsTo + { + return $this->belongsTo(Company::class, 'parent_company_id'); + } + + public function subsidiaries(): HasMany + { + return $this->hasMany(Company::class, 'parent_company_id'); + } + + public function users(): BelongsToMany + { + return $this->belongsToMany(User::class)->withPivot('is_default')->withTimestamps(); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + protected function fullName(): Attribute + { + return Attribute::make( + get: fn () => $this->code ? "{$this->name} ({$this->code})" : $this->name + ); + } + + protected function isParent(): Attribute + { + return Attribute::make( + get: fn () => is_null($this->parent_company_id) + ); + } +} diff --git a/erp/app/Modules/Core/Models/CustomFieldDefinition.php b/erp/app/Modules/Core/Models/CustomFieldDefinition.php new file mode 100644 index 00000000000..1cd6f77fb0c --- /dev/null +++ b/erp/app/Modules/Core/Models/CustomFieldDefinition.php @@ -0,0 +1,36 @@ + 'array', + 'required' => 'boolean', + 'is_active' => 'boolean', + 'sort_order' => 'integer', + ]; + + public function values(): HasMany + { + return $this->hasMany(CustomFieldValue::class, 'definition_id'); + } +} diff --git a/erp/app/Modules/Core/Models/CustomFieldValue.php b/erp/app/Modules/Core/Models/CustomFieldValue.php new file mode 100644 index 00000000000..1291b18ce72 --- /dev/null +++ b/erp/app/Modules/Core/Models/CustomFieldValue.php @@ -0,0 +1,22 @@ +belongsTo(CustomFieldDefinition::class, 'definition_id'); + } +} diff --git a/erp/app/Modules/Core/Models/ErpNotification.php b/erp/app/Modules/Core/Models/ErpNotification.php new file mode 100644 index 00000000000..03ce644b6f2 --- /dev/null +++ b/erp/app/Modules/Core/Models/ErpNotification.php @@ -0,0 +1,35 @@ + 'array', + 'read_at' => 'datetime', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function markAsRead(): void + { + $this->update(['read_at' => now()]); + } + + public function isRead(): bool + { + return $this->read_at !== null; + } +} diff --git a/erp/app/Modules/Core/Models/NotificationInbox.php b/erp/app/Modules/Core/Models/NotificationInbox.php new file mode 100644 index 00000000000..fbbf4accdc2 --- /dev/null +++ b/erp/app/Modules/Core/Models/NotificationInbox.php @@ -0,0 +1,71 @@ + 'boolean', + 'read_at' => 'datetime', + 'created_at' => 'datetime', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function markRead(): void + { + $this->is_read = true; + $this->read_at = now(); + $this->save(); + } + + public static function send( + int $tenantId, + int $userId, + string $title, + string $type, + ?string $body = null, + ?string $link = null + ): self { + $notification = new self(); + $notification->tenant_id = $tenantId; + $notification->user_id = $userId; + $notification->title = $title; + $notification->type = $type; + $notification->body = $body; + $notification->link = $link; + $notification->is_read = false; + $notification->created_at = now(); + $notification->save(); + + return $notification; + } +} diff --git a/erp/app/Modules/Core/Models/NotificationRule.php b/erp/app/Modules/Core/Models/NotificationRule.php new file mode 100644 index 00000000000..e8cf8fb2ac1 --- /dev/null +++ b/erp/app/Modules/Core/Models/NotificationRule.php @@ -0,0 +1,38 @@ + 'array', + 'is_active' => 'boolean', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function scopeActive(Builder $query): Builder + { + return $query->where('is_active', true); + } +} diff --git a/erp/app/Modules/Core/Models/SsoProvider.php b/erp/app/Modules/Core/Models/SsoProvider.php new file mode 100644 index 00000000000..561343ea74f --- /dev/null +++ b/erp/app/Modules/Core/Models/SsoProvider.php @@ -0,0 +1,90 @@ + 'boolean']; + + protected $hidden = ['client_secret']; + + // Generate the SP entity ID for this provider + public function getSpEntityId(): string + { + return url('/sso/saml/' . $this->id . '/metadata'); + } + + // Generate the ACS (Assertion Consumer Service) URL + public function getAcsUrl(): string + { + return url('/sso/saml/' . $this->id . '/acs'); + } + + // Build SAML AuthnRequest XML + public function buildAuthnRequest(): string + { + $id = '_' . bin2hex(random_bytes(16)); + $issueInstant = now()->format('Y-m-d\TH:i:s\Z'); + $acsUrl = htmlspecialchars($this->getAcsUrl()); + $entityId = htmlspecialchars($this->getSpEntityId()); + + return << + {$entityId} + + +XML; + } + + // Parse a SAML response XML and extract user attributes + // Returns ['email' => ..., 'name' => ...] or throws + public function parseSamlResponse(string $samlResponseBase64): array + { + $xml = base64_decode($samlResponseBase64); + $doc = new \DOMDocument(); + $doc->loadXML($xml, LIBXML_NOERROR | LIBXML_NOWARNING); + + $xpath = new \DOMXPath($doc); + $xpath->registerNamespace('saml', 'urn:oasis:names:tc:SAML:2.0:assertion'); + $xpath->registerNamespace('samlp', 'urn:oasis:names:tc:SAML:2.0:protocol'); + + // Extract status + $statusCode = $xpath->evaluate('string(//samlp:StatusCode/@Value)'); + if ($statusCode && !str_contains($statusCode, 'Success')) { + throw new \RuntimeException('SAML authentication failed: ' . $statusCode); + } + + // Extract email from NameID or attribute + $emailAttr = $this->email_attribute ?? 'email'; + $email = $xpath->evaluate("string(//saml:Attribute[@Name='{$emailAttr}']/saml:AttributeValue)"); + if (empty($email)) { + $email = $xpath->evaluate('string(//saml:NameID)'); + } + + // Extract name + $nameAttr = $this->name_attribute ?? 'displayName'; + $name = $xpath->evaluate("string(//saml:Attribute[@Name='{$nameAttr}']/saml:AttributeValue)"); + + if (empty($email)) { + throw new \RuntimeException('Could not extract email from SAML response.'); + } + + return ['email' => $email, 'name' => $name ?: $email]; + } +} diff --git a/erp/app/Modules/Core/Models/Tenant.php b/erp/app/Modules/Core/Models/Tenant.php new file mode 100644 index 00000000000..f56b1c63328 --- /dev/null +++ b/erp/app/Modules/Core/Models/Tenant.php @@ -0,0 +1,46 @@ + 'array', + 'is_active' => 'boolean', + ]; + + public function users(): HasMany + { + return $this->hasMany(\App\Models\User::class); + } + + public function auditLogs(): HasMany + { + return $this->hasMany(AuditLog::class); + } +} diff --git a/erp/app/Modules/Core/Models/TenantSetting.php b/erp/app/Modules/Core/Models/TenantSetting.php new file mode 100644 index 00000000000..508093fc125 --- /dev/null +++ b/erp/app/Modules/Core/Models/TenantSetting.php @@ -0,0 +1,29 @@ +belongsTo(Tenant::class); + } + + public static function getValue(int $tenantId, string $key, mixed $default = null): mixed + { + return static::where('tenant_id', $tenantId)->where('key', $key)->value('value') ?? $default; + } + + public static function setValue(int $tenantId, string $key, mixed $value): void + { + static::updateOrCreate( + ['tenant_id' => $tenantId, 'key' => $key], + ['value' => $value] + ); + } +} diff --git a/erp/app/Modules/Core/Observers/AuditLogObserver.php b/erp/app/Modules/Core/Observers/AuditLogObserver.php new file mode 100644 index 00000000000..1edd105b365 --- /dev/null +++ b/erp/app/Modules/Core/Observers/AuditLogObserver.php @@ -0,0 +1,66 @@ +log('created', $model, [], $model->getAttributes()); + } + + public function updated(Model $model): void + { + $dirty = $model->getDirty(); + if (empty($dirty)) { + return; + } + + $old = array_intersect_key($model->getOriginal(), $dirty); + $this->log('updated', $model, $old, $dirty); + } + + public function deleted(Model $model): void + { + $this->log('deleted', $model, $model->getOriginal(), []); + } + + private function log(string $action, Model $model, array $old, array $new): void + { + $tenantId = $this->resolveTenantId($model); + + AuditLog::create([ + 'user_id' => Auth::id(), + 'tenant_id' => $tenantId, + 'event' => $action, + 'action' => $action, + 'auditable_type' => get_class($model), + 'auditable_id' => $model->getKey(), + 'old_values' => $old ?: null, + 'new_values' => $new ?: null, + 'ip_address' => Request::ip(), + 'user_agent' => Request::userAgent(), + ]); + } + + private function resolveTenantId(Model $model): ?int + { + if (isset($model->tenant_id)) { + return $model->tenant_id; + } + + try { + /** @var \App\Modules\Core\Models\Tenant|null $tenant */ + $tenant = app()->has('tenant') ? app('tenant') : null; + + return $tenant?->id; + } catch (\Throwable) { + return null; + } + } +} diff --git a/erp/app/Modules/Core/Policies/AuditLogPolicy.php b/erp/app/Modules/Core/Policies/AuditLogPolicy.php new file mode 100644 index 00000000000..722c8ec5328 --- /dev/null +++ b/erp/app/Modules/Core/Policies/AuditLogPolicy.php @@ -0,0 +1,18 @@ +hasRole(['super-admin', 'admin']); + } + + public function view(User $user, $model): bool + { + return $user->hasRole(['super-admin', 'admin']); + } +} diff --git a/erp/app/Modules/Core/Policies/CompanyPolicy.php b/erp/app/Modules/Core/Policies/CompanyPolicy.php new file mode 100644 index 00000000000..82964a01804 --- /dev/null +++ b/erp/app/Modules/Core/Policies/CompanyPolicy.php @@ -0,0 +1,34 @@ +hasPermissionTo('admin') || $user->hasRole(['super-admin', 'admin']); + } + + public function update(User $user, Company $company): bool + { + return $user->hasPermissionTo('admin') || $user->hasRole(['super-admin', 'admin']); + } + + public function delete(User $user, Company $company): bool + { + return $user->hasPermissionTo('admin') || $user->hasRole(['super-admin', 'admin']); + } +} diff --git a/erp/app/Modules/Core/Providers/CoreServiceProvider.php b/erp/app/Modules/Core/Providers/CoreServiceProvider.php new file mode 100644 index 00000000000..35a47fe00bb --- /dev/null +++ b/erp/app/Modules/Core/Providers/CoreServiceProvider.php @@ -0,0 +1,92 @@ +app->register(InventoryServiceProvider::class); + $this->app->register(FinanceServiceProvider::class); + $this->app->register(HRServiceProvider::class); + $this->app->register(ManufacturingServiceProvider::class); + $this->app->register(CRMServiceProvider::class); + $this->app->register(PMServiceProvider::class); + $this->app->register(POSServiceProvider::class); + $this->app->register(HelpdeskServiceProvider::class); + $this->app->register(AccountingServiceProvider::class); + $this->app->register(FleetServiceProvider::class); + $this->app->register(MarketingServiceProvider::class); + $this->app->register(FieldServiceProvider::class); + $this->app->register(ApprovalsServiceProvider::class); + $this->app->register(EcommerceServiceProvider::class); + $this->app->register(DiscussServiceProvider::class); + $this->app->register(SubcontractingServiceProvider::class); + $this->app->register(RentalServiceProvider::class); + $this->app->register(SubscriptionsServiceProvider::class); + $this->app->register(SurveyServiceProvider::class); + $this->app->register(DocumentsServiceProvider::class); + $this->app->register(EventsServiceProvider::class); + $this->app->register(KnowledgeBaseServiceProvider::class); + $this->app->register(PlanningServiceProvider::class); + $this->app->register(SignServiceProvider::class); + $this->app->register(MaintenanceServiceProvider::class); + $this->app->register(QualityControlServiceProvider::class); + $this->app->register(LiveChatServiceProvider::class); + $this->app->register(RepairsServiceProvider::class); + $this->app->register(SocialMarketingServiceProvider::class); + $this->app->register(FrontdeskServiceProvider::class); + $this->app->register(LunchServiceProvider::class); + $this->app->register(WebsiteServiceProvider::class); + $this->app->register(AppointmentsServiceProvider::class); + $this->app->register(PurchaseServiceProvider::class); + } + + public function boot(): void + { + $this->loadRoutesFrom(__DIR__ . '/../routes/core.php'); + Gate::policy(AuditLog::class, AuditLogPolicy::class); + Gate::policy(Company::class, CompanyPolicy::class); + } +} diff --git a/erp/app/Modules/Core/Traits/Auditable.php b/erp/app/Modules/Core/Traits/Auditable.php new file mode 100644 index 00000000000..15aa83155f8 --- /dev/null +++ b/erp/app/Modules/Core/Traits/Auditable.php @@ -0,0 +1,45 @@ +getDirty(), + $model->tenant_id ?? null + ); + }); + + static::updated(function ($model) { + $dirty = $model->getDirty(); + if (empty($dirty)) { + return; + } + AuditLog::record( + 'updated', + $model, + $model->getOriginal(), + $dirty, + $model->tenant_id ?? null + ); + }); + + static::deleted(function ($model) { + AuditLog::record( + 'deleted', + $model, + $model->toArray(), + [], + $model->tenant_id ?? null + ); + }); + } +} diff --git a/erp/app/Modules/Core/Traits/BelongsToTenant.php b/erp/app/Modules/Core/Traits/BelongsToTenant.php new file mode 100644 index 00000000000..2369d641493 --- /dev/null +++ b/erp/app/Modules/Core/Traits/BelongsToTenant.php @@ -0,0 +1,32 @@ +has('tenant')) { + /** @var Tenant $tenant */ + $tenant = app('tenant'); + $query->where((new static())->getTable() . '.tenant_id', $tenant->id); + } + }); + + static::creating(function ($model) { + if (app()->has('tenant') && empty($model->tenant_id)) { + $model->tenant_id = app('tenant')->id; + } + }); + } + + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } +} diff --git a/erp/app/Modules/Core/Traits/HasAuditLog.php b/erp/app/Modules/Core/Traits/HasAuditLog.php new file mode 100644 index 00000000000..90cf8dce939 --- /dev/null +++ b/erp/app/Modules/Core/Traits/HasAuditLog.php @@ -0,0 +1,17 @@ + app(AuditLogObserver::class); + + static::created(fn ($model) => $observer()->created($model)); + static::updated(fn ($model) => $observer()->updated($model)); + static::deleted(fn ($model) => $observer()->deleted($model)); + } +} diff --git a/erp/app/Modules/Core/routes/core.php b/erp/app/Modules/Core/routes/core.php new file mode 100644 index 00000000000..1e783308568 --- /dev/null +++ b/erp/app/Modules/Core/routes/core.php @@ -0,0 +1,45 @@ +group(function () { + Route::get('/dashboard', [DashboardController::class, 'index'])->name('dashboard'); + + Route::get('/analytics', [AnalyticsController::class, 'index'])->name('analytics'); + + Route::get('/notifications', [NotificationInboxController::class, 'index'])->name('notifications.index'); + Route::post('/notifications/mark-all-read', [NotificationInboxController::class, 'markAllRead'])->name('notifications.mark-all-read'); + Route::patch('/notifications/{notification}/read', [NotificationInboxController::class, 'markRead'])->name('notifications.read'); + Route::delete('/notifications/{notification}', [NotificationInboxController::class, 'destroy'])->name('notifications.destroy'); + + Route::get('/search', SearchController::class)->name('search'); + + Route::prefix('export')->name('export.')->group(function () { + Route::get('products', [ExportController::class, 'products'])->name('products'); + Route::get('invoices', [ExportController::class, 'invoices'])->name('invoices'); + Route::get('employees', [ExportController::class, 'employees'])->name('employees'); + }); + + Route::get('/settings', [SettingController::class, 'edit'])->name('settings.edit'); + Route::put('/settings', [SettingController::class, 'update'])->name('settings.update'); + + Route::prefix('admin')->name('admin.')->group(function () { + Route::resource('users', UserController::class)->names('users'); + Route::get('audit-log', [AdminAuditLogController::class, 'index'])->name('audit-log.index'); + }); + + Route::middleware(['web', 'auth', 'verified'])->prefix('core')->name('core.')->group(function () { + Route::resource('audit-logs', AuditLogController::class)->only(['index', 'show']); + Route::resource('companies', CompanyController::class); + }); +}); diff --git a/erp/app/Modules/Discuss/Http/Controllers/DiscussController.php b/erp/app/Modules/Discuss/Http/Controllers/DiscussController.php new file mode 100644 index 00000000000..cfc92a3b8c5 --- /dev/null +++ b/erp/app/Modules/Discuss/Http/Controllers/DiscussController.php @@ -0,0 +1,174 @@ +id(); + $channels = DiscussChannel::where('is_archived', false) + ->where(function ($q) use ($userId) { + $q->where('type', 'public') + ->orWhereHas('members', fn ($m) => $m->where('user_id', $userId)); + }) + ->with(['creator']) + ->withCount('messages') + ->latest() + ->get() + ->map(fn ($ch) => [ + 'id' => $ch->id, + 'name' => $ch->name, + 'type' => $ch->type, + 'description' => $ch->description, + 'messages_count' => $ch->messages_count, + 'unread_count' => $ch->getUnreadCountFor($userId), + 'created_by' => $ch->creator?->name, + ]); + + return Inertia::render('Discuss/Index', ['channels' => $channels]); + } + + public function show(DiscussChannel $channel, Request $request): Response + { + $channel->markReadFor(auth()->id()); + + $messages = $channel->messages() + ->with(['user', 'replies.user']) + ->whereNull('parent_id') + ->latest() + ->take(50) + ->get() + ->reverse() + ->values() + ->map(fn ($m) => [ + 'id' => $m->id, + 'body' => $m->body, + 'is_edited' => $m->is_edited, + 'is_pinned' => $m->is_pinned, + 'created_at' => $m->created_at, + 'user' => ['id' => $m->user->id, 'name' => $m->user->name], + 'replies_count' => $m->replies()->count(), + ]); + + $members = $channel->members()->get()->map(fn ($u) => [ + 'id' => $u->id, + 'name' => $u->name, + ]); + + return Inertia::render('Discuss/Show', [ + 'channel' => [ + 'id' => $channel->id, + 'name' => $channel->name, + 'type' => $channel->type, + 'description' => $channel->description, + ], + 'messages' => $messages, + 'members' => $members, + ]); + } + + public function store(Request $request): RedirectResponse + { + $data = $request->validate([ + 'name' => 'required|string|max:100', + 'description' => 'nullable|string|max:500', + 'type' => 'required|in:public,private', + 'member_ids' => 'nullable|array', + 'member_ids.*' => 'exists:users,id', + ]); + + $channel = DiscussChannel::createPublic( + auth()->user()->tenant_id, + auth()->id(), + $data['name'], + $data['description'] ?? null, + ); + + if ($channel->type !== 'public' || !empty($data['member_ids'])) { + foreach ($data['member_ids'] ?? [] as $uid) { + if ((int) $uid !== auth()->id()) { + $channel->members()->syncWithoutDetaching([(int) $uid => ['last_read_at' => now()]]); + } + } + } + + return redirect()->route('discuss.show', $channel)->with('success', 'Channel created.'); + } + + public function sendMessage(Request $request, DiscussChannel $channel): JsonResponse + { + $data = $request->validate([ + 'body' => 'required|string|max:4000', + 'parent_id' => 'nullable|exists:discuss_messages,id', + ]); + + $message = DiscussMessage::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'channel_id' => $channel->id, + 'user_id' => auth()->id(), + 'body' => $data['body'], + 'parent_id' => $data['parent_id'] ?? null, + ]); + + $message->load('user'); + + broadcast(new NewDiscussMessage($message))->toOthers(); + + return response()->json([ + 'id' => $message->id, + 'body' => $message->body, + 'is_edited' => false, + 'is_pinned' => false, + 'created_at' => $message->created_at, + 'user' => ['id' => $message->user->id, 'name' => $message->user->name], + 'replies_count' => 0, + ], 201); + } + + public function editMessage(Request $request, DiscussChannel $channel, DiscussMessage $message): JsonResponse + { + abort_if($message->user_id !== auth()->id(), 403); + $data = $request->validate(['body' => 'required|string|max:4000']); + $message->edit($data['body']); + return response()->json(['ok' => true]); + } + + public function deleteMessage(DiscussChannel $channel, DiscussMessage $message): JsonResponse + { + abort_if($message->user_id !== auth()->id() && auth()->user()->cannot('manage-discuss'), 403); + $message->delete(); + return response()->json(['ok' => true]); + } + + public function joinChannel(DiscussChannel $channel): RedirectResponse + { + $channel->members()->syncWithoutDetaching([auth()->id() => ['last_read_at' => now()]]); + return redirect()->route('discuss.show', $channel)->with('success', 'Joined channel.'); + } + + public function leaveChannel(DiscussChannel $channel): RedirectResponse + { + $channel->members()->detach(auth()->id()); + return redirect()->route('discuss.index')->with('success', 'Left channel.'); + } + + public function users(): JsonResponse + { + $users = User::where('tenant_id', auth()->user()->tenant_id) + ->orderBy('name') + ->get(['id', 'name']); + return response()->json($users); + } +} diff --git a/erp/app/Modules/Discuss/Models/DiscussChannel.php b/erp/app/Modules/Discuss/Models/DiscussChannel.php new file mode 100644 index 00000000000..bf8f2592588 --- /dev/null +++ b/erp/app/Modules/Discuss/Models/DiscussChannel.php @@ -0,0 +1,81 @@ + 'boolean']; + + // Relations + + public function messages(): HasMany + { + return $this->hasMany(DiscussMessage::class, 'channel_id'); + } + + public function members(): BelongsToMany + { + return $this->belongsToMany(User::class, 'discuss_channel_members', 'channel_id', 'user_id') + ->withPivot('last_read_at') + ->withTimestamps(); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function latestMessage(): HasMany + { + return $this->hasMany(DiscussMessage::class, 'channel_id')->latest()->limit(1); + } + + // Helpers + + public static function createPublic(int $tenantId, int $creatorId, string $name, ?string $description = null): self + { + $channel = self::create([ + 'tenant_id' => $tenantId, + 'name' => $name, + 'slug' => Str::slug($name) . '-' . Str::random(6), + 'description' => $description, + 'type' => 'public', + 'created_by' => $creatorId, + ]); + + $channel->members()->attach($creatorId, ['last_read_at' => now()]); + + return $channel; + } + + public function getUnreadCountFor(int $userId): int + { + $member = $this->members()->where('user_id', $userId)->first(); + if (!$member) return 0; + $lastRead = $member->pivot->last_read_at; + if (!$lastRead) return $this->messages()->count(); + return $this->messages()->where('created_at', '>', $lastRead)->count(); + } + + public function markReadFor(int $userId): void + { + $this->members()->updateExistingPivot($userId, ['last_read_at' => now()]); + } +} diff --git a/erp/app/Modules/Discuss/Models/DiscussMessage.php b/erp/app/Modules/Discuss/Models/DiscussMessage.php new file mode 100644 index 00000000000..25099fa7ea2 --- /dev/null +++ b/erp/app/Modules/Discuss/Models/DiscussMessage.php @@ -0,0 +1,66 @@ + 'boolean', 'is_pinned' => 'boolean']; + + // Relations + + public function channel(): BelongsTo + { + return $this->belongsTo(DiscussChannel::class, 'channel_id'); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function parent(): BelongsTo + { + return $this->belongsTo(self::class, 'parent_id'); + } + + public function replies(): HasMany + { + return $this->hasMany(self::class, 'parent_id'); + } + + // Actions + + public function edit(string $newBody): void + { + $this->body = $newBody; + $this->is_edited = true; + $this->save(); + } + + public function pin(): void + { + $this->is_pinned = true; + $this->save(); + } + + public function unpin(): void + { + $this->is_pinned = false; + $this->save(); + } +} diff --git a/erp/app/Modules/Discuss/Providers/DiscussServiceProvider.php b/erp/app/Modules/Discuss/Providers/DiscussServiceProvider.php new file mode 100644 index 00000000000..bef1b1c5ebd --- /dev/null +++ b/erp/app/Modules/Discuss/Providers/DiscussServiceProvider.php @@ -0,0 +1,13 @@ +loadRoutesFrom(__DIR__ . '/../routes/discuss.php'); + } +} diff --git a/erp/app/Modules/Discuss/routes/discuss.php b/erp/app/Modules/Discuss/routes/discuss.php new file mode 100644 index 00000000000..3093399f145 --- /dev/null +++ b/erp/app/Modules/Discuss/routes/discuss.php @@ -0,0 +1,18 @@ +prefix('discuss')->name('discuss.')->group(function () { + Route::get('/', [DiscussController::class, 'index'])->name('index'); + Route::post('/', [DiscussController::class, 'store'])->name('store'); + Route::get('users', [DiscussController::class, 'users'])->name('users'); + + Route::get('{channel}', [DiscussController::class, 'show'])->name('show'); + Route::post('{channel}/join', [DiscussController::class, 'joinChannel'])->name('join'); + Route::delete('{channel}/leave', [DiscussController::class, 'leaveChannel'])->name('leave'); + + Route::post('{channel}/messages', [DiscussController::class, 'sendMessage'])->name('messages.store'); + Route::patch('{channel}/messages/{message}', [DiscussController::class, 'editMessage'])->name('messages.update'); + Route::delete('{channel}/messages/{message}', [DiscussController::class, 'deleteMessage'])->name('messages.destroy'); +}); diff --git a/erp/app/Modules/Documents/Http/Controllers/DocumentController.php b/erp/app/Modules/Documents/Http/Controllers/DocumentController.php new file mode 100644 index 00000000000..a9dec094d84 --- /dev/null +++ b/erp/app/Modules/Documents/Http/Controllers/DocumentController.php @@ -0,0 +1,242 @@ +when($request->folder_id, fn ($q) => $q->where('folder_id', $request->folder_id)) + ->when($request->search, function ($q) use ($request) { + $search = $request->search; + $q->where(function ($inner) use ($search) { + $inner->where('title', 'like', "%{$search}%") + ->orWhereJsonContains('tags', $search); + }); + }) + ->orderByDesc('created_at') + ->paginate(20) + ->withQueryString(); + + $folders = DocumentFolder::with('children') + ->whereNull('parent_id') + ->orderBy('name') + ->get(); + + return Inertia::render('Documents/Index', [ + 'documents' => $documents, + 'folders' => $folders, + 'filters' => $request->only(['folder_id', 'search']), + ]); + } + + public function folders(): Response + { + $folders = DocumentFolder::with('children') + ->whereNull('parent_id') + ->orderBy('name') + ->get() + ->map(function (DocumentFolder $folder) { + return [ + 'id' => $folder->id, + 'name' => $folder->name, + 'document_count' => $folder->documentCount(), + 'children' => $folder->children->map(function (DocumentFolder $child) { + return [ + 'id' => $child->id, + 'name' => $child->name, + 'document_count' => $child->documentCount(), + 'children' => [], + ]; + }), + ]; + }); + + return Inertia::render('Documents/Folders', [ + 'folders' => $folders, + ]); + } + + public function show(Document $document): Response + { + $document->load(['folder', 'uploader', 'versions.uploader']); + + return Inertia::render('Documents/Show', [ + 'document' => $document, + ]); + } + + public function store(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'title' => 'required|string|max:255', + 'description' => 'nullable|string', + 'folder_id' => 'nullable|exists:document_folders,id', + 'file_path' => 'required|string', + 'file_name' => 'nullable|string|max:255', + 'file_size' => 'nullable|integer', + 'mime_type' => 'nullable|string|max:255', + 'tags' => 'nullable|array', + ]); + + $document = Document::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'title' => $validated['title'], + 'description' => $validated['description'] ?? null, + 'folder_id' => $validated['folder_id'] ?? null, + 'file_path' => $validated['file_path'], + 'file_name' => $validated['file_name'] ?? basename($validated['file_path']), + 'file_size' => $validated['file_size'] ?? null, + 'mime_type' => $validated['mime_type'] ?? null, + 'tags' => $validated['tags'] ?? null, + 'version' => 1, + 'uploaded_by' => auth()->id(), + ]); + + return redirect()->route('documents.show', $document)->with('success', 'Document uploaded.'); + } + + public function uploadPage(): Response + { + $folders = DocumentFolder::orderBy('name')->get(['id', 'name']); + + return Inertia::render('Documents/Upload', [ + 'folders' => $folders, + ]); + } + + public function upload(Request $request): JsonResponse + { + $request->validate([ + 'file' => 'required|file|max:51200', + 'folder_id' => 'nullable|exists:document_folders,id', + ]); + + $file = $request->file('file'); + $tenant = auth()->user()->tenant_id; + $directory = "documents/{$tenant}/" . date('Y/m'); + $fileName = Str::uuid() . '.' . $file->getClientOriginalExtension(); + $path = $file->storeAs($directory, $fileName, 'public'); + + $document = Document::create([ + 'tenant_id' => $tenant, + 'title' => pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME), + 'folder_id' => $request->folder_id, + 'file_path' => $path, + 'file_name' => $file->getClientOriginalName(), + 'file_size' => $file->getSize(), + 'mime_type' => $file->getMimeType(), + 'version' => 1, + 'uploaded_by' => auth()->id(), + ]); + + return response()->json([ + 'id' => $document->id, + 'title' => $document->title, + 'file_name' => $document->file_name, + 'file_size' => $document->file_size, + 'url' => route('documents.show', $document), + ]); + } + + public function update(Request $request, Document $document): RedirectResponse + { + $validated = $request->validate([ + 'title' => 'sometimes|required|string|max:255', + 'description' => 'nullable|string', + 'folder_id' => 'nullable|exists:document_folders,id', + 'tags' => 'nullable|array', + ]); + + $document->update($validated); + + return redirect()->route('documents.show', $document)->with('success', 'Document updated.'); + } + + public function destroy(Document $document): RedirectResponse + { + $document->delete(); + + return redirect()->route('documents.index')->with('success', 'Document deleted.'); + } + + public function addVersion(Request $request, Document $document): RedirectResponse + { + $validated = $request->validate([ + 'file_path' => 'required|string', + 'file_name' => 'required|string|max:255', + 'file_size' => 'nullable|integer', + 'notes' => 'nullable|string', + ]); + + $document->addVersion( + filePath: $validated['file_path'], + fileName: $validated['file_name'], + fileSize: $validated['file_size'] ?? null, + uploadedBy: auth()->id(), + notes: $validated['notes'] ?? null, + ); + + return redirect()->route('documents.show', $document)->with('success', 'New version added.'); + } + + public function storeFolder(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'parent_id' => 'nullable|exists:document_folders,id', + ]); + + DocumentFolder::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'name' => $validated['name'], + 'parent_id' => $validated['parent_id'] ?? null, + 'created_by' => auth()->id(), + ]); + + return redirect()->back()->with('success', 'Folder created.'); + } + + public function destroyFolder(DocumentFolder $folder): RedirectResponse + { + if ($folder->documentCount() > 0) { + return redirect()->back()->withErrors(['folder' => 'Cannot delete a folder that contains documents.']); + } + + $folder->delete(); + + return redirect()->back()->with('success', 'Folder deleted.'); + } + + public function search(Request $request): Response + { + $query = $request->get('q', ''); + + $documents = Document::with(['folder', 'uploader']) + ->when($query, function ($q) use ($query) { + $q->where(function ($inner) use ($query) { + $inner->where('title', 'like', "%{$query}%") + ->orWhereJsonContains('tags', $query); + }); + }) + ->orderByDesc('created_at') + ->paginate(20) + ->withQueryString(); + + return Inertia::render('Documents/Search', [ + 'documents' => $documents, + 'query' => $query, + ]); + } +} diff --git a/erp/app/Modules/Documents/Models/Document.php b/erp/app/Modules/Documents/Models/Document.php new file mode 100644 index 00000000000..493744c9d47 --- /dev/null +++ b/erp/app/Modules/Documents/Models/Document.php @@ -0,0 +1,96 @@ + 'array', + ]; + + public function folder(): BelongsTo + { + return $this->belongsTo(DocumentFolder::class, 'folder_id'); + } + + public function uploader(): BelongsTo + { + return $this->belongsTo(User::class, 'uploaded_by'); + } + + public function versions(): HasMany + { + return $this->hasMany(DocumentVersion::class)->orderByDesc('version'); + } + + public function addVersion( + string $filePath, + string $fileName, + ?int $fileSize, + int $uploadedBy, + ?string $notes = null + ): DocumentVersion { + $this->version += 1; + $this->file_path = $filePath; + $this->file_name = $fileName; + $this->file_size = $fileSize; + $this->save(); + + return $this->versions()->create([ + 'document_id' => $this->id, + 'tenant_id' => $this->tenant_id, + 'version' => $this->version, + 'file_path' => $filePath, + 'file_name' => $fileName, + 'file_size' => $fileSize, + 'uploaded_by' => $uploadedBy, + 'notes' => $notes, + ]); + } + + public function fileSizeFormatted(): string + { + if ($this->file_size === null) { + return 'Unknown'; + } + + $bytes = $this->file_size; + + if ($bytes >= 1073741824) { + return round($bytes / 1073741824, 2) . ' GB'; + } + + if ($bytes >= 1048576) { + return round($bytes / 1048576, 2) . ' MB'; + } + + if ($bytes >= 1024) { + return round($bytes / 1024, 2) . ' KB'; + } + + return $bytes . ' B'; + } +} diff --git a/erp/app/Modules/Documents/Models/DocumentFolder.php b/erp/app/Modules/Documents/Models/DocumentFolder.php new file mode 100644 index 00000000000..f04e80f18cc --- /dev/null +++ b/erp/app/Modules/Documents/Models/DocumentFolder.php @@ -0,0 +1,42 @@ +belongsTo(self::class, 'parent_id'); + } + + public function children(): HasMany + { + return $this->hasMany(self::class, 'parent_id'); + } + + public function documents(): HasMany + { + return $this->hasMany(Document::class, 'folder_id'); + } + + public function documentCount(): int + { + return $this->documents()->count(); + } +} diff --git a/erp/app/Modules/Documents/Models/DocumentVersion.php b/erp/app/Modules/Documents/Models/DocumentVersion.php new file mode 100644 index 00000000000..9155acef833 --- /dev/null +++ b/erp/app/Modules/Documents/Models/DocumentVersion.php @@ -0,0 +1,34 @@ +belongsTo(Document::class); + } + + public function uploader(): BelongsTo + { + return $this->belongsTo(User::class, 'uploaded_by'); + } +} diff --git a/erp/app/Modules/Documents/Providers/DocumentsServiceProvider.php b/erp/app/Modules/Documents/Providers/DocumentsServiceProvider.php new file mode 100644 index 00000000000..f83802769db --- /dev/null +++ b/erp/app/Modules/Documents/Providers/DocumentsServiceProvider.php @@ -0,0 +1,16 @@ +loadRoutesFrom(__DIR__ . '/../routes/documents.php'); + $this->loadMigrationsFrom(__DIR__ . '/../../../database/migrations'); + } +} diff --git a/erp/app/Modules/Documents/routes/documents.php b/erp/app/Modules/Documents/routes/documents.php new file mode 100644 index 00000000000..e482eeda190 --- /dev/null +++ b/erp/app/Modules/Documents/routes/documents.php @@ -0,0 +1,18 @@ +prefix('documents')->name('documents.')->group(function () { + Route::get('folders', [DocumentController::class, 'folders'])->name('folders'); + Route::post('folders', [DocumentController::class, 'storeFolder'])->name('folders.store'); + Route::delete('folders/{folder}', [DocumentController::class, 'destroyFolder'])->name('folders.destroy'); + Route::get('search', [DocumentController::class, 'search'])->name('search'); + Route::post('{document}/versions', [DocumentController::class, 'addVersion'])->name('versions.store'); + Route::get('upload', [DocumentController::class, 'uploadPage'])->name('upload.page'); + Route::post('upload', [DocumentController::class, 'upload'])->name('upload'); + Route::get('', [DocumentController::class, 'index'])->name('index'); + Route::post('', [DocumentController::class, 'store'])->name('store'); + Route::get('{document}', [DocumentController::class, 'show'])->name('show'); + Route::patch('{document}', [DocumentController::class, 'update'])->name('update'); + Route::delete('{document}', [DocumentController::class, 'destroy'])->name('destroy'); +}); diff --git a/erp/app/Modules/Ecommerce/Http/Controllers/CartController.php b/erp/app/Modules/Ecommerce/Http/Controllers/CartController.php new file mode 100644 index 00000000000..696a8a35629 --- /dev/null +++ b/erp/app/Modules/Ecommerce/Http/Controllers/CartController.php @@ -0,0 +1,107 @@ +session()->getId(); + } + + public function index(Request $request, string $slug): Response + { + $store = StoreSettings::where('store_slug', $slug)->where('is_active', true)->firstOrFail(); + + $sessionKey = $this->getSessionKey($request); + $cartItems = StoreCart::getOrCreateForSession($sessionKey) + ->filter(fn ($item) => $item->tenant_id === null || $item->tenant_id === $store->tenant_id) + ->map(fn ($item) => [ + 'id' => $item->id, + 'quantity' => $item->quantity, + 'store_product_id' => $item->store_product_id, + 'product_name' => $item->product?->product?->name ?? 'Product', + 'product_sku' => $item->product?->product?->sku, + 'unit_price' => $item->product?->store_price ?? 0, + 'line_total' => ($item->product?->store_price ?? 0) * $item->quantity, + ])->values(); + + return Inertia::render('Ecommerce/Storefront/Cart', [ + 'store' => $store, + 'cartItems' => $cartItems, + ]); + } + + public function add(Request $request, string $slug): RedirectResponse + { + $store = StoreSettings::where('store_slug', $slug)->where('is_active', true)->firstOrFail(); + + $data = $request->validate([ + 'store_product_id' => 'required|exists:store_products,id', + 'quantity' => 'required|integer|min:1', + ]); + + $product = StoreProduct::where('id', $data['store_product_id']) + ->where('tenant_id', $store->tenant_id) + ->where('is_visible', true) + ->firstOrFail(); + + $sessionKey = $this->getSessionKey($request); + + $existing = StoreCart::where('session_key', $sessionKey) + ->where('store_product_id', $product->id) + ->first(); + + if ($existing) { + $existing->increment('quantity', $data['quantity']); + } else { + StoreCart::create([ + 'tenant_id' => $store->tenant_id, + 'session_key' => $sessionKey, + 'store_product_id' => $product->id, + 'quantity' => $data['quantity'], + ]); + } + + return back()->with('success', 'Item added to cart.'); + } + + public function update(Request $request, string $slug, StoreCart $cartItem): RedirectResponse + { + $data = $request->validate([ + 'quantity' => 'required|integer|min:0', + ]); + + if ($data['quantity'] === 0) { + $cartItem->delete(); + } else { + $cartItem->update(['quantity' => $data['quantity']]); + } + + return back()->with('success', 'Cart updated.'); + } + + public function remove(string $slug, StoreCart $cartItem): RedirectResponse + { + $cartItem->delete(); + + return back()->with('success', 'Item removed from cart.'); + } + + public function clear(Request $request, string $slug): RedirectResponse + { + $sessionKey = $this->getSessionKey($request); + StoreCart::where('session_key', $sessionKey)->delete(); + + return back()->with('success', 'Cart cleared.'); + } +} diff --git a/erp/app/Modules/Ecommerce/Http/Controllers/CouponController.php b/erp/app/Modules/Ecommerce/Http/Controllers/CouponController.php new file mode 100644 index 00000000000..dcf0e483654 --- /dev/null +++ b/erp/app/Modules/Ecommerce/Http/Controllers/CouponController.php @@ -0,0 +1,103 @@ +user()->tenant_id; + + $coupons = StoreCoupon::where('tenant_id', $tenantId) + ->orderByDesc('created_at') + ->paginate(20); + + return Inertia::render('Ecommerce/Coupons/Index', [ + 'coupons' => $coupons, + ]); + } + + public function store(Request $request): RedirectResponse + { + $tenantId = auth()->user()->tenant_id; + + $data = $request->validate([ + 'code' => ['required', 'string', 'max:50', + \Illuminate\Validation\Rule::unique('store_coupons')->where(fn ($q) => $q->where('tenant_id', $tenantId))->whereNull('deleted_at'), + ], + 'type' => 'required|in:fixed,percentage', + 'value' => 'required|numeric|min:0', + 'min_order_amount' => 'nullable|numeric|min:0', + 'max_uses' => 'nullable|integer|min:1', + 'valid_from' => 'nullable|date', + 'valid_until' => 'nullable|date|after_or_equal:valid_from', + 'is_active' => 'boolean', + ]); + + StoreCoupon::create(array_merge($data, ['tenant_id' => $tenantId])); + + return back()->with('success', 'Coupon created.'); + } + + public function destroy(StoreCoupon $coupon): RedirectResponse + { + $coupon->delete(); + + return back()->with('success', 'Coupon deleted.'); + } + + public function validate(Request $request, string $slug): JsonResponse + { + $store = StoreSettings::where('store_slug', $slug)->where('is_active', true)->firstOrFail(); + + $data = $request->validate([ + 'code' => 'required|string', + 'subtotal' => 'nullable|numeric|min:0', + ]); + + $coupon = StoreCoupon::where('tenant_id', $store->tenant_id) + ->where('code', strtoupper($data['code'])) + ->first(); + + if (! $coupon) { + $coupon = StoreCoupon::where('tenant_id', $store->tenant_id) + ->where('code', $data['code']) + ->first(); + } + + if (! $coupon || ! $coupon->isValid()) { + return response()->json([ + 'valid' => false, + 'discount_amount' => 0, + 'message' => 'Invalid or expired coupon code.', + ]); + } + + $subtotal = (float) ($data['subtotal'] ?? 0); + + if ($subtotal < $coupon->min_order_amount) { + return response()->json([ + 'valid' => false, + 'discount_amount' => 0, + 'message' => 'Order does not meet minimum amount for this coupon.', + ]); + } + + $discount = $coupon->applyTo($subtotal); + + return response()->json([ + 'valid' => true, + 'discount_amount' => $discount, + 'message' => 'Coupon applied successfully.', + ]); + } +} diff --git a/erp/app/Modules/Ecommerce/Http/Controllers/EcommerceDashboardController.php b/erp/app/Modules/Ecommerce/Http/Controllers/EcommerceDashboardController.php new file mode 100644 index 00000000000..ba02ecb6ced --- /dev/null +++ b/erp/app/Modules/Ecommerce/Http/Controllers/EcommerceDashboardController.php @@ -0,0 +1,65 @@ +user()->tenant_id; + + $totalOrders = StoreOrder::where('tenant_id', $tenantId)->count(); + + $ordersToday = StoreOrder::where('tenant_id', $tenantId) + ->whereDate('created_at', today()) + ->count(); + + $revenueToday = StoreOrder::where('tenant_id', $tenantId) + ->whereDate('created_at', today()) + ->where('payment_status', 'paid') + ->sum('total'); + + $pendingOrders = StoreOrder::where('tenant_id', $tenantId) + ->where('status', 'pending') + ->count(); + + $topProducts = StoreOrderItem::select('product_name', DB::raw('SUM(quantity) as order_count')) + ->whereHas('order', fn ($q) => $q->where('tenant_id', $tenantId)) + ->groupBy('product_name') + ->orderByDesc('order_count') + ->limit(5) + ->get(); + + $recentOrders = StoreOrder::where('tenant_id', $tenantId) + ->latest() + ->limit(10) + ->get() + ->map(fn ($o) => [ + 'id' => $o->id, + 'order_number' => $o->order_number, + 'customer_name' => $o->customer_name, + 'total' => $o->total, + 'status' => $o->status, + 'payment_status' => $o->payment_status, + 'created_at' => $o->created_at, + ]); + + return Inertia::render('Ecommerce/Dashboard', [ + 'stats' => [ + 'total_orders' => $totalOrders, + 'orders_today' => $ordersToday, + 'revenue_today' => (float) $revenueToday, + 'pending_orders' => $pendingOrders, + ], + 'topProducts' => $topProducts, + 'recentOrders' => $recentOrders, + ]); + } +} diff --git a/erp/app/Modules/Ecommerce/Http/Controllers/ReviewController.php b/erp/app/Modules/Ecommerce/Http/Controllers/ReviewController.php new file mode 100644 index 00000000000..1861adb38f3 --- /dev/null +++ b/erp/app/Modules/Ecommerce/Http/Controllers/ReviewController.php @@ -0,0 +1,80 @@ +where('is_active', true)->firstOrFail(); + + $data = $request->validate([ + 'reviewer_name' => 'required|string|max:255', + 'reviewer_email' => 'nullable|email|max:255', + 'rating' => 'required|integer|min:1|max:5', + 'title' => 'nullable|string|max:255', + 'body' => 'nullable|string', + ]); + + StoreReview::create(array_merge($data, [ + 'tenant_id' => $store->tenant_id, + 'store_product_id' => $storeProduct->id, + 'is_approved' => false, + ])); + + return back()->with('success', 'Review submitted. It will appear once approved.'); + } + + public function index(Request $request): Response + { + $tenantId = auth()->user()->tenant_id; + + $reviews = StoreReview::with('product.product') + ->where('tenant_id', $tenantId) + ->when($request->filled('rating'), fn ($q) => $q->where('rating', $request->rating)) + ->when($request->filled('is_approved'), fn ($q) => $q->where('is_approved', $request->boolean('is_approved'))) + ->orderByDesc('created_at') + ->paginate(20) + ->through(fn ($r) => [ + 'id' => $r->id, + 'reviewer_name' => $r->reviewer_name, + 'rating' => $r->rating, + 'title' => $r->title, + 'body' => $r->body, + 'is_approved' => $r->is_approved, + 'created_at' => $r->created_at, + 'product' => $r->product ? [ + 'id' => $r->product->id, + 'name' => $r->product->product?->name ?? 'Product', + ] : null, + ]); + + return Inertia::render('Ecommerce/Reviews/Index', [ + 'reviews' => $reviews, + 'filters' => $request->only(['rating', 'is_approved']), + ]); + } + + public function approve(StoreReview $review): RedirectResponse + { + $review->approve(); + + return back()->with('success', 'Review approved.'); + } + + public function destroy(StoreReview $review): RedirectResponse + { + $review->delete(); + + return back()->with('success', 'Review deleted.'); + } +} diff --git a/erp/app/Modules/Ecommerce/Http/Controllers/StoreCategoryController.php b/erp/app/Modules/Ecommerce/Http/Controllers/StoreCategoryController.php new file mode 100644 index 00000000000..79291827646 --- /dev/null +++ b/erp/app/Modules/Ecommerce/Http/Controllers/StoreCategoryController.php @@ -0,0 +1,88 @@ +user()->tenant_id; + + $categories = StoreCategory::where('tenant_id', $tenantId) + ->withCount('storeProducts') + ->orderBy('sort_order') + ->orderBy('name') + ->get() + ->map(fn ($c) => [ + 'id' => $c->id, + 'name' => $c->name, + 'slug' => $c->slug, + 'description' => $c->description, + 'parent_id' => $c->parent_id, + 'sort_order' => $c->sort_order, + 'is_active' => $c->is_active, + 'store_products_count' => $c->store_products_count, + ]); + + return Inertia::render('Ecommerce/Categories/Index', [ + 'categories' => $categories, + ]); + } + + public function store(Request $request): RedirectResponse + { + $tenantId = auth()->user()->tenant_id; + + $data = $request->validate([ + 'name' => 'required|string|max:255', + 'description' => 'nullable|string', + 'parent_id' => 'nullable|exists:store_categories,id', + 'sort_order' => 'nullable|integer|min:0', + 'is_active' => 'boolean', + ]); + + $slug = Str::slug($data['name']); + $originalSlug = $slug; + $counter = 1; + while (StoreCategory::where('tenant_id', $tenantId)->where('slug', $slug)->exists()) { + $slug = $originalSlug . '-' . $counter++; + } + + StoreCategory::create(array_merge($data, [ + 'tenant_id' => $tenantId, + 'slug' => $slug, + ])); + + return redirect()->back()->with('success', 'Category created.'); + } + + public function update(Request $request, StoreCategory $category): RedirectResponse + { + $data = $request->validate([ + 'name' => 'required|string|max:255', + 'description' => 'nullable|string', + 'parent_id' => 'nullable|exists:store_categories,id', + 'sort_order' => 'nullable|integer|min:0', + 'is_active' => 'boolean', + ]); + + $category->update($data); + + return redirect()->back()->with('success', 'Category updated.'); + } + + public function destroy(StoreCategory $category): RedirectResponse + { + $category->delete(); + + return redirect()->back()->with('success', 'Category deleted.'); + } +} diff --git a/erp/app/Modules/Ecommerce/Http/Controllers/StoreOrderController.php b/erp/app/Modules/Ecommerce/Http/Controllers/StoreOrderController.php new file mode 100644 index 00000000000..de24d1c6ee4 --- /dev/null +++ b/erp/app/Modules/Ecommerce/Http/Controllers/StoreOrderController.php @@ -0,0 +1,112 @@ +user()->tenant_id; + + $orders = StoreOrder::withCount('items') + ->where('tenant_id', $tenantId) + ->when($request->status, fn ($q) => $q->where('status', $request->status)) + ->when($request->payment_status, fn ($q) => $q->where('payment_status', $request->payment_status)) + ->latest() + ->paginate(25) + ->through(fn ($o) => [ + 'id' => $o->id, + 'order_number' => $o->order_number, + 'customer_name' => $o->customer_name, + 'customer_email' => $o->customer_email, + 'items_count' => $o->items_count, + 'total' => $o->total, + 'status' => $o->status, + 'payment_status' => $o->payment_status, + 'created_at' => $o->created_at, + ]); + + return Inertia::render('Ecommerce/Orders/Index', [ + 'orders' => $orders, + 'filters' => $request->only(['status', 'payment_status']), + ]); + } + + public function show(StoreOrder $order): Response + { + $order->load(['items.storeProduct.product', 'processedBy']); + + return Inertia::render('Ecommerce/Orders/Show', [ + 'order' => [ + 'id' => $order->id, + 'order_number' => $order->order_number, + 'status' => $order->status, + 'customer_name' => $order->customer_name, + 'customer_email' => $order->customer_email, + 'customer_phone' => $order->customer_phone, + 'shipping_address' => $order->shipping_address, + 'billing_address' => $order->billing_address, + 'subtotal' => $order->subtotal, + 'discount_amount' => $order->discount_amount, + 'shipping_amount' => $order->shipping_amount, + 'tax_amount' => $order->tax_amount, + 'total' => $order->total, + 'payment_method' => $order->payment_method, + 'payment_status' => $order->payment_status, + 'notes' => $order->notes, + 'processed_by' => $order->processedBy ? ['name' => $order->processedBy->name] : null, + 'created_at' => $order->created_at, + 'items' => $order->items->map(fn ($item) => [ + 'id' => $item->id, + 'product_name' => $item->product_name, + 'product_sku' => $item->product_sku, + 'quantity' => $item->quantity, + 'unit_price' => $item->unit_price, + 'line_total' => $item->line_total, + ]), + ], + ]); + } + + public function confirm(StoreOrder $order): RedirectResponse + { + $order->confirm(); + + return redirect()->back()->with('success', 'Order confirmed.'); + } + + public function markPaid(StoreOrder $order): RedirectResponse + { + $order->markPaid(); + + return redirect()->back()->with('success', 'Order marked as paid.'); + } + + public function ship(StoreOrder $order): RedirectResponse + { + $order->ship(); + + return redirect()->back()->with('success', 'Order marked as shipped.'); + } + + public function deliver(StoreOrder $order): RedirectResponse + { + $order->deliver(); + + return redirect()->back()->with('success', 'Order marked as delivered.'); + } + + public function cancel(StoreOrder $order): RedirectResponse + { + $order->cancel(); + + return redirect()->back()->with('success', 'Order cancelled.'); + } +} diff --git a/erp/app/Modules/Ecommerce/Http/Controllers/StoreProductController.php b/erp/app/Modules/Ecommerce/Http/Controllers/StoreProductController.php new file mode 100644 index 00000000000..37a40e3b147 --- /dev/null +++ b/erp/app/Modules/Ecommerce/Http/Controllers/StoreProductController.php @@ -0,0 +1,146 @@ +user()->tenant_id; + + $products = StoreProduct::with(['product', 'category']) + ->where('tenant_id', $tenantId) + ->when($request->category_id, fn ($q) => $q->where('category_id', $request->category_id)) + ->when($request->has('is_visible') && $request->is_visible !== '', fn ($q) => $q->where('is_visible', $request->boolean('is_visible'))) + ->when($request->has('is_featured') && $request->is_featured !== '', fn ($q) => $q->where('is_featured', $request->boolean('is_featured'))) + ->orderBy('sort_order') + ->paginate(20) + ->through(fn ($sp) => [ + 'id' => $sp->id, + 'store_price' => $sp->store_price, + 'compare_price' => $sp->compare_price, + 'is_featured' => $sp->is_featured, + 'is_visible' => $sp->is_visible, + 'sort_order' => $sp->sort_order, + 'product' => $sp->product ? [ + 'id' => $sp->product->id, + 'name' => $sp->product->name, + 'sku' => $sp->product->sku, + ] : null, + 'category' => $sp->category ? [ + 'id' => $sp->category->id, + 'name' => $sp->category->name, + ] : null, + ]); + + $categories = StoreCategory::where('tenant_id', $tenantId) + ->where('is_active', true) + ->orderBy('name') + ->get(['id', 'name']); + + return Inertia::render('Ecommerce/Products/Index', [ + 'storeProducts' => $products, + 'categories' => $categories, + 'filters' => $request->only(['category_id', 'is_visible', 'is_featured']), + ]); + } + + public function create(): Response + { + $tenantId = auth()->user()->tenant_id; + + $existingProductIds = StoreProduct::where('tenant_id', $tenantId)->pluck('product_id'); + + $inventoryProducts = Product::where('tenant_id', $tenantId) + ->whereNotIn('id', $existingProductIds) + ->where('is_active', true) + ->orderBy('name') + ->get(['id', 'name', 'sku', 'sale_price']); + + $categories = StoreCategory::where('tenant_id', $tenantId) + ->where('is_active', true) + ->orderBy('name') + ->get(['id', 'name']); + + return Inertia::render('Ecommerce/Products/Create', [ + 'inventoryProducts' => $inventoryProducts, + 'categories' => $categories, + ]); + } + + public function store(Request $request): RedirectResponse + { + $tenantId = auth()->user()->tenant_id; + + $data = $request->validate([ + 'product_id' => 'required|exists:products,id', + 'category_id' => 'nullable|exists:store_categories,id', + 'store_price' => 'required|numeric|min:0', + 'compare_price' => 'nullable|numeric|min:0', + 'is_featured' => 'boolean', + 'is_visible' => 'boolean', + 'sort_order' => 'nullable|integer|min:0', + 'short_description' => 'nullable|string', + 'long_description' => 'nullable|string', + 'meta_title' => 'nullable|string|max:255', + 'meta_description' => 'nullable|string', + ]); + + StoreProduct::create(array_merge($data, ['tenant_id' => $tenantId])); + + return redirect()->route('ecommerce.products.index')->with('success', 'Product added to store.'); + } + + public function edit(StoreProduct $product): Response + { + $tenantId = auth()->user()->tenant_id; + + $product->load(['product', 'category']); + + $categories = StoreCategory::where('tenant_id', $tenantId) + ->where('is_active', true) + ->orderBy('name') + ->get(['id', 'name']); + + return Inertia::render('Ecommerce/Products/Edit', [ + 'storeProduct' => $product, + 'categories' => $categories, + ]); + } + + public function update(Request $request, StoreProduct $product): RedirectResponse + { + $data = $request->validate([ + 'category_id' => 'nullable|exists:store_categories,id', + 'store_price' => 'required|numeric|min:0', + 'compare_price' => 'nullable|numeric|min:0', + 'is_featured' => 'boolean', + 'is_visible' => 'boolean', + 'sort_order' => 'nullable|integer|min:0', + 'short_description' => 'nullable|string', + 'long_description' => 'nullable|string', + 'meta_title' => 'nullable|string|max:255', + 'meta_description' => 'nullable|string', + ]); + + $product->update($data); + + return redirect()->route('ecommerce.products.index')->with('success', 'Store product updated.'); + } + + public function destroy(StoreProduct $product): RedirectResponse + { + $product->delete(); + + return redirect()->route('ecommerce.products.index')->with('success', 'Store product removed.'); + } +} diff --git a/erp/app/Modules/Ecommerce/Http/Controllers/StoreSettingsController.php b/erp/app/Modules/Ecommerce/Http/Controllers/StoreSettingsController.php new file mode 100644 index 00000000000..59e8db50332 --- /dev/null +++ b/erp/app/Modules/Ecommerce/Http/Controllers/StoreSettingsController.php @@ -0,0 +1,55 @@ +user()->tenant_id; + + $settings = StoreSettings::firstOrCreate( + ['tenant_id' => $tenantId], + [ + 'store_name' => 'My Store', + 'store_slug' => 'my-store-' . $tenantId, + 'currency_code' => 'USD', + 'is_active' => true, + 'primary_color' => '#4f46e5', + 'allow_guest_checkout' => true, + ] + ); + + return Inertia::render('Ecommerce/Settings', [ + 'settings' => $settings, + ]); + } + + public function update(Request $request): RedirectResponse + { + $tenantId = auth()->user()->tenant_id; + + $data = $request->validate([ + 'store_name' => 'required|string|max:255', + 'store_slug' => 'required|string|max:255|alpha_dash', + 'description' => 'nullable|string', + 'currency_code' => 'required|string|size:3', + 'is_active' => 'boolean', + 'logo_path' => 'nullable|string|max:255', + 'primary_color' => 'nullable|string|max:7', + 'allow_guest_checkout' => 'boolean', + ]); + + $settings = StoreSettings::where('tenant_id', $tenantId)->firstOrFail(); + $settings->update($data); + + return redirect()->back()->with('success', 'Store settings updated.'); + } +} diff --git a/erp/app/Modules/Ecommerce/Http/Controllers/StorefrontController.php b/erp/app/Modules/Ecommerce/Http/Controllers/StorefrontController.php new file mode 100644 index 00000000000..97a835bc66b --- /dev/null +++ b/erp/app/Modules/Ecommerce/Http/Controllers/StorefrontController.php @@ -0,0 +1,199 @@ +where('is_active', true) + ->firstOrFail(); + + $featuredProducts = StoreProduct::with('product') + ->where('tenant_id', $store->tenant_id) + ->where('is_featured', true) + ->where('is_visible', true) + ->orderBy('sort_order') + ->limit(12) + ->get() + ->map(fn ($sp) => [ + 'id' => $sp->id, + 'store_price' => $sp->store_price, + 'compare_price' => $sp->compare_price, + 'short_description' => $sp->short_description, + 'product' => $sp->product ? [ + 'name' => $sp->product->name, + 'sku' => $sp->product->sku, + ] : null, + ]); + + return Inertia::render('Ecommerce/Storefront/Index', [ + 'store' => $store, + 'featuredProducts' => $featuredProducts, + ]); + } + + public function products(Request $request, string $slug): Response + { + $store = StoreSettings::where('store_slug', $slug) + ->where('is_active', true) + ->firstOrFail(); + + $products = StoreProduct::with(['product', 'category']) + ->where('tenant_id', $store->tenant_id) + ->where('is_visible', true) + ->when($request->category_id, fn ($q) => $q->where('category_id', $request->category_id)) + ->orderBy('sort_order') + ->paginate(24) + ->through(fn ($sp) => [ + 'id' => $sp->id, + 'store_price' => $sp->store_price, + 'compare_price' => $sp->compare_price, + 'is_featured' => $sp->is_featured, + 'short_description' => $sp->short_description, + 'product' => $sp->product ? [ + 'name' => $sp->product->name, + 'sku' => $sp->product->sku, + ] : null, + 'category' => $sp->category ? ['name' => $sp->category->name] : null, + ]); + + return Inertia::render('Ecommerce/Storefront/Products', [ + 'store' => $store, + 'products' => $products, + 'filters' => $request->only(['category_id']), + ]); + } + + public function product(string $slug, StoreProduct $storeProduct): Response + { + $store = StoreSettings::where('store_slug', $slug) + ->where('is_active', true) + ->firstOrFail(); + + $storeProduct->load(['product', 'category']); + + return Inertia::render('Ecommerce/Storefront/Product', [ + 'store' => $store, + 'storeProduct' => [ + 'id' => $storeProduct->id, + 'store_price' => $storeProduct->store_price, + 'compare_price' => $storeProduct->compare_price, + 'is_featured' => $storeProduct->is_featured, + 'short_description' => $storeProduct->short_description, + 'long_description' => $storeProduct->long_description, + 'meta_title' => $storeProduct->meta_title, + 'meta_description' => $storeProduct->meta_description, + 'discount_percent' => $storeProduct->discountPercent(), + 'product' => $storeProduct->product ? [ + 'name' => $storeProduct->product->name, + 'sku' => $storeProduct->product->sku, + ] : null, + 'category' => $storeProduct->category ? ['name' => $storeProduct->category->name] : null, + ], + ]); + } + + public function checkout(Request $request, string $slug): Response + { + $store = StoreSettings::where('store_slug', $slug) + ->where('is_active', true) + ->firstOrFail(); + + $cartItems = $request->session()->get('cart_' . $slug, []); + + return Inertia::render('Ecommerce/Storefront/Checkout', [ + 'store' => $store, + 'cartItems' => $cartItems, + ]); + } + + public function placeOrder(Request $request, string $slug): \Inertia\Response + { + $store = StoreSettings::where('store_slug', $slug) + ->where('is_active', true) + ->firstOrFail(); + + $data = $request->validate([ + 'customer_name' => 'required|string|max:255', + 'customer_email' => 'required|email|max:255', + 'customer_phone' => 'nullable|string|max:50', + 'shipping_address' => 'nullable|string', + 'billing_address' => 'nullable|string', + 'notes' => 'nullable|string', + 'payment_method' => 'required|in:cash_on_delivery,bank_transfer,card', + 'items' => 'required|array|min:1', + 'items.*.store_product_id' => 'nullable|exists:store_products,id', + 'items.*.product_name' => 'required|string', + 'items.*.product_sku' => 'nullable|string', + 'items.*.quantity' => 'required|integer|min:1', + 'items.*.unit_price' => 'required|numeric|min:0', + 'items.*.line_total' => 'required|numeric|min:0', + ]); + + $subtotal = collect($data['items'])->sum('line_total'); + + $order = StoreOrder::create([ + 'tenant_id' => $store->tenant_id, + 'status' => 'pending', + 'customer_name' => $data['customer_name'], + 'customer_email' => $data['customer_email'], + 'customer_phone' => $data['customer_phone'] ?? null, + 'shipping_address' => $data['shipping_address'] ?? null, + 'billing_address' => $data['billing_address'] ?? null, + 'subtotal' => $subtotal, + 'discount_amount' => 0, + 'shipping_amount' => 0, + 'tax_amount' => 0, + 'total' => $subtotal, + 'payment_method' => $data['payment_method'], + 'payment_status' => 'pending', + 'notes' => $data['notes'] ?? null, + 'ip_address' => $request->ip(), + ]); + + $order->order_number = $order->generateOrderNumber(); + $order->save(); + + foreach ($data['items'] as $item) { + StoreOrderItem::create([ + 'order_id' => $order->id, + 'store_product_id' => $item['store_product_id'] ?? null, + 'product_name' => $item['product_name'], + 'product_sku' => $item['product_sku'] ?? null, + 'quantity' => $item['quantity'], + 'unit_price' => $item['unit_price'], + 'line_total' => $item['line_total'], + ]); + } + + return Inertia::render('Ecommerce/Storefront/OrderConfirmation', [ + 'store' => $store, + 'order' => [ + 'id' => $order->id, + 'order_number' => $order->order_number, + 'customer_name' => $order->customer_name, + 'customer_email' => $order->customer_email, + 'total' => $order->total, + 'payment_method' => $order->payment_method, + 'items' => $order->items->map(fn ($i) => [ + 'product_name' => $i->product_name, + 'quantity' => $i->quantity, + 'unit_price' => $i->unit_price, + 'line_total' => $i->line_total, + ]), + ], + ]); + } +} diff --git a/erp/app/Modules/Ecommerce/Models/StoreCart.php b/erp/app/Modules/Ecommerce/Models/StoreCart.php new file mode 100644 index 00000000000..47140de600e --- /dev/null +++ b/erp/app/Modules/Ecommerce/Models/StoreCart.php @@ -0,0 +1,29 @@ +belongsTo(StoreProduct::class, 'store_product_id'); + } + + public static function getOrCreateForSession(string $sessionKey): Collection + { + return static::where('session_key', $sessionKey)->with('product.product')->get(); + } +} diff --git a/erp/app/Modules/Ecommerce/Models/StoreCategory.php b/erp/app/Modules/Ecommerce/Models/StoreCategory.php new file mode 100644 index 00000000000..7d6b2901a8b --- /dev/null +++ b/erp/app/Modules/Ecommerce/Models/StoreCategory.php @@ -0,0 +1,44 @@ + 'boolean', + ]; + + public function parent(): BelongsTo + { + return $this->belongsTo(StoreCategory::class, 'parent_id'); + } + + public function children(): HasMany + { + return $this->hasMany(StoreCategory::class, 'parent_id'); + } + + public function storeProducts(): HasMany + { + return $this->hasMany(StoreProduct::class, 'category_id'); + } +} diff --git a/erp/app/Modules/Ecommerce/Models/StoreCoupon.php b/erp/app/Modules/Ecommerce/Models/StoreCoupon.php new file mode 100644 index 00000000000..dc9d0267737 --- /dev/null +++ b/erp/app/Modules/Ecommerce/Models/StoreCoupon.php @@ -0,0 +1,72 @@ + 'boolean', + 'valid_from' => 'date', + 'valid_until' => 'date', + ]; + + public function isValid(): bool + { + if (! $this->is_active) { + return false; + } + + $today = Carbon::today(); + + if ($this->valid_from !== null && $this->valid_from->gt($today)) { + return false; + } + + if ($this->valid_until !== null && $this->valid_until->lt($today)) { + return false; + } + + if ($this->max_uses !== null && $this->uses_count >= $this->max_uses) { + return false; + } + + return true; + } + + public function applyTo(float $subtotal): float + { + if ($this->type === 'percentage') { + return round($subtotal * $this->value / 100, 2); + } + + return min((float) $this->value, $subtotal); + } + + public function redeem(): void + { + $this->increment('uses_count'); + } +} diff --git a/erp/app/Modules/Ecommerce/Models/StoreOrder.php b/erp/app/Modules/Ecommerce/Models/StoreOrder.php new file mode 100644 index 00000000000..32bc22ec0cb --- /dev/null +++ b/erp/app/Modules/Ecommerce/Models/StoreOrder.php @@ -0,0 +1,96 @@ + 'float', + 'discount_amount' => 'float', + 'shipping_amount' => 'float', + 'tax_amount' => 'float', + 'total' => 'float', + ]; + + public function items(): HasMany + { + return $this->hasMany(StoreOrderItem::class, 'order_id'); + } + + public function processedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'processed_by'); + } + + public function generateOrderNumber(): string + { + return 'SO-' . date('Y') . '-' . str_pad((string) $this->id, 5, '0', STR_PAD_LEFT); + } + + public function confirm(): void + { + $this->status = 'confirmed'; + $this->save(); + } + + public function markPaid(): void + { + $this->payment_status = 'paid'; + $this->save(); + + if ($this->status === 'pending') { + $this->confirm(); + } + } + + public function ship(): void + { + $this->status = 'shipped'; + $this->save(); + } + + public function deliver(): void + { + $this->status = 'delivered'; + $this->save(); + } + + public function cancel(): void + { + $this->status = 'cancelled'; + $this->save(); + } +} diff --git a/erp/app/Modules/Ecommerce/Models/StoreOrderItem.php b/erp/app/Modules/Ecommerce/Models/StoreOrderItem.php new file mode 100644 index 00000000000..51d965b689b --- /dev/null +++ b/erp/app/Modules/Ecommerce/Models/StoreOrderItem.php @@ -0,0 +1,36 @@ + 'float', + 'line_total' => 'float', + ]; + + public function order(): BelongsTo + { + return $this->belongsTo(StoreOrder::class, 'order_id'); + } + + public function storeProduct(): BelongsTo + { + return $this->belongsTo(StoreProduct::class, 'store_product_id'); + } +} diff --git a/erp/app/Modules/Ecommerce/Models/StoreProduct.php b/erp/app/Modules/Ecommerce/Models/StoreProduct.php new file mode 100644 index 00000000000..5e0aa4f7b85 --- /dev/null +++ b/erp/app/Modules/Ecommerce/Models/StoreProduct.php @@ -0,0 +1,61 @@ + 'float', + 'compare_price' => 'float', + 'is_featured' => 'boolean', + 'is_visible' => 'boolean', + ]; + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class, 'product_id'); + } + + public function category(): BelongsTo + { + return $this->belongsTo(StoreCategory::class, 'category_id'); + } + + public function isOnSale(): bool + { + return $this->compare_price !== null && $this->compare_price > $this->store_price; + } + + public function discountPercent(): int + { + if (! $this->isOnSale()) { + return 0; + } + + return (int) round(($this->compare_price - $this->store_price) / $this->compare_price * 100); + } +} diff --git a/erp/app/Modules/Ecommerce/Models/StoreReview.php b/erp/app/Modules/Ecommerce/Models/StoreReview.php new file mode 100644 index 00000000000..ad06d167a3a --- /dev/null +++ b/erp/app/Modules/Ecommerce/Models/StoreReview.php @@ -0,0 +1,46 @@ + 'boolean', + ]; + + public function product(): BelongsTo + { + return $this->belongsTo(StoreProduct::class, 'store_product_id'); + } + + public function order(): BelongsTo + { + return $this->belongsTo(StoreOrder::class, 'order_id'); + } + + public function approve(): void + { + $this->is_approved = true; + $this->save(); + } +} diff --git a/erp/app/Modules/Ecommerce/Models/StoreSettings.php b/erp/app/Modules/Ecommerce/Models/StoreSettings.php new file mode 100644 index 00000000000..7f4e4290298 --- /dev/null +++ b/erp/app/Modules/Ecommerce/Models/StoreSettings.php @@ -0,0 +1,30 @@ + 'boolean', + 'allow_guest_checkout' => 'boolean', + ]; +} diff --git a/erp/app/Modules/Ecommerce/Policies/EcommercePolicy.php b/erp/app/Modules/Ecommerce/Policies/EcommercePolicy.php new file mode 100644 index 00000000000..4e349a09702 --- /dev/null +++ b/erp/app/Modules/Ecommerce/Policies/EcommercePolicy.php @@ -0,0 +1,33 @@ +can('inventory.create'); + } + + public function update(User $user): bool + { + return $user->can('inventory.create'); + } + + public function delete(User $user): bool + { + return $user->can('inventory.delete'); + } +} diff --git a/erp/app/Modules/Ecommerce/Providers/EcommerceServiceProvider.php b/erp/app/Modules/Ecommerce/Providers/EcommerceServiceProvider.php new file mode 100644 index 00000000000..f7feffc10df --- /dev/null +++ b/erp/app/Modules/Ecommerce/Providers/EcommerceServiceProvider.php @@ -0,0 +1,28 @@ +loadRoutesFrom(__DIR__ . '/../routes/ecommerce.php'); + + Gate::policy(StoreSettings::class, EcommercePolicy::class); + Gate::policy(StoreCategory::class, EcommercePolicy::class); + Gate::policy(StoreProduct::class, EcommercePolicy::class); + Gate::policy(StoreOrder::class, EcommercePolicy::class); + Gate::policy(StoreOrderItem::class, EcommercePolicy::class); + } +} diff --git a/erp/app/Modules/Ecommerce/routes/ecommerce.php b/erp/app/Modules/Ecommerce/routes/ecommerce.php new file mode 100644 index 00000000000..4e245887f3d --- /dev/null +++ b/erp/app/Modules/Ecommerce/routes/ecommerce.php @@ -0,0 +1,59 @@ +prefix('ecommerce')->name('ecommerce.')->group(function () { + Route::get('dashboard', [EcommerceDashboardController::class, 'index'])->name('dashboard'); + Route::get('settings', [StoreSettingsController::class, 'show'])->name('settings'); + Route::put('settings', [StoreSettingsController::class, 'update'])->name('settings.update'); + Route::resource('categories', StoreCategoryController::class)->except(['show', 'create', 'edit']); + Route::resource('products', StoreProductController::class)->except(['show']); + + // Order actions BEFORE resource + Route::post('orders/{order}/confirm', [StoreOrderController::class, 'confirm'])->name('orders.confirm'); + Route::post('orders/{order}/mark-paid', [StoreOrderController::class, 'markPaid'])->name('orders.mark-paid'); + Route::post('orders/{order}/ship', [StoreOrderController::class, 'ship'])->name('orders.ship'); + Route::post('orders/{order}/deliver', [StoreOrderController::class, 'deliver'])->name('orders.deliver'); + Route::post('orders/{order}/cancel', [StoreOrderController::class, 'cancel'])->name('orders.cancel'); + Route::resource('orders', StoreOrderController::class)->only(['index', 'show']); + + // Coupons + Route::get('coupons', [CouponController::class, 'index'])->name('coupons.index'); + Route::post('coupons', [CouponController::class, 'store'])->name('coupons.store'); + Route::delete('coupons/{coupon}', [CouponController::class, 'destroy'])->name('coupons.destroy'); + + // Reviews + Route::get('reviews', [ReviewController::class, 'index'])->name('reviews.index'); + Route::post('reviews/{review}/approve', [ReviewController::class, 'approve'])->name('reviews.approve'); + Route::delete('reviews/{review}', [ReviewController::class, 'destroy'])->name('reviews.destroy'); +}); + +// Public storefront (no auth) +Route::middleware('web')->prefix('store')->name('store.')->group(function () { + Route::get('{slug}', [StorefrontController::class, 'index'])->name('index'); + Route::get('{slug}/products', [StorefrontController::class, 'products'])->name('products'); + Route::get('{slug}/products/{storeProduct}', [StorefrontController::class, 'product'])->name('product'); + Route::get('{slug}/checkout', [StorefrontController::class, 'checkout'])->name('checkout'); + Route::post('{slug}/checkout', [StorefrontController::class, 'placeOrder'])->name('place-order'); + + // Cart + Route::prefix('{slug}')->group(function () { + Route::get('cart', [CartController::class, 'index'])->name('cart'); + Route::post('cart', [CartController::class, 'add'])->name('cart.add'); + Route::patch('cart/{cartItem}', [CartController::class, 'update'])->name('cart.update'); + Route::delete('cart/{cartItem}', [CartController::class, 'remove'])->name('cart.remove'); + Route::delete('cart', [CartController::class, 'clear'])->name('cart.clear'); + Route::post('coupon/validate', [CouponController::class, 'validate'])->name('coupon.validate'); + Route::post('products/{storeProduct}/reviews', [ReviewController::class, 'store'])->name('reviews.store'); + }); +}); diff --git a/erp/app/Modules/Events/Http/Controllers/EventController.php b/erp/app/Modules/Events/Http/Controllers/EventController.php new file mode 100644 index 00000000000..29dd1d68338 --- /dev/null +++ b/erp/app/Modules/Events/Http/Controllers/EventController.php @@ -0,0 +1,155 @@ +orderByDesc('starts_at') + ->paginate(20) + ->withQueryString(); + + return Inertia::render('Events/Index', [ + 'events' => $events, + ]); + } + + public function show(Event $event): Response + { + $event->load(['registrations', 'organizer']); + + return Inertia::render('Events/Show', [ + 'event' => $event, + ]); + } + + public function store(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'title' => 'required|string|max:255', + 'description' => 'nullable|string', + 'location' => 'nullable|string|max:255', + 'starts_at' => 'required|date', + 'ends_at' => 'nullable|date', + 'capacity' => 'nullable|integer|min:1', + 'organizer_id' => 'nullable|exists:users,id', + ]); + + $event = Event::create([ + ...$validated, + 'tenant_id' => auth()->user()->tenant_id, + 'status' => 'draft', + ]); + + return redirect()->route('events.show', $event)->with('success', 'Event created.'); + } + + public function update(Request $request, Event $event): RedirectResponse + { + abort_if($event->status !== 'draft', 403, 'Only draft events can be updated.'); + + $validated = $request->validate([ + 'title' => 'sometimes|required|string|max:255', + 'description' => 'nullable|string', + 'location' => 'nullable|string|max:255', + 'starts_at' => 'sometimes|required|date', + 'ends_at' => 'nullable|date', + 'capacity' => 'nullable|integer|min:1', + 'organizer_id' => 'nullable|exists:users,id', + ]); + + $event->update($validated); + + return redirect()->route('events.show', $event)->with('success', 'Event updated.'); + } + + public function destroy(Event $event): RedirectResponse + { + abort_if( + ! in_array($event->status, ['draft', 'cancelled']), + 403, + 'Only draft or cancelled events can be deleted.' + ); + + $event->delete(); + + return redirect()->route('events.index')->with('success', 'Event deleted.'); + } + + public function publish(Event $event): RedirectResponse + { + $event->publish(); + + return redirect()->back()->with('success', 'Event published.'); + } + + public function cancel(Event $event): RedirectResponse + { + $event->cancel(); + + return redirect()->back()->with('success', 'Event cancelled.'); + } + + public function register(Request $request, Event $event): RedirectResponse + { + abort_unless($event->isOpen(), 422, 'This event is not open for registration.'); + + if ($event->isFull()) { + abort(422, 'This event is full.'); + } + + $validated = $request->validate([ + 'attendee_name' => 'required|string|max:255', + 'attendee_email' => 'required|email|max:255', + 'notes' => 'nullable|string', + ]); + + EventRegistration::create([ + ...$validated, + 'event_id' => $event->id, + 'tenant_id' => $event->tenant_id, + 'status' => 'registered', + 'registered_at' => now(), + ]); + + return redirect()->back()->with('success', 'Registration successful.'); + } + + public function confirmRegistration(Event $event, EventRegistration $registration): RedirectResponse + { + abort_if($registration->event_id !== $event->id, 404); + + $registration->confirm(); + + return redirect()->back()->with('success', 'Registration confirmed.'); + } + + public function markAttended(Event $event, EventRegistration $registration): RedirectResponse + { + abort_if($registration->event_id !== $event->id, 404); + + $registration->markAttended(); + + return redirect()->back()->with('success', 'Marked as attended.'); + } + + public function cancelRegistration(Event $event, EventRegistration $registration): RedirectResponse + { + abort_if($registration->event_id !== $event->id, 404); + + $registration->cancel(); + + return redirect()->back()->with('success', 'Registration cancelled.'); + } +} diff --git a/erp/app/Modules/Events/Models/Event.php b/erp/app/Modules/Events/Models/Event.php new file mode 100644 index 00000000000..b09b7cc099e --- /dev/null +++ b/erp/app/Modules/Events/Models/Event.php @@ -0,0 +1,80 @@ + 'datetime', + 'ends_at' => 'datetime', + ]; + + public function registrations(): HasMany + { + return $this->hasMany(EventRegistration::class); + } + + public function organizer(): BelongsTo + { + return $this->belongsTo(User::class, 'organizer_id'); + } + + public function publish(): void + { + $this->status = 'published'; + $this->save(); + } + + public function cancel(): void + { + $this->status = 'cancelled'; + $this->save(); + } + + public function registrationCount(): int + { + return $this->registrations()->count(); + } + + public function availableSpots(): ?int + { + if ($this->capacity === null) { + return null; + } + + return $this->capacity - $this->registrationCount(); + } + + public function isFull(): bool + { + return $this->capacity !== null && $this->registrationCount() >= $this->capacity; + } + + public function isOpen(): bool + { + return $this->status === 'published' + && ! $this->isFull() + && ($this->ends_at === null || $this->ends_at->gt(now())); + } +} diff --git a/erp/app/Modules/Events/Models/EventRegistration.php b/erp/app/Modules/Events/Models/EventRegistration.php new file mode 100644 index 00000000000..af790323d0d --- /dev/null +++ b/erp/app/Modules/Events/Models/EventRegistration.php @@ -0,0 +1,49 @@ + 'datetime', + ]; + + public function event(): BelongsTo + { + return $this->belongsTo(Event::class); + } + + public function confirm(): void + { + $this->status = 'confirmed'; + $this->save(); + } + + public function markAttended(): void + { + $this->status = 'attended'; + $this->save(); + } + + public function cancel(): void + { + $this->status = 'cancelled'; + $this->save(); + } +} diff --git a/erp/app/Modules/Events/Providers/EventsServiceProvider.php b/erp/app/Modules/Events/Providers/EventsServiceProvider.php new file mode 100644 index 00000000000..3ccd90d685e --- /dev/null +++ b/erp/app/Modules/Events/Providers/EventsServiceProvider.php @@ -0,0 +1,16 @@ +loadRoutesFrom(__DIR__ . '/../routes/events.php'); + $this->loadMigrationsFrom(__DIR__ . '/../../../database/migrations'); + } +} diff --git a/erp/app/Modules/Events/routes/events.php b/erp/app/Modules/Events/routes/events.php new file mode 100644 index 00000000000..c00cbdce843 --- /dev/null +++ b/erp/app/Modules/Events/routes/events.php @@ -0,0 +1,18 @@ +prefix('events')->name('events.')->group(function () { + Route::post('{event}/publish', [EventController::class, 'publish'])->name('publish'); + Route::post('{event}/cancel', [EventController::class, 'cancel'])->name('cancel'); + Route::post('{event}/register', [EventController::class, 'register'])->name('register'); + Route::post('{event}/registrations/{registration}/confirm', [EventController::class, 'confirmRegistration'])->name('registrations.confirm'); + Route::post('{event}/registrations/{registration}/attend', [EventController::class, 'markAttended'])->name('registrations.attend'); + Route::post('{event}/registrations/{registration}/cancel', [EventController::class, 'cancelRegistration'])->name('registrations.cancel'); + Route::get('', [EventController::class, 'index'])->name('index'); + Route::post('', [EventController::class, 'store'])->name('store'); + Route::get('{event}', [EventController::class, 'show'])->name('show'); + Route::patch('{event}', [EventController::class, 'update'])->name('update'); + Route::delete('{event}', [EventController::class, 'destroy'])->name('destroy'); +}); diff --git a/erp/app/Modules/FieldService/Http/Controllers/FieldServiceDashboardController.php b/erp/app/Modules/FieldService/Http/Controllers/FieldServiceDashboardController.php new file mode 100644 index 00000000000..87629640e63 --- /dev/null +++ b/erp/app/Modules/FieldService/Http/Controllers/FieldServiceDashboardController.php @@ -0,0 +1,53 @@ +count(); + + $inProgressCount = ServiceOrder::where('status', 'in_progress')->count(); + + $completedTodayCount = ServiceOrder::where('status', 'completed') + ->whereDate('completed_at', today()) + ->count(); + + $overdueCount = ServiceOrder::where('scheduled_at', '<', now()) + ->whereNotIn('status', ['completed', 'cancelled']) + ->count(); + + // Orders by technician breakdown + $technicianBreakdown = User::whereHas('assignedServiceOrders', function ($q) { + $q->whereNotIn('status', ['completed', 'cancelled']); + }) + ->withCount(['assignedServiceOrders as active_orders_count' => function ($q) { + $q->whereNotIn('status', ['completed', 'cancelled']); + }]) + ->orderByDesc('active_orders_count') + ->get(['id', 'name']); + + $recentOrders = ServiceOrder::with('technician') + ->orderByDesc('created_at') + ->limit(10) + ->get(); + + return Inertia::render('FieldService/Dashboard', [ + 'stats' => [ + 'pending' => $pendingCount, + 'inProgress' => $inProgressCount, + 'completedToday' => $completedTodayCount, + 'overdue' => $overdueCount, + ], + 'technicianBreakdown' => $technicianBreakdown, + 'recentOrders' => $recentOrders, + ]); + } +} diff --git a/erp/app/Modules/FieldService/Http/Controllers/ServiceChecklistController.php b/erp/app/Modules/FieldService/Http/Controllers/ServiceChecklistController.php new file mode 100644 index 00000000000..e918d82ace7 --- /dev/null +++ b/erp/app/Modules/FieldService/Http/Controllers/ServiceChecklistController.php @@ -0,0 +1,92 @@ +orderBy('name') + ->get(); + + return Inertia::render('FieldService/Checklists/Index', [ + 'checklists' => $checklists, + ]); + } + + public function store(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'description' => 'nullable|string', + 'is_active' => 'boolean', + 'items' => 'nullable|array', + 'items.*.label' => 'required_with:items|string|max:255', + 'items.*.sequence' => 'nullable|integer|min:0', + ]); + + $checklist = ServiceChecklist::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'name' => $validated['name'], + 'description' => $validated['description'] ?? null, + 'is_active' => $validated['is_active'] ?? true, + ]); + + if (!empty($validated['items'])) { + foreach ($validated['items'] as $index => $item) { + $checklist->items()->create([ + 'label' => $item['label'], + 'sequence' => $item['sequence'] ?? $index, + ]); + } + } + + return redirect()->back()->with('success', 'Checklist created.'); + } + + public function update(Request $request, ServiceChecklist $checklist): RedirectResponse + { + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'description' => 'nullable|string', + 'is_active' => 'boolean', + 'items' => 'nullable|array', + 'items.*.label' => 'required_with:items|string|max:255', + 'items.*.sequence' => 'nullable|integer|min:0', + ]); + + $checklist->update([ + 'name' => $validated['name'], + 'description' => $validated['description'] ?? null, + 'is_active' => $validated['is_active'] ?? $checklist->is_active, + ]); + + // Sync items + $checklist->items()->delete(); + if (!empty($validated['items'])) { + foreach ($validated['items'] as $index => $item) { + $checklist->items()->create([ + 'label' => $item['label'], + 'sequence' => $item['sequence'] ?? $index, + ]); + } + } + + return redirect()->back()->with('success', 'Checklist updated.'); + } + + public function destroy(ServiceChecklist $checklist): RedirectResponse + { + $checklist->delete(); + + return redirect()->back()->with('success', 'Checklist deleted.'); + } +} diff --git a/erp/app/Modules/FieldService/Http/Controllers/ServiceOrderController.php b/erp/app/Modules/FieldService/Http/Controllers/ServiceOrderController.php new file mode 100644 index 00000000000..9f02c92cc68 --- /dev/null +++ b/erp/app/Modules/FieldService/Http/Controllers/ServiceOrderController.php @@ -0,0 +1,232 @@ +orderByDesc('created_at'); + + if ($request->filled('status')) { + $query->where('status', $request->status); + } + + if ($request->filled('priority')) { + $query->where('priority', $request->priority); + } + + if ($request->filled('assigned_to')) { + $query->where('assigned_to', $request->assigned_to); + } + + $orders = $query->paginate(25)->withQueryString(); + + return Inertia::render('FieldService/Orders/Index', [ + 'orders' => $orders, + 'filters' => $request->only(['status', 'priority', 'assigned_to']), + 'users' => User::orderBy('name')->get(['id', 'name']), + ]); + } + + public function create(): Response + { + return Inertia::render('FieldService/Orders/Create', [ + 'users' => User::orderBy('name')->get(['id', 'name']), + 'checklists' => ServiceChecklist::where('is_active', true)->get(['id', 'name']), + ]); + } + + public function store(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'title' => 'required|string|max:255', + 'description' => 'nullable|string', + 'type' => 'required|in:installation,repair,maintenance,inspection,other', + 'priority' => 'required|in:low,medium,high,urgent', + 'status' => 'nullable|in:pending,assigned,in_progress,on_hold,completed,cancelled', + 'customer_name' => 'nullable|string|max:255', + 'customer_email' => 'nullable|email|max:255', + 'customer_phone' => 'nullable|string|max:50', + 'address' => 'nullable|string', + 'scheduled_at' => 'nullable|date', + 'estimated_duration' => 'nullable|integer|min:0', + 'assigned_to' => 'nullable|exists:users,id', + 'notes' => 'nullable|string', + 'items' => 'nullable|array', + 'items.*.description' => 'required_with:items|string|max:255', + 'items.*.quantity' => 'required_with:items|numeric|min:0', + 'items.*.unit_price' => 'required_with:items|numeric|min:0', + 'items.*.line_total' => 'nullable|numeric|min:0', + ]); + + $order = ServiceOrder::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'title' => $validated['title'], + 'description' => $validated['description'] ?? null, + 'type' => $validated['type'], + 'priority' => $validated['priority'], + 'status' => $validated['status'] ?? 'pending', + 'customer_name' => $validated['customer_name'] ?? null, + 'customer_email' => $validated['customer_email'] ?? null, + 'customer_phone' => $validated['customer_phone'] ?? null, + 'address' => $validated['address'] ?? null, + 'scheduled_at' => $validated['scheduled_at'] ?? null, + 'estimated_duration' => $validated['estimated_duration'] ?? null, + 'assigned_to' => $validated['assigned_to'] ?? null, + 'notes' => $validated['notes'] ?? null, + 'created_by' => auth()->id(), + ]); + + $order->order_number = $order->generateOrderNumber(); + $order->save(); + + if (!empty($validated['items'])) { + foreach ($validated['items'] as $item) { + $order->items()->create([ + 'description' => $item['description'], + 'quantity' => $item['quantity'], + 'unit_price' => $item['unit_price'], + 'line_total' => $item['line_total'] ?? ($item['quantity'] * $item['unit_price']), + ]); + } + } + + return redirect()->route('field-service.orders.show', $order)->with('success', 'Service order created.'); + } + + public function show(ServiceOrder $order): Response + { + $order->load(['technician', 'items', 'checklistResults.checklistItem']); + + return Inertia::render('FieldService/Orders/Show', [ + 'order' => $order, + ]); + } + + public function edit(ServiceOrder $order): Response + { + $order->load(['items']); + + return Inertia::render('FieldService/Orders/Edit', [ + 'order' => $order, + 'users' => User::orderBy('name')->get(['id', 'name']), + 'checklists' => ServiceChecklist::where('is_active', true)->get(['id', 'name']), + ]); + } + + public function update(Request $request, ServiceOrder $order): RedirectResponse + { + $validated = $request->validate([ + 'title' => 'required|string|max:255', + 'description' => 'nullable|string', + 'type' => 'required|in:installation,repair,maintenance,inspection,other', + 'priority' => 'required|in:low,medium,high,urgent', + 'status' => 'nullable|in:pending,assigned,in_progress,on_hold,completed,cancelled', + 'customer_name' => 'nullable|string|max:255', + 'customer_email' => 'nullable|email|max:255', + 'customer_phone' => 'nullable|string|max:50', + 'address' => 'nullable|string', + 'scheduled_at' => 'nullable|date', + 'estimated_duration' => 'nullable|integer|min:0', + 'assigned_to' => 'nullable|exists:users,id', + 'notes' => 'nullable|string', + 'items' => 'nullable|array', + 'items.*.description' => 'required_with:items|string|max:255', + 'items.*.quantity' => 'required_with:items|numeric|min:0', + 'items.*.unit_price' => 'required_with:items|numeric|min:0', + 'items.*.line_total' => 'nullable|numeric|min:0', + ]); + + $order->update([ + 'title' => $validated['title'], + 'description' => $validated['description'] ?? null, + 'type' => $validated['type'], + 'priority' => $validated['priority'], + 'status' => $validated['status'] ?? $order->status, + 'customer_name' => $validated['customer_name'] ?? null, + 'customer_email' => $validated['customer_email'] ?? null, + 'customer_phone' => $validated['customer_phone'] ?? null, + 'address' => $validated['address'] ?? null, + 'scheduled_at' => $validated['scheduled_at'] ?? null, + 'estimated_duration' => $validated['estimated_duration'] ?? null, + 'assigned_to' => $validated['assigned_to'] ?? null, + 'notes' => $validated['notes'] ?? null, + ]); + + // Sync items + $order->items()->delete(); + if (!empty($validated['items'])) { + foreach ($validated['items'] as $item) { + $order->items()->create([ + 'description' => $item['description'], + 'quantity' => $item['quantity'], + 'unit_price' => $item['unit_price'], + 'line_total' => $item['line_total'] ?? ($item['quantity'] * $item['unit_price']), + ]); + } + } + + return redirect()->route('field-service.orders.show', $order)->with('success', 'Service order updated.'); + } + + public function destroy(ServiceOrder $order): RedirectResponse + { + $order->delete(); + + return redirect()->route('field-service.orders.index')->with('success', 'Service order deleted.'); + } + + public function start(ServiceOrder $order): RedirectResponse + { + $order->start(); + + return redirect()->back()->with('success', 'Service order started.'); + } + + public function complete(ServiceOrder $order): RedirectResponse + { + $order->complete(); + + return redirect()->back()->with('success', 'Service order completed.'); + } + + public function cancel(ServiceOrder $order): RedirectResponse + { + $order->cancel(); + + return redirect()->back()->with('success', 'Service order cancelled.'); + } + + public function updateChecklist(Request $request, ServiceOrder $order): RedirectResponse + { + $validated = $request->validate([ + 'results' => 'required|array', + 'results.*.checklist_item_id' => 'required|exists:service_checklist_items,id', + 'results.*.is_checked' => 'required|boolean', + 'results.*.notes' => 'nullable|string|max:255', + ]); + + foreach ($validated['results'] as $result) { + $order->checklistResults()->updateOrCreate( + ['checklist_item_id' => $result['checklist_item_id']], + [ + 'is_checked' => $result['is_checked'], + 'notes' => $result['notes'] ?? null, + ] + ); + } + + return redirect()->back()->with('success', 'Checklist updated.'); + } +} diff --git a/erp/app/Modules/FieldService/Models/ServiceChecklist.php b/erp/app/Modules/FieldService/Models/ServiceChecklist.php new file mode 100644 index 00000000000..505ed57ec4f --- /dev/null +++ b/erp/app/Modules/FieldService/Models/ServiceChecklist.php @@ -0,0 +1,28 @@ + 'boolean', + ]; + + public function items(): HasMany + { + return $this->hasMany(ServiceChecklistItem::class, 'checklist_id')->orderBy('sequence'); + } +} diff --git a/erp/app/Modules/FieldService/Models/ServiceChecklistItem.php b/erp/app/Modules/FieldService/Models/ServiceChecklistItem.php new file mode 100644 index 00000000000..024cd7766c5 --- /dev/null +++ b/erp/app/Modules/FieldService/Models/ServiceChecklistItem.php @@ -0,0 +1,20 @@ +belongsTo(ServiceChecklist::class, 'checklist_id'); + } +} diff --git a/erp/app/Modules/FieldService/Models/ServiceOrder.php b/erp/app/Modules/FieldService/Models/ServiceOrder.php new file mode 100644 index 00000000000..8bf4e487ca2 --- /dev/null +++ b/erp/app/Modules/FieldService/Models/ServiceOrder.php @@ -0,0 +1,98 @@ + 'datetime', + 'started_at' => 'datetime', + 'completed_at' => 'datetime', + 'estimated_duration' => 'integer', + 'actual_duration' => 'integer', + ]; + + public function technician(): BelongsTo + { + return $this->belongsTo(User::class, 'assigned_to'); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function items(): HasMany + { + return $this->hasMany(ServiceOrderItem::class, 'service_order_id'); + } + + public function checklistResults(): HasMany + { + return $this->hasMany(ServiceOrderChecklistResult::class, 'service_order_id'); + } + + public function generateOrderNumber(): string + { + return 'FS-' . date('Y') . '-' . str_pad((string) $this->id, 5, '0', STR_PAD_LEFT); + } + + public function start(): void + { + $this->status = 'in_progress'; + $this->started_at = now(); + $this->save(); + } + + public function complete(): void + { + $this->status = 'completed'; + $this->completed_at = now(); + $this->actual_duration = $this->started_at + ? (int) now()->diffInMinutes($this->started_at) + : null; + $this->save(); + } + + public function cancel(): void + { + $this->status = 'cancelled'; + $this->save(); + } + + public function totalAmount(): float + { + return (float) $this->items()->sum('line_total'); + } +} diff --git a/erp/app/Modules/FieldService/Models/ServiceOrderChecklistResult.php b/erp/app/Modules/FieldService/Models/ServiceOrderChecklistResult.php new file mode 100644 index 00000000000..3db4dcc42ac --- /dev/null +++ b/erp/app/Modules/FieldService/Models/ServiceOrderChecklistResult.php @@ -0,0 +1,30 @@ + 'boolean', + ]; + + public function order(): BelongsTo + { + return $this->belongsTo(ServiceOrder::class, 'service_order_id'); + } + + public function checklistItem(): BelongsTo + { + return $this->belongsTo(ServiceChecklistItem::class, 'checklist_item_id'); + } +} diff --git a/erp/app/Modules/FieldService/Models/ServiceOrderItem.php b/erp/app/Modules/FieldService/Models/ServiceOrderItem.php new file mode 100644 index 00000000000..c2bca83fa37 --- /dev/null +++ b/erp/app/Modules/FieldService/Models/ServiceOrderItem.php @@ -0,0 +1,28 @@ + 'float', + 'unit_price' => 'float', + 'line_total' => 'float', + ]; + + public function order(): BelongsTo + { + return $this->belongsTo(ServiceOrder::class, 'service_order_id'); + } +} diff --git a/erp/app/Modules/FieldService/Policies/FieldServicePolicy.php b/erp/app/Modules/FieldService/Policies/FieldServicePolicy.php new file mode 100644 index 00000000000..5c0dfade7ba --- /dev/null +++ b/erp/app/Modules/FieldService/Policies/FieldServicePolicy.php @@ -0,0 +1,33 @@ +can('inventory.create'); + } + + public function update(User $user): bool + { + return $user->can('inventory.create'); + } + + public function delete(User $user): bool + { + return $user->can('inventory.delete'); + } +} diff --git a/erp/app/Modules/FieldService/Providers/FieldServiceProvider.php b/erp/app/Modules/FieldService/Providers/FieldServiceProvider.php new file mode 100644 index 00000000000..b91aa6475e3 --- /dev/null +++ b/erp/app/Modules/FieldService/Providers/FieldServiceProvider.php @@ -0,0 +1,27 @@ +loadRoutesFrom(__DIR__ . '/../routes/field_service.php'); + Gate::policy(ServiceOrder::class, FieldServicePolicy::class); + Gate::policy(ServiceOrderItem::class, FieldServicePolicy::class); + Gate::policy(ServiceChecklist::class, FieldServicePolicy::class); + Gate::policy(ServiceChecklistItem::class, FieldServicePolicy::class); + Gate::policy(ServiceOrderChecklistResult::class, FieldServicePolicy::class); + } +} diff --git a/erp/app/Modules/FieldService/routes/field_service.php b/erp/app/Modules/FieldService/routes/field_service.php new file mode 100644 index 00000000000..c0990d46aab --- /dev/null +++ b/erp/app/Modules/FieldService/routes/field_service.php @@ -0,0 +1,19 @@ +prefix('field-service')->name('field-service.')->group(function () { + Route::get('dashboard', [FieldServiceDashboardController::class, 'index'])->name('dashboard'); + + // Order actions BEFORE resource + Route::post('orders/{order}/start', [ServiceOrderController::class, 'start'])->name('orders.start'); + Route::post('orders/{order}/complete', [ServiceOrderController::class, 'complete'])->name('orders.complete'); + Route::post('orders/{order}/cancel', [ServiceOrderController::class, 'cancel'])->name('orders.cancel'); + Route::post('orders/{order}/update-checklist', [ServiceOrderController::class, 'updateChecklist'])->name('orders.update-checklist'); + Route::resource('orders', ServiceOrderController::class); + + Route::resource('checklists', ServiceChecklistController::class)->except(['show', 'create', 'edit']); +}); diff --git a/erp/app/Modules/Finance/Console/Commands/GenerateRecurringInvoices.php b/erp/app/Modules/Finance/Console/Commands/GenerateRecurringInvoices.php new file mode 100644 index 00000000000..b9fe9ea2f27 --- /dev/null +++ b/erp/app/Modules/Finance/Console/Commands/GenerateRecurringInvoices.php @@ -0,0 +1,27 @@ +with('items')->get() as $recurringInvoice) { + $recurringInvoice->generateInvoice(); + $count++; + } + + $this->info("Generated {$count} invoice(s)."); + + return self::SUCCESS; + } +} diff --git a/erp/app/Modules/Finance/Http/Controllers/AccountController.php b/erp/app/Modules/Finance/Http/Controllers/AccountController.php new file mode 100644 index 00000000000..b7e519dce0e --- /dev/null +++ b/erp/app/Modules/Finance/Http/Controllers/AccountController.php @@ -0,0 +1,96 @@ +authorize('viewAny', Account::class); + + $accounts = Account::with('parent') + ->when($request->search, fn ($q) => $q->where('name', 'like', "%{$request->search}%") + ->orWhere('code', 'like', "%{$request->search}%")) + ->when($request->type, fn ($q) => $q->where('type', $request->type)) + ->orderBy('code') + ->paginate(50) + ->withQueryString(); + + return Inertia::render('Finance/Accounts/Index', [ + 'accounts' => AccountResource::collection($accounts), + 'filters' => $request->only(['search', 'type']), + 'breadcrumbs' => [ + ['label' => 'Finance'], + ['label' => 'Chart of Accounts', 'href' => route('finance.accounts.index')], + ], + ]); + } + + public function create(): Response + { + $this->authorize('create', Account::class); + + return Inertia::render('Finance/Accounts/Create', [ + 'parentAccounts' => Account::active()->orderBy('code')->get(['id', 'code', 'name', 'type']), + 'breadcrumbs' => [ + ['label' => 'Finance'], + ['label' => 'Chart of Accounts', 'href' => route('finance.accounts.index')], + ['label' => 'New Account'], + ], + ]); + } + + public function store(StoreAccountRequest $request): RedirectResponse + { + $this->authorize('create', Account::class); + + Account::create([...$request->validated(), 'tenant_id' => auth()->user()->tenant_id]); + + return redirect()->route('finance.accounts.index') + ->with('success', 'Account created.'); + } + + public function edit(Account $account): Response + { + $this->authorize('update', $account); + + return Inertia::render('Finance/Accounts/Edit', [ + 'account' => new AccountResource($account), + 'parentAccounts' => Account::active()->where('id', '!=', $account->id)->orderBy('code')->get(['id', 'code', 'name', 'type']), + 'breadcrumbs' => [ + ['label' => 'Finance'], + ['label' => 'Chart of Accounts', 'href' => route('finance.accounts.index')], + ['label' => $account->name . ' — Edit'], + ], + ]); + } + + public function update(StoreAccountRequest $request, Account $account): RedirectResponse + { + $this->authorize('update', $account); + + $account->update($request->validated()); + + return redirect()->route('finance.accounts.index') + ->with('success', 'Account updated.'); + } + + public function destroy(Account $account): RedirectResponse + { + $this->authorize('delete', $account); + + $account->delete(); + + return redirect()->route('finance.accounts.index') + ->with('success', 'Account deleted.'); + } +} diff --git a/erp/app/Modules/Finance/Http/Controllers/AdvancePaymentController.php b/erp/app/Modules/Finance/Http/Controllers/AdvancePaymentController.php new file mode 100644 index 00000000000..6e30f07b865 --- /dev/null +++ b/erp/app/Modules/Finance/Http/Controllers/AdvancePaymentController.php @@ -0,0 +1,102 @@ +authorize('viewAny', AdvancePayment::class); + + $query = AdvancePayment::with(['contact']) + ->orderByDesc('payment_date'); + + if ($request->filled('status')) { + $query->where('status', $request->status); + } + + $advancePayments = $query->paginate(20); + + return Inertia::render('Finance/AdvancePayments/Index', compact('advancePayments')); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', AdvancePayment::class); + + $data = $request->validate([ + 'contact_id' => 'nullable|exists:contacts,id', + 'amount' => 'required|numeric|min:0.01', + 'payment_date' => 'required|date', + 'currency' => 'nullable|string|max:3', + 'reference' => 'nullable|string|max:255', + 'payment_method' => 'nullable|string|max:50', + 'notes' => 'nullable|string', + ]); + + $advancePayment = AdvancePayment::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'contact_id' => $data['contact_id'] ?? null, + 'amount' => $data['amount'], + 'payment_date' => $data['payment_date'], + 'currency' => $data['currency'] ?? 'USD', + 'reference' => $data['reference'] ?? null, + 'payment_method' => $data['payment_method'] ?? null, + 'notes' => $data['notes'] ?? null, + 'status' => 'received', + 'applied_amount' => 0, + 'created_by' => auth()->id(), + ]); + + return redirect()->route('finance.advance-payments.show', $advancePayment) + ->with('success', 'Advance payment recorded.'); + } + + public function show(AdvancePayment $advancePayment): Response + { + $this->authorize('view', $advancePayment); + + $advancePayment->load(['contact', 'createdBy']); + + return Inertia::render('Finance/AdvancePayments/Show', compact('advancePayment')); + } + + public function apply(Request $request, AdvancePayment $advancePayment): RedirectResponse + { + $this->authorize('update', $advancePayment); + + $data = $request->validate([ + 'amount' => 'required|numeric|min:0.01', + ]); + + $advancePayment->applyAmount((float) $data['amount']); + + return back()->with('success', 'Amount applied successfully.'); + } + + public function refund(AdvancePayment $advancePayment): RedirectResponse + { + $this->authorize('update', $advancePayment); + + $advancePayment->refund(); + + return back()->with('success', 'Advance payment refunded.'); + } + + public function destroy(AdvancePayment $advancePayment): RedirectResponse + { + $this->authorize('delete', $advancePayment); + + $advancePayment->delete(); + + return redirect()->route('finance.advance-payments.index') + ->with('success', 'Advance payment deleted.'); + } +} diff --git a/erp/app/Modules/Finance/Http/Controllers/AttachmentController.php b/erp/app/Modules/Finance/Http/Controllers/AttachmentController.php new file mode 100644 index 00000000000..b19852845ea --- /dev/null +++ b/erp/app/Modules/Finance/Http/Controllers/AttachmentController.php @@ -0,0 +1,70 @@ + \App\Modules\Finance\Models\Invoice::class, + 'bills' => \App\Modules\Finance\Models\Bill::class, + 'expense-claims' => \App\Modules\HR\Models\ExpenseClaim::class, + 'projects' => \App\Modules\Finance\Models\Project::class, + ]; + + public function store(Request $request, string $modelType, int $modelId): RedirectResponse + { + abort_unless(array_key_exists($modelType, $this->allowedModels), 404); + + $this->authorize('create', Attachment::class); + + $request->validate([ + 'file' => 'required|file|max:20480|mimes:pdf,png,jpg,jpeg,webp,gif,csv,xlsx,docx,doc', + ]); + + $modelClass = $this->allowedModels[$modelType]; + $model = $modelClass::findOrFail($modelId); + + $file = $request->file('file'); + $path = $file->store("attachments/{$modelType}/{$modelId}", 'local'); + + Attachment::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'attachable_type' => $modelClass, + 'attachable_id' => $modelId, + 'filename' => $file->getClientOriginalName(), + 'disk' => 'local', + 'path' => $path, + 'mime_type' => $file->getMimeType(), + 'size' => $file->getSize(), + 'uploaded_by' => auth()->id(), + ]); + + return back()->with('success', 'File attached.'); + } + + public function download(Attachment $attachment): Response|\Symfony\Component\HttpFoundation\StreamedResponse + { + $this->authorize('view', $attachment); + + abort_unless(Storage::disk($attachment->disk)->exists($attachment->path), 404); + + return Storage::disk($attachment->disk)->download($attachment->path, $attachment->filename); + } + + public function destroy(Attachment $attachment): RedirectResponse + { + $this->authorize('delete', $attachment); + + Storage::disk($attachment->disk)->delete($attachment->path); + $attachment->delete(); + + return back()->with('success', 'Attachment deleted.'); + } +} diff --git a/erp/app/Modules/Finance/Http/Controllers/BankAccountController.php b/erp/app/Modules/Finance/Http/Controllers/BankAccountController.php new file mode 100644 index 00000000000..83070b5345a --- /dev/null +++ b/erp/app/Modules/Finance/Http/Controllers/BankAccountController.php @@ -0,0 +1,107 @@ +authorize('viewAny', BankAccount::class); + + $accounts = BankAccount::where('tenant_id', $request->user()->tenant_id) + ->paginate(20); + + return Inertia::render('Finance/BankAccounts/Index', [ + 'accounts' => $accounts, + ]); + } + + public function create(): Response + { + $this->authorize('create', BankAccount::class); + + return Inertia::render('Finance/BankAccounts/Create'); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', BankAccount::class); + + $data = $request->validate([ + 'name' => 'required|string|max:255', + 'bank_name' => 'required|string|max:255', + 'account_number' => 'nullable|string|max:255', + 'currency' => 'nullable|string|max:10', + ]); + + $account = BankAccount::create([ + ...$data, + 'tenant_id' => $request->user()->tenant_id, + 'currency' => $data['currency'] ?? 'USD', + ]); + + return redirect()->back() + ->with('success', 'Bank account created.'); + } + + public function show(Request $request, BankAccount $bankAccount): Response + { + $this->authorize('view', $bankAccount); + + $transactions = BankTransaction::where('bank_account_id', $bankAccount->id) + ->orderByDesc('transaction_date') + ->orderByDesc('id') + ->paginate(50); + + return Inertia::render('Finance/BankAccounts/Show', [ + 'account' => $bankAccount->load([]), + 'transactions' => $transactions, + 'balance' => $bankAccount->balance, + 'unreconciled' => $bankAccount->unreconciledCount, + ]); + } + + public function edit(BankAccount $bankAccount): Response + { + $this->authorize('update', $bankAccount); + + return Inertia::render('Finance/BankAccounts/Edit', [ + 'account' => $bankAccount, + ]); + } + + public function update(Request $request, BankAccount $bankAccount): RedirectResponse + { + $this->authorize('update', $bankAccount); + + $data = $request->validate([ + 'name' => 'required|string|max:255', + 'bank_name' => 'required|string|max:255', + 'account_number' => 'nullable|string|max:255', + 'currency' => 'nullable|string|max:10', + ]); + + $bankAccount->update($data); + + return redirect()->back() + ->with('success', 'Bank account updated.'); + } + + public function destroy(BankAccount $bankAccount): RedirectResponse + { + $this->authorize('delete', $bankAccount); + + $bankAccount->delete(); + + return redirect()->back() + ->with('success', 'Bank account deleted.'); + } +} diff --git a/erp/app/Modules/Finance/Http/Controllers/BankReconciliationController.php b/erp/app/Modules/Finance/Http/Controllers/BankReconciliationController.php new file mode 100644 index 00000000000..6be8eabdb2c --- /dev/null +++ b/erp/app/Modules/Finance/Http/Controllers/BankReconciliationController.php @@ -0,0 +1,110 @@ +authorize('viewAny', BankReconciliation::class); + + $query = BankReconciliation::with('account') + ->where('tenant_id', $request->user()->tenant_id); + + if ($request->filled('bank_account_id')) { + $query->where('bank_account_id', $request->bank_account_id); + } + + $reconciliations = $query->orderByDesc('statement_date')->paginate(20); + + $bankAccounts = BankAccount::where('tenant_id', $request->user()->tenant_id) + ->get(['id', 'name', 'bank_name']); + + return Inertia::render('Finance/BankReconciliations/Index', [ + 'reconciliations' => $reconciliations, + 'bankAccounts' => $bankAccounts, + 'filters' => $request->only(['bank_account_id']), + ]); + } + + public function create(): Response + { + $this->authorize('create', BankReconciliation::class); + + $bankAccounts = BankAccount::where('tenant_id', auth()->user()->tenant_id) + ->get(['id', 'name', 'bank_name']); + + return Inertia::render('Finance/BankReconciliations/Create', [ + 'bankAccounts' => $bankAccounts, + ]); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', BankReconciliation::class); + + $data = $request->validate([ + 'bank_account_id' => 'required|exists:bank_accounts,id', + 'statement_date' => 'required|date', + 'statement_balance' => 'required|numeric', + 'notes' => 'nullable|string', + ]); + + $reconciliation = BankReconciliation::create([ + ...$data, + 'tenant_id' => $request->user()->tenant_id, + ]); + + return redirect()->route('finance.bank-reconciliations.show', $reconciliation) + ->with('success', 'Reconciliation created.'); + } + + public function show(BankReconciliation $bankReconciliation): Response + { + $this->authorize('view', $bankReconciliation); + + $bankReconciliation->load('account'); + + $transactions = BankTransaction::where('bank_account_id', $bankReconciliation->bank_account_id) + ->where('tenant_id', $bankReconciliation->tenant_id) + ->orderByDesc('transaction_date') + ->get(); + + return Inertia::render('Finance/BankReconciliations/Show', [ + 'reconciliation' => array_merge($bankReconciliation->toArray(), [ + 'difference' => $bankReconciliation->difference, + 'is_balanced' => $bankReconciliation->is_balanced, + ]), + 'transactions' => $transactions, + ]); + } + + public function complete(Request $request, BankReconciliation $bankReconciliation): RedirectResponse + { + $this->authorize('update', $bankReconciliation); + + $bankReconciliation->complete($request->user()); + + return redirect()->back() + ->with('success', 'Reconciliation completed.'); + } + + public function destroy(BankReconciliation $bankReconciliation): RedirectResponse + { + $this->authorize('delete', $bankReconciliation); + + $bankReconciliation->delete(); + + return redirect()->route('finance.bank-reconciliations.index') + ->with('success', 'Reconciliation deleted.'); + } +} diff --git a/erp/app/Modules/Finance/Http/Controllers/BankStatementController.php b/erp/app/Modules/Finance/Http/Controllers/BankStatementController.php new file mode 100644 index 00000000000..5e1997ceb5b --- /dev/null +++ b/erp/app/Modules/Finance/Http/Controllers/BankStatementController.php @@ -0,0 +1,46 @@ +authorize('update', $bankAccount); + $request->validate(['file' => 'required|file|mimes:csv,txt|max:2048']); + + $path = $request->file('file')->getRealPath(); + $handle = fopen($path, 'r'); + $header = fgetcsv($handle); // skip header row + $count = 0; + $now = now(); + + while (($row = fgetcsv($handle)) !== false) { + if (count($row) < 3) continue; + [$date, $description, $amount] = $row; + $reference = $row[3] ?? null; + if (!is_numeric(str_replace([',', ' '], '', $amount))) continue; + $amount = (float) str_replace(',', '', $amount); + BankTransaction::create([ + 'tenant_id' => $bankAccount->tenant_id, + 'bank_account_id' => $bankAccount->id, + 'transaction_date' => $date, + 'description' => trim($description), + 'amount' => $amount, + 'reference' => $reference ? trim($reference) : null, + 'reconciled' => false, + 'imported_at' => $now, + ]); + $count++; + } + fclose($handle); + + return back()->with('success', "{$count} transactions imported."); + } +} diff --git a/erp/app/Modules/Finance/Http/Controllers/BankTransactionController.php b/erp/app/Modules/Finance/Http/Controllers/BankTransactionController.php new file mode 100644 index 00000000000..1f42fe62f3c --- /dev/null +++ b/erp/app/Modules/Finance/Http/Controllers/BankTransactionController.php @@ -0,0 +1,90 @@ +authorize('viewAny', BankTransaction::class); + + $query = BankTransaction::with('account') + ->where('tenant_id', $request->user()->tenant_id); + + if ($request->filled('bank_account_id')) { + $query->where('bank_account_id', $request->bank_account_id); + } + + $transactions = $query->orderByDesc('transaction_date')->orderByDesc('id')->paginate(20); + + $bankAccounts = BankAccount::where('tenant_id', $request->user()->tenant_id) + ->get(['id', 'name', 'bank_name']); + + return Inertia::render('Finance/BankTransactions/Index', [ + 'transactions' => $transactions, + 'bankAccounts' => $bankAccounts, + 'filters' => $request->only(['bank_account_id']), + ]); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', BankTransaction::class); + + $data = $request->validate([ + 'bank_account_id' => 'required|exists:bank_accounts,id', + 'transaction_date' => 'required|date', + 'description' => 'required|string|max:500', + 'amount' => 'required|numeric', + 'type' => 'required|in:credit,debit', + 'reference' => 'nullable|string|max:255', + ]); + + // For debit transactions, ensure amount is stored as negative + if ($data['type'] === 'debit' && $data['amount'] > 0) { + $data['amount'] = -abs($data['amount']); + } + + $transaction = BankTransaction::create([ + ...$data, + 'tenant_id' => $request->user()->tenant_id, + ]); + + $transaction->account->updateBalance(); + + return redirect()->back() + ->with('success', 'Transaction added.'); + } + + public function reconcile(Request $request, BankTransaction $bankTransaction): RedirectResponse + { + $this->authorize('update', $bankTransaction); + + $bankTransaction->update([ + 'is_reconciled' => !$bankTransaction->is_reconciled, + ]); + + return redirect()->back() + ->with('success', 'Transaction reconcile status updated.'); + } + + public function destroy(BankTransaction $bankTransaction): RedirectResponse + { + $this->authorize('delete', $bankTransaction); + + $account = $bankTransaction->account; + $bankTransaction->delete(); + $account->updateBalance(); + + return redirect()->back() + ->with('success', 'Transaction deleted.'); + } +} diff --git a/erp/app/Modules/Finance/Http/Controllers/BankTransferController.php b/erp/app/Modules/Finance/Http/Controllers/BankTransferController.php new file mode 100644 index 00000000000..00b8c3e3708 --- /dev/null +++ b/erp/app/Modules/Finance/Http/Controllers/BankTransferController.php @@ -0,0 +1,105 @@ +authorize('viewAny', BankTransfer::class); + + $query = BankTransfer::where('tenant_id', $request->user()->tenant_id) + ->with(['fromAccount', 'toAccount']); + + if ($request->filled('status')) { + $query->where('status', $request->status); + } + + $transfers = $query->latest()->paginate(20); + + return Inertia::render('Finance/BankTransfers/Index', [ + 'transfers' => $transfers, + 'filters' => $request->only(['status']), + ]); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', BankTransfer::class); + + $data = $request->validate([ + 'from_account_id' => 'required|exists:bank_accounts,id|different:to_account_id', + 'to_account_id' => 'required|exists:bank_accounts,id', + 'amount' => 'required|numeric|min:0.01', + 'transfer_date' => 'required|date', + 'currency' => 'nullable|string|max:3', + 'reference' => 'nullable|string|max:255', + 'notes' => 'nullable|string', + ]); + + $transfer = BankTransfer::create([ + ...$data, + 'tenant_id' => $request->user()->tenant_id, + 'created_by' => $request->user()->id, + 'currency' => $data['currency'] ?? 'USD', + ]); + + return redirect()->route('finance.bank-transfers.show', $transfer) + ->with('success', 'Bank transfer created.'); + } + + public function show(BankTransfer $bankTransfer): Response + { + $this->authorize('view', $bankTransfer); + + $bankTransfer->load(['fromAccount', 'toAccount', 'createdBy']); + + return Inertia::render('Finance/BankTransfers/Show', [ + 'transfer' => $bankTransfer, + ]); + } + + public function complete(BankTransfer $bankTransfer): RedirectResponse + { + $this->authorize('create', BankTransfer::class); + + $bankTransfer->complete(); + + return back()->with('success', 'Transfer marked as completed.'); + } + + public function fail(BankTransfer $bankTransfer): RedirectResponse + { + $this->authorize('create', BankTransfer::class); + + $bankTransfer->fail(); + + return back()->with('success', 'Transfer marked as failed.'); + } + + public function cancel(BankTransfer $bankTransfer): RedirectResponse + { + $this->authorize('create', BankTransfer::class); + + $bankTransfer->cancel(); + + return back()->with('success', 'Transfer cancelled.'); + } + + public function destroy(BankTransfer $bankTransfer): RedirectResponse + { + $this->authorize('delete', $bankTransfer); + + $bankTransfer->delete(); + + return redirect()->route('finance.bank-transfers.index') + ->with('success', 'Bank transfer deleted.'); + } +} diff --git a/erp/app/Modules/Finance/Http/Controllers/BatchPaymentController.php b/erp/app/Modules/Finance/Http/Controllers/BatchPaymentController.php new file mode 100644 index 00000000000..8fd6521073c --- /dev/null +++ b/erp/app/Modules/Finance/Http/Controllers/BatchPaymentController.php @@ -0,0 +1,160 @@ +authorize('viewAny', BatchPayment::class); + + $batches = BatchPayment::withCount('payments') + ->orderByDesc('payment_date') + ->paginate(25); + + return Inertia::render('Finance/BatchPayments/Index', compact('batches')); + } + + public function create(Request $request): Response + { + $this->authorize('create', BatchPayment::class); + + $type = $request->get('type', 'received'); + + if ($type === 'received') { + $openItems = Invoice::with('contact') + ->whereIn('status', ['sent', 'partial']) + ->orderBy('due_date') + ->get(['id', 'number', 'contact_id', 'due_date', 'currency_code', 'status']); + } else { + $openItems = Bill::with('contact') + ->whereIn('status', ['received', 'partial']) + ->orderBy('due_date') + ->get(['id', 'number', 'contact_id', 'due_date', 'currency_code', 'status']); + } + + // Load items and payments for computed totals + $openItems->load(['items', 'payments']); + + return Inertia::render('Finance/BatchPayments/Create', compact('openItems', 'type')); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', BatchPayment::class); + + $data = $request->validate([ + 'reference' => 'required|string|max:100|unique:batch_payments,reference', + 'payment_date' => 'required|date', + 'payment_method' => 'required|in:bank_transfer,cheque,cash,card,other', + 'type' => 'required|in:received,made', + 'notes' => 'nullable|string', + 'payments' => 'required|array|min:1', + 'payments.*.id' => 'required|integer', + 'payments.*.amount' => 'required|numeric|min:0.01', + ]); + + DB::transaction(function () use ($data) { + $totalAmount = collect($data['payments'])->sum('amount'); + + $batch = BatchPayment::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'reference' => $data['reference'], + 'payment_date' => $data['payment_date'], + 'payment_method' => $data['payment_method'], + 'type' => $data['type'], + 'total_amount' => $totalAmount, + 'notes' => $data['notes'] ?? null, + ]); + + foreach ($data['payments'] as $p) { + if ($data['type'] === 'received') { + $invoice = Invoice::with(['items', 'payments'])->findOrFail($p['id']); + $outstanding = $invoice->total - $invoice->amount_paid; + $amount = min((float) $p['amount'], $outstanding); + + if ($amount <= 0) { + continue; + } + + Payment::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'invoice_id' => $invoice->id, + 'amount' => $amount, + 'payment_date' => $data['payment_date'], + 'method' => $data['payment_method'], + 'reference' => $data['reference'], + 'batch_payment_id' => $batch->id, + ]); + + // Reload to get updated amount_paid + $invoice->load(['items', 'payments']); + if ($invoice->amount_due <= 0 && $invoice->canTransitionTo('paid')) { + $invoice->transitionTo('paid'); + } elseif ($invoice->amount_paid > 0 && $invoice->canTransitionTo('partial')) { + $invoice->transitionTo('partial'); + } + } else { + $bill = Bill::with(['items', 'payments'])->findOrFail($p['id']); + $outstanding = $bill->total - $bill->amount_paid; + $amount = min((float) $p['amount'], $outstanding); + + if ($amount <= 0) { + continue; + } + + BillPayment::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'bill_id' => $bill->id, + 'amount' => $amount, + 'payment_date' => $data['payment_date'], + 'method' => $data['payment_method'], + 'reference' => $data['reference'], + ]); + + // Reload to get updated amount_paid + $bill->load(['items', 'payments']); + if ($bill->amount_due <= 0 && $bill->canTransitionTo('paid')) { + $bill->transitionTo('paid'); + } elseif ($bill->amount_paid > 0 && $bill->canTransitionTo('partial')) { + $bill->transitionTo('partial'); + } + } + } + }); + + return redirect()->route('finance.batch-payments.index') + ->with('success', 'Batch payment recorded.'); + } + + public function show(BatchPayment $batchPayment): Response + { + $this->authorize('view', $batchPayment); + + $batchPayment->load('payments.invoice'); + + return Inertia::render('Finance/BatchPayments/Show', compact('batchPayment')); + } + + public function destroy(BatchPayment $batchPayment): RedirectResponse + { + $this->authorize('delete', $batchPayment); + + $batchPayment->delete(); + + return redirect()->route('finance.batch-payments.index') + ->with('success', 'Batch payment deleted.'); + } +} diff --git a/erp/app/Modules/Finance/Http/Controllers/BillController.php b/erp/app/Modules/Finance/Http/Controllers/BillController.php new file mode 100644 index 00000000000..cd49cef0db3 --- /dev/null +++ b/erp/app/Modules/Finance/Http/Controllers/BillController.php @@ -0,0 +1,218 @@ +authorize('viewAny', Bill::class); + + $bills = Bill::with('contact') + ->when($request->status, fn ($q) => $q->where('status', $request->status)) + ->when($request->contact_id, fn ($q) => $q->where('contact_id', $request->contact_id)) + ->when($request->search, fn ($q) => $q->where('number', 'like', "%{$request->search}%")) + ->latest('issue_date') + ->paginate(25) + ->withQueryString(); + + return Inertia::render('Finance/Bills/Index', [ + 'bills' => BillResource::collection($bills), + 'contacts' => Contact::vendors()->active()->orderBy('name')->get(['id', 'name']), + 'filters' => $request->only(['status', 'contact_id', 'search']), + 'currencies' => $this->currencies, + 'breadcrumbs' => [ + ['label' => 'Finance'], + ['label' => 'Bills', 'href' => route('finance.bills.index')], + ], + ]); + } + + public function create(): Response + { + $this->authorize('create', Bill::class); + + return Inertia::render('Finance/Bills/Create', [ + 'contacts' => Contact::vendors()->active()->orderBy('name')->get(['id', 'name']), + 'currencies' => $this->currencies, + 'breadcrumbs' => [ + ['label' => 'Finance'], + ['label' => 'Bills', 'href' => route('finance.bills.index')], + ['label' => 'New Bill'], + ], + ]); + } + + public function store(StoreBillRequest $request): RedirectResponse + { + $this->authorize('create', Bill::class); + + $data = $request->validated(); + + $bill = DB::transaction(function () use ($data) { + $bill = Bill::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'contact_id' => $data['contact_id'] ?? null, + 'issue_date' => $data['issue_date'], + 'due_date' => $data['due_date'] ?? null, + 'notes' => $data['notes'] ?? null, + 'created_by' => auth()->id(), + 'currency_code' => $data['currency_code'] ?? 'USD', + 'exchange_rate' => $data['exchange_rate'] ?? 1.0, + ]); + + $bill->update([ + 'number' => 'BILL-' . now()->format('Y') . '-' . str_pad((string) $bill->id, 5, '0', STR_PAD_LEFT), + ]); + + foreach ($data['items'] as $item) { + BillItem::create([ + 'bill_id' => $bill->id, + 'description' => $item['description'], + 'quantity' => $item['quantity'], + 'unit_price' => $item['unit_price'], + 'tax_rate' => $item['tax_rate'], + ]); + } + + return $bill; + }); + + return redirect()->route('finance.bills.show', $bill) + ->with('success', 'Bill created.'); + } + + public function show(Bill $bill): Response + { + $this->authorize('view', $bill); + + $bill->load(['contact', 'items', 'payments', 'creator', 'attachments']); + + return Inertia::render('Finance/Bills/Show', [ + 'bill' => new BillResource($bill), + 'breadcrumbs' => [ + ['label' => 'Finance'], + ['label' => 'Bills', 'href' => route('finance.bills.index')], + ['label' => $bill->number ?? "Bill #{$bill->id}"], + ], + ]); + } + + public function receive(Bill $bill): RedirectResponse + { + $this->authorize('update', $bill); + + try { + $bill->transitionTo('received'); + } catch (\DomainException $e) { + return back()->withErrors(['status' => $e->getMessage()]); + } + + return back()->with('success', 'Bill marked as received.'); + } + + public function cancel(Bill $bill): RedirectResponse + { + $this->authorize('update', $bill); + + try { + $bill->transitionTo('cancelled'); + } catch (\DomainException $e) { + return back()->withErrors(['status' => $e->getMessage()]); + } + + return back()->with('success', 'Bill cancelled.'); + } + + public function recordPayment(StorePaymentRequest $request, Bill $bill): RedirectResponse + { + $this->authorize('update', $bill); + + if ($bill->status !== 'received') { + return back()->withErrors(['status' => 'Payments can only be recorded on received bills.']); + } + + $data = $request->validated(); + + DB::transaction(function () use ($data, $bill) { + BillPayment::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'bill_id' => $bill->id, + 'amount' => $data['amount'], + 'payment_date' => $data['payment_date'], + 'method' => $data['method'], + 'reference' => $data['reference'] ?? null, + 'notes' => $data['notes'] ?? null, + ]); + + $bill->load(['items', 'payments']); + + if ($bill->amount_due <= 0 && $bill->canTransitionTo('paid')) { + $bill->transitionTo('paid'); + } + }); + + return back()->with('success', 'Payment recorded.'); + } + + public function destroy(Bill $bill): RedirectResponse + { + $this->authorize('delete', $bill); + + $bill->delete(); + + return redirect()->route('finance.bills.index') + ->with('success', 'Bill deleted.'); + } + + public function pdf(Bill $bill): \Illuminate\Http\Response + { + $this->authorize('view', $bill); + $bill->load(['items', 'contact', 'payments']); + $pdf = $this->renderDocumentPdf('pdf.bill', [ + 'bill' => $bill, + 'company' => $this->resolveCompanyName(), + ]); + $filename = 'bill-' . $bill->number . '.pdf'; + return response($pdf, 200, [ + 'Content-Type' => 'application/pdf', + 'Content-Disposition' => 'attachment; filename="' . $filename . '"', + ]); + } + + public function email(Request $request, Bill $bill): \Illuminate\Http\RedirectResponse + { + $this->authorize('update', $bill); + $request->validate(['email' => 'required|email', 'message' => 'nullable|string|max:1000']); + + $bill->load(['items', 'contact', 'payments']); + $pdf = $this->renderDocumentPdf('pdf.bill', [ + 'bill' => $bill, + 'company' => $this->resolveCompanyName(), + ]); + $filename = 'bill-' . $bill->number . '.pdf'; + + $this->sendDocumentEmail($request, $request->input('email'), 'Bill ' . $bill->number, $pdf, $filename); + + return back()->with('success', 'Bill emailed successfully.'); + } +} diff --git a/erp/app/Modules/Finance/Http/Controllers/BudgetController.php b/erp/app/Modules/Finance/Http/Controllers/BudgetController.php new file mode 100644 index 00000000000..5a1c783a8bc --- /dev/null +++ b/erp/app/Modules/Finance/Http/Controllers/BudgetController.php @@ -0,0 +1,219 @@ +authorize('viewAny', Budget::class); + + $query = Budget::withCount('lines'); + + if ($request->filled('fiscal_year')) { + $query->where('fiscal_year', (int) $request->input('fiscal_year')); + } + + if ($request->filled('status')) { + $query->where('status', $request->input('status')); + } + + $budgets = $query->orderByDesc('fiscal_year') + ->orderByDesc('id') + ->paginate(20); + + return Inertia::render('Finance/Budgets/Index', [ + 'budgets' => $budgets, + 'filters' => $request->only(['fiscal_year', 'status']), + ]); + } + + public function create(): Response + { + $this->authorize('create', Budget::class); + + return Inertia::render('Finance/Budgets/Create'); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', Budget::class); + + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'fiscal_year' => ['required'], + 'total_amount' => ['required', 'numeric', 'min:0'], + 'department' => ['nullable', 'string', 'max:255'], + 'budget_type' => ['nullable', Rule::in(['annual', 'quarterly', 'monthly', 'project'])], + 'notes' => ['nullable', 'string'], + 'start_date' => ['nullable', 'date'], + 'end_date' => ['nullable', 'date'], + ]); + + $budget = Budget::create([ + 'tenant_id' => app('tenant')->id, + 'created_by' => auth()->id(), + 'name' => $validated['name'], + 'fiscal_year' => $validated['fiscal_year'], + 'year' => $validated['fiscal_year'], + 'total_amount' => $validated['total_amount'], + 'department' => $validated['department'] ?? null, + 'budget_type' => $validated['budget_type'] ?? 'annual', + 'period_type' => $validated['budget_type'] ?? 'annual', + 'notes' => $validated['notes'] ?? null, + 'start_date' => $validated['start_date'] ?? null, + 'end_date' => $validated['end_date'] ?? null, + 'status' => 'draft', + ]); + + return redirect()->route('finance.budgets.index'); + } + + public function show(Budget $budget): Response + { + $this->authorize('view', $budget); + + $budget->load('lines', 'lineItems'); + + return Inertia::render('Finance/Budgets/Show', [ + 'budget' => array_merge($budget->toArray(), [ + 'total_budgeted' => $budget->total_budgeted, + 'total_actual' => $budget->total_actual, + 'total_variance' => $budget->total_variance, + 'variance_percent' => $budget->variance_percent, + 'remaining_amount' => $budget->remaining_amount, + 'utilization_percent' => $budget->utilization_percent, + 'is_active' => $budget->is_active, + 'is_exceeded' => $budget->is_exceeded, + 'lines' => $budget->lines->map(fn ($line) => array_merge($line->toArray(), [ + 'variance' => $line->variance, + 'variance_percent' => $line->variance_percent, + 'is_over_budget' => $line->is_over_budget, + ]))->values(), + ]), + ]); + } + + public function edit(Budget $budget): Response + { + $this->authorize('update', $budget); + + return Inertia::render('Finance/Budgets/Edit', [ + 'budget' => $budget, + ]); + } + + public function update(Request $request, Budget $budget): RedirectResponse + { + $this->authorize('update', $budget); + + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'fiscal_year' => ['required'], + 'total_amount' => ['required', 'numeric', 'min:0'], + 'department' => ['nullable', 'string', 'max:255'], + 'budget_type' => ['nullable', Rule::in(['annual', 'quarterly', 'monthly', 'project'])], + 'notes' => ['nullable', 'string'], + 'start_date' => ['nullable', 'date'], + 'end_date' => ['nullable', 'date'], + ]); + + $budget->update([ + 'name' => $validated['name'], + 'fiscal_year' => $validated['fiscal_year'], + 'year' => $validated['fiscal_year'], + 'total_amount' => $validated['total_amount'], + 'department' => $validated['department'] ?? null, + 'budget_type' => $validated['budget_type'] ?? $budget->budget_type, + 'period_type' => $validated['budget_type'] ?? $budget->period_type, + 'notes' => $validated['notes'] ?? null, + 'start_date' => $validated['start_date'] ?? null, + 'end_date' => $validated['end_date'] ?? null, + ]); + + return redirect()->route('finance.budgets.index'); + } + + public function destroy(Budget $budget): RedirectResponse + { + $this->authorize('delete', $budget); + + $budget->delete(); + + return redirect()->route('finance.budgets.index'); + } + + public function activate(Request $request, Budget $budget): RedirectResponse + { + $this->authorize('update', $budget); + + $budget->activate(auth()->id()); + + return redirect()->back(); + } + + public function close(Request $request, Budget $budget): RedirectResponse + { + $this->authorize('update', $budget); + + $budget->close(); + + return redirect()->back(); + } + + public function addLine(Request $request, Budget $budget): RedirectResponse + { + $this->authorize('update', $budget); + + $validated = $request->validate([ + 'category' => ['required', 'string', 'max:255'], + 'line_type' => ['required', Rule::in(['income', 'expense'])], + 'period_number' => ['required', 'integer', 'min:1'], + 'budgeted_amount' => ['required', 'numeric', 'min:0'], + 'notes' => ['nullable', 'string'], + ]); + + $budget->lines()->create([ + 'tenant_id' => app('tenant')->id, + 'category' => $validated['category'], + 'line_type' => $validated['line_type'], + 'period_number' => $validated['period_number'], + 'budgeted_amount' => $validated['budgeted_amount'], + 'actual_amount' => 0, + 'notes' => $validated['notes'] ?? null, + ]); + + return redirect()->back(); + } + + public function updateActual(Request $request, Budget $budget, BudgetLine $line): RedirectResponse + { + $this->authorize('update', $budget); + + $validated = $request->validate([ + 'actual_amount' => ['required', 'numeric', 'min:0'], + ]); + + $line->update(['actual_amount' => $validated['actual_amount']]); + + return redirect()->back(); + } + + public function removeLine(Request $request, Budget $budget, BudgetLine $line): RedirectResponse + { + $this->authorize('update', $budget); + + $line->delete(); + + return redirect()->back(); + } +} diff --git a/erp/app/Modules/Finance/Http/Controllers/BudgetLineController.php b/erp/app/Modules/Finance/Http/Controllers/BudgetLineController.php new file mode 100644 index 00000000000..5145d760649 --- /dev/null +++ b/erp/app/Modules/Finance/Http/Controllers/BudgetLineController.php @@ -0,0 +1,34 @@ +authorize('update', $budgetLine->budget); + + $validated = $request->validate([ + 'amount' => ['required', 'numeric', 'min:0'], + 'notes' => ['nullable', 'string'], + ]); + + $budgetLine->update($validated); + + return redirect()->back()->with('success', 'Budget line updated.'); + } + + public function destroy(BudgetLine $budgetLine): RedirectResponse + { + $this->authorize('delete', $budgetLine->budget); + + $budgetLine->delete(); + + return redirect()->back()->with('success', 'Budget line deleted.'); + } +} diff --git a/erp/app/Modules/Finance/Http/Controllers/CashFlowForecastController.php b/erp/app/Modules/Finance/Http/Controllers/CashFlowForecastController.php new file mode 100644 index 00000000000..f2c1ebdf9d0 --- /dev/null +++ b/erp/app/Modules/Finance/Http/Controllers/CashFlowForecastController.php @@ -0,0 +1,120 @@ +authorize('viewAny', CashFlowForecast::class); + + $forecasts = CashFlowForecast::orderByDesc('id')->paginate(20); + + return Inertia::render('Finance/CashFlowForecasts/Index', [ + 'forecasts' => $forecasts, + ]); + } + + public function create(): Response + { + $this->authorize('create', CashFlowForecast::class); + + return Inertia::render('Finance/CashFlowForecasts/Create'); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', CashFlowForecast::class); + + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'period_start' => ['required', 'date'], + 'period_end' => ['required', 'date', 'after_or_equal:period_start'], + 'opening_balance' => ['nullable', 'numeric'], + ]); + + CashFlowForecast::create([ + 'tenant_id' => app('tenant')->id, + 'created_by' => auth()->id(), + 'name' => $validated['name'], + 'period_start' => $validated['period_start'], + 'period_end' => $validated['period_end'], + 'opening_balance' => $validated['opening_balance'] ?? 0, + ]); + + return redirect()->route('finance.cash-flow-forecasts.index'); + } + + public function show(CashFlowForecast $cashFlowForecast): Response + { + $this->authorize('view', $cashFlowForecast); + + return Inertia::render('Finance/CashFlowForecasts/Show', [ + 'forecast' => $cashFlowForecast, + ]); + } + + public function edit(CashFlowForecast $cashFlowForecast): Response + { + $this->authorize('update', $cashFlowForecast); + + return Inertia::render('Finance/CashFlowForecasts/Edit', [ + 'forecast' => $cashFlowForecast, + ]); + } + + public function update(Request $request, CashFlowForecast $cashFlowForecast): RedirectResponse + { + $this->authorize('update', $cashFlowForecast); + + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'period_start' => ['required', 'date'], + 'period_end' => ['required', 'date', 'after_or_equal:period_start'], + 'opening_balance' => ['nullable', 'numeric'], + 'projected_inflows' => ['nullable', 'numeric'], + 'projected_outflows' => ['nullable', 'numeric'], + 'actual_inflows' => ['nullable', 'numeric'], + 'actual_outflows' => ['nullable', 'numeric'], + 'notes' => ['nullable', 'string'], + ]); + + $cashFlowForecast->update($validated); + + return redirect()->route('finance.cash-flow-forecasts.index'); + } + + public function destroy(CashFlowForecast $cashFlowForecast): RedirectResponse + { + $this->authorize('delete', $cashFlowForecast); + + $cashFlowForecast->delete(); + + return redirect()->route('finance.cash-flow-forecasts.index'); + } + + public function publish(Request $request, CashFlowForecast $cashFlowForecast): RedirectResponse + { + $this->authorize('publish', $cashFlowForecast); + + $cashFlowForecast->publish(auth()->id()); + + return redirect()->route('finance.cash-flow-forecasts.index'); + } + + public function archive(Request $request, CashFlowForecast $cashFlowForecast): RedirectResponse + { + $this->authorize('archive', $cashFlowForecast); + + $cashFlowForecast->archive(); + + return redirect()->route('finance.cash-flow-forecasts.index'); + } +} diff --git a/erp/app/Modules/Finance/Http/Controllers/CommissionController.php b/erp/app/Modules/Finance/Http/Controllers/CommissionController.php new file mode 100644 index 00000000000..e8246611ea5 --- /dev/null +++ b/erp/app/Modules/Finance/Http/Controllers/CommissionController.php @@ -0,0 +1,161 @@ +authorize('viewAny', Commission::class); + + $commissions = Commission::with(['rule', 'user', 'invoice']) + ->when($request->status, fn ($q) => $q->where('status', $request->status)) + ->when($request->user_id, fn ($q) => $q->where('user_id', $request->user_id)) + ->latest() + ->paginate(15) + ->withQueryString(); + + return Inertia::render('Finance/Commissions/Index', [ + 'commissions' => $commissions, + 'users' => User::orderBy('name')->get(['id', 'name']), + 'filters' => $request->only(['status', 'user_id']), + 'breadcrumbs' => [ + ['label' => 'Finance'], + ['label' => 'Commissions', 'href' => route('finance.commissions.index')], + ], + ]); + } + + public function create(): Response + { + $this->authorize('create', Commission::class); + + return Inertia::render('Finance/Commissions/Create', [ + 'rules' => CommissionRule::with('user')->where('is_active', true)->latest()->get(['id', 'name', 'user_id']), + 'invoices' => Invoice::latest()->get(['id', 'number']), + 'breadcrumbs' => [ + ['label' => 'Finance'], + ['label' => 'Commissions', 'href' => route('finance.commissions.index')], + ['label' => 'New Commission'], + ], + ]); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', Commission::class); + + $data = $request->validate([ + 'commission_rule_id' => ['required', Rule::exists('commission_rules', 'id')], + 'invoice_id' => ['required', Rule::exists('invoices', 'id')], + 'notes' => ['nullable', 'string'], + ]); + + $rule = CommissionRule::findOrFail($data['commission_rule_id']); + $invoice = Invoice::with('items', 'payments')->findOrFail($data['invoice_id']); + + $invoiceAmount = (float) $invoice->total; + $commissionAmount = $rule->calculateCommission($invoiceAmount); + + $commission = Commission::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'commission_rule_id' => $rule->id, + 'user_id' => $rule->user_id, + 'invoice_id' => $invoice->id, + 'invoice_amount' => $invoiceAmount, + 'commission_amount' => $commissionAmount, + 'status' => 'pending', + 'notes' => $data['notes'] ?? null, + ]); + + return redirect()->route('finance.commissions.show', $commission) + ->with('success', 'Commission created.'); + } + + public function show(Commission $commission): Response + { + $this->authorize('view', $commission); + + $commission->load(['rule', 'user', 'invoice']); + + return Inertia::render('Finance/Commissions/Show', [ + 'commission' => $commission, + 'breadcrumbs' => [ + ['label' => 'Finance'], + ['label' => 'Commissions', 'href' => route('finance.commissions.index')], + ['label' => "Commission #{$commission->id}"], + ], + ]); + } + + public function destroy(Commission $commission): RedirectResponse + { + $this->authorize('delete', $commission); + + $commission->delete(); + + return redirect()->route('finance.commissions.index') + ->with('success', 'Commission deleted.'); + } + + public function approve(Commission $commission): RedirectResponse + { + $this->authorize('create', Commission::class); + + $commission->approve(); + + return back()->with('success', 'Commission approved.'); + } + + public function markPaid(Commission $commission): RedirectResponse + { + $this->authorize('create', Commission::class); + + $commission->markPaid(); + + return back()->with('success', 'Commission marked as paid.'); + } + + public function generate(Request $request): RedirectResponse + { + $this->authorize('create', Commission::class); + + $request->validate([ + 'invoice_id' => ['required', Rule::exists('invoices', 'id')], + ]); + + $invoice = Invoice::with('items', 'payments')->findOrFail($request->invoice_id); + + $rule = CommissionRule::where('user_id', $invoice->assigned_to_user_id) + ->where('is_active', true) + ->where('tenant_id', app('tenant')->id) + ->first(); + + if (!$rule) { + return back()->with('error', 'No active commission rule for assigned user.'); + } + + $commission = Commission::create([ + 'tenant_id' => app('tenant')->id, + 'commission_rule_id' => $rule->id, + 'user_id' => $rule->user_id, + 'invoice_id' => $invoice->id, + 'invoice_amount' => $invoice->total, + 'commission_amount' => $rule->calculateCommission((float) $invoice->total), + 'status' => 'pending', + ]); + + return redirect()->route('finance.commissions.show', $commission); + } +} diff --git a/erp/app/Modules/Finance/Http/Controllers/CommissionRuleController.php b/erp/app/Modules/Finance/Http/Controllers/CommissionRuleController.php new file mode 100644 index 00000000000..d28bdf94a3a --- /dev/null +++ b/erp/app/Modules/Finance/Http/Controllers/CommissionRuleController.php @@ -0,0 +1,98 @@ +authorize('viewAny', CommissionRule::class); + + $rules = CommissionRule::with('user') + ->latest() + ->paginate(15); + + return Inertia::render('Finance/CommissionRules/Index', [ + 'rules' => $rules, + 'breadcrumbs' => [ + ['label' => 'Finance'], + ['label' => 'Commission Rules', 'href' => route('finance.commission-rules.index')], + ], + ]); + } + + public function create(): Response + { + $this->authorize('create', CommissionRule::class); + + return Inertia::render('Finance/CommissionRules/Create', [ + 'users' => User::orderBy('name')->get(['id', 'name']), + 'breadcrumbs' => [ + ['label' => 'Finance'], + ['label' => 'Commission Rules', 'href' => route('finance.commission-rules.index')], + ['label' => 'New Rule'], + ], + ]); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', CommissionRule::class); + + $data = $request->validate([ + 'user_id' => ['required', Rule::exists('users', 'id')], + 'name' => ['required', 'string', 'max:255'], + 'rate' => ['required_if:type,percentage', 'nullable', 'numeric', 'min:0', 'max:1'], + 'type' => ['required', Rule::in(['percentage', 'fixed'])], + 'fixed_amount' => ['required_if:type,fixed', 'nullable', 'numeric', 'min:0'], + ]); + + $rule = CommissionRule::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'user_id' => $data['user_id'], + 'name' => $data['name'], + 'type' => $data['type'], + 'rate' => $data['rate'] ?? 0, + 'fixed_amount' => $data['fixed_amount'] ?? null, + 'is_active' => $request->boolean('is_active', true), + ]); + + return redirect()->route('finance.commission-rules.show', $rule) + ->with('success', 'Commission rule created.'); + } + + public function show(CommissionRule $commissionRule): Response + { + $this->authorize('view', $commissionRule); + + $commissionRule->load(['user', 'commissions.invoice']); + + return Inertia::render('Finance/CommissionRules/Show', [ + 'rule' => $commissionRule, + 'breadcrumbs' => [ + ['label' => 'Finance'], + ['label' => 'Commission Rules', 'href' => route('finance.commission-rules.index')], + ['label' => $commissionRule->name], + ], + ]); + } + + public function destroy(CommissionRule $commissionRule): RedirectResponse + { + $this->authorize('delete', $commissionRule); + + $commissionRule->delete(); + + return redirect()->route('finance.commission-rules.index') + ->with('success', 'Commission rule deleted.'); + } +} diff --git a/erp/app/Modules/Finance/Http/Controllers/Concerns/SendsDocuments.php b/erp/app/Modules/Finance/Http/Controllers/Concerns/SendsDocuments.php new file mode 100644 index 00000000000..9e0692e5168 --- /dev/null +++ b/erp/app/Modules/Finance/Http/Controllers/Concerns/SendsDocuments.php @@ -0,0 +1,44 @@ +output(); + } + + private function resolveCompanyName(): string + { + try { + return app('tenant')->name; + } catch (\Throwable) { + return config('app.name', 'ERP'); + } + } + + private function sendDocumentEmail( + Request $request, + string $toEmail, + string $subject, + string $pdfData, + string $filename, + ): void { + $company = $this->resolveCompanyName(); + $messageBody = $request->input('message', ''); + + Mail::to($toEmail)->send(new DocumentMail( + mailSubject: $subject, + pdfData: $pdfData, + filename: $filename, + company: $company, + messageBody: $messageBody, + )); + } +} diff --git a/erp/app/Modules/Finance/Http/Controllers/ContactController.php b/erp/app/Modules/Finance/Http/Controllers/ContactController.php new file mode 100644 index 00000000000..4a86f0e393c --- /dev/null +++ b/erp/app/Modules/Finance/Http/Controllers/ContactController.php @@ -0,0 +1,95 @@ +authorize('viewAny', Contact::class); + + $contacts = Contact::when($request->search, fn ($q) => $q->search($request->search)) + ->when($request->type, fn ($q) => $q->where('type', $request->type)) + ->orderBy('name') + ->paginate(25) + ->withQueryString(); + + return Inertia::render('Finance/Contacts/Index', [ + 'contacts' => ContactResource::collection($contacts), + 'filters' => $request->only(['search', 'type']), + 'breadcrumbs' => [ + ['label' => 'Finance'], + ['label' => 'Contacts', 'href' => route('finance.contacts.index')], + ], + ]); + } + + public function create(): Response + { + $this->authorize('create', Contact::class); + + return Inertia::render('Finance/Contacts/Create', [ + 'priceLists' => PriceList::where('is_active', true)->orderBy('name')->get(['id', 'name']), + 'breadcrumbs' => [ + ['label' => 'Finance'], + ['label' => 'Contacts', 'href' => route('finance.contacts.index')], + ['label' => 'New Contact'], + ], + ]); + } + + public function store(StoreContactRequest $request): RedirectResponse + { + $this->authorize('create', Contact::class); + + Contact::create([...$request->validated(), 'tenant_id' => auth()->user()->tenant_id]); + + return redirect()->route('finance.contacts.index') + ->with('success', 'Contact created.'); + } + + public function edit(Contact $contact): Response + { + $this->authorize('update', $contact); + + return Inertia::render('Finance/Contacts/Edit', [ + 'contact' => new ContactResource($contact), + 'priceLists' => PriceList::where('is_active', true)->orderBy('name')->get(['id', 'name']), + 'breadcrumbs' => [ + ['label' => 'Finance'], + ['label' => 'Contacts', 'href' => route('finance.contacts.index')], + ['label' => $contact->name . ' — Edit'], + ], + ]); + } + + public function update(StoreContactRequest $request, Contact $contact): RedirectResponse + { + $this->authorize('update', $contact); + + $contact->update($request->validated()); + + return redirect()->route('finance.contacts.index') + ->with('success', 'Contact updated.'); + } + + public function destroy(Contact $contact): RedirectResponse + { + $this->authorize('delete', $contact); + + $contact->delete(); + + return redirect()->route('finance.contacts.index') + ->with('success', 'Contact deleted.'); + } +} diff --git a/erp/app/Modules/Finance/Http/Controllers/ContractController.php b/erp/app/Modules/Finance/Http/Controllers/ContractController.php new file mode 100644 index 00000000000..c060244e947 --- /dev/null +++ b/erp/app/Modules/Finance/Http/Controllers/ContractController.php @@ -0,0 +1,195 @@ +authorize('viewAny', Contract::class); + + $contracts = Contract::with('createdBy') + ->when($request->status, fn ($q) => $q->where('status', $request->status)) + ->when($request->type, fn ($q) => $q->where('type', $request->type)) + ->latest() + ->paginate(15) + ->withQueryString(); + + return Inertia::render('Finance/Contracts/Index', [ + 'contracts' => $contracts, + 'filters' => $request->only(['status', 'type']), + 'breadcrumbs' => [ + ['label' => 'Finance'], + ['label' => 'Contracts', 'href' => route('finance.contracts.index')], + ], + ]); + } + + public function create(): Response + { + $this->authorize('create', Contract::class); + + return Inertia::render('Finance/Contracts/Create', [ + 'contacts' => Contact::orderBy('name')->get(['id', 'name']), + 'breadcrumbs' => [ + ['label' => 'Finance'], + ['label' => 'Contracts', 'href' => route('finance.contracts.index')], + ['label' => 'New Contract'], + ], + ]); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', Contract::class); + + $data = $request->validate([ + 'title' => ['required', 'string', 'max:255'], + 'party_name' => ['required', 'string', 'max:255'], + 'party_email'=> ['nullable', 'email', 'max:255'], + 'type' => ['nullable', Rule::in(['client', 'vendor', 'employee', 'employment', 'nda', 'other'])], + 'status' => ['nullable', Rule::in(['draft', 'active', 'expired', 'terminated'])], + 'contact_id' => ['nullable', Rule::exists('contacts', 'id')], + 'start_date' => ['required', 'date'], + 'end_date' => ['required', 'date', 'after:start_date'], + 'value' => ['nullable', 'numeric', 'min:0'], + 'currency' => ['nullable', 'string', 'max:3'], + 'currency_code' => ['nullable', 'string', 'max:3'], + 'terms' => ['nullable', 'string'], + 'notes' => ['nullable', 'string'], + 'reference' => ['nullable', 'string', 'max:100'], + 'description'=> ['nullable', 'string'], + 'auto_renew' => ['boolean'], + 'renewal_notice_days' => ['nullable', 'integer', 'min:0'], + ]); + + $contract = Contract::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'contract_number' => Contract::generateContractNumber(), + 'created_by' => auth()->id(), + ...$data, + ]); + + return redirect()->route('finance.contracts.show', $contract) + ->with('success', 'Contract created.'); + } + + public function show(Contract $contract): Response + { + $this->authorize('view', $contract); + + $contract->load(['createdBy', 'renewals', 'contact']); + + return Inertia::render('Finance/Contracts/Show', [ + 'contract' => $contract, + 'breadcrumbs' => [ + ['label' => 'Finance'], + ['label' => 'Contracts', 'href' => route('finance.contracts.index')], + ['label' => $contract->title], + ], + ]); + } + + public function edit(Contract $contract): Response + { + $this->authorize('update', $contract); + + return Inertia::render('Finance/Contracts/Edit', [ + 'contract' => $contract, + 'contacts' => Contact::orderBy('name')->get(['id', 'name']), + 'breadcrumbs' => [ + ['label' => 'Finance'], + ['label' => 'Contracts', 'href' => route('finance.contracts.index')], + ['label' => $contract->title, 'href' => route('finance.contracts.show', $contract)], + ['label' => 'Edit'], + ], + ]); + } + + public function update(Request $request, Contract $contract): RedirectResponse + { + $this->authorize('update', $contract); + + $data = $request->validate([ + 'title' => ['required', 'string', 'max:255'], + 'reference' => ['nullable', 'string', 'max:100'], + 'contact_id' => ['nullable', Rule::exists('contacts', 'id')], + 'type' => ['required', Rule::in(['client', 'vendor', 'employee', 'employment', 'nda', 'other'])], + 'status' => ['nullable', Rule::in(['draft', 'active', 'expired', 'terminated'])], + 'value' => ['nullable', 'numeric', 'min:0'], + 'currency_code' => ['nullable', 'string', 'size:3'], + 'start_date' => ['nullable', 'date'], + 'end_date' => ['nullable', 'date', 'after_or_equal:start_date'], + 'auto_renew' => ['boolean'], + 'renewal_notice_days' => ['nullable', 'integer', 'min:0'], + 'description' => ['nullable', 'string'], + 'terms' => ['nullable', 'string'], + ]); + + $contract->update($data); + + return redirect()->route('finance.contracts.show', $contract) + ->with('success', 'Contract updated.'); + } + + public function destroy(Contract $contract): RedirectResponse + { + $this->authorize('delete', $contract); + + $contract->delete(); + + return redirect()->route('finance.contracts.index') + ->with('success', 'Contract deleted.'); + } + + public function activate(Contract $contract): RedirectResponse + { + $this->authorize('update', $contract); + + $contract->activate(); + + return back()->with('success', 'Contract activated.'); + } + + public function terminate(Request $request, Contract $contract): RedirectResponse + { + $this->authorize('update', $contract); + + $data = $request->validate([ + 'notes' => ['nullable', 'string'], + ]); + + $contract->terminate($data['notes'] ?? ''); + + return back()->with('success', 'Contract terminated.'); + } + + public function renew(Request $request, Contract $contract): RedirectResponse + { + $this->authorize('update', $contract); + + $data = $request->validate([ + 'new_end_date' => ['required', 'date', 'after:' . ($contract->end_date?->toDateString() ?? 'today')], + 'new_value' => ['nullable', 'numeric', 'min:0'], + 'notes' => ['nullable', 'string'], + ]); + + $contract->renew( + $data['new_end_date'], + isset($data['new_value']) ? (float) $data['new_value'] : null, + $data['notes'] ?? '', + auth()->id() + ); + + return back()->with('success', 'Contract renewed.'); + } +} diff --git a/erp/app/Modules/Finance/Http/Controllers/CreditNoteController.php b/erp/app/Modules/Finance/Http/Controllers/CreditNoteController.php new file mode 100644 index 00000000000..5a31e51212e --- /dev/null +++ b/erp/app/Modules/Finance/Http/Controllers/CreditNoteController.php @@ -0,0 +1,210 @@ +authorize('viewAny', CreditNote::class); + + $query = CreditNote::with(['contact']) + ->orderByDesc('issue_date'); + + if ($request->filled('status')) { + $query->where('status', $request->status); + } + + $creditNotes = $query->paginate(20); + + return Inertia::render('Finance/CreditNotes/Index', compact('creditNotes')); + } + + public function create(): Response + { + $this->authorize('create', CreditNote::class); + + $contacts = Contact::orderBy('name')->get(['id', 'name', 'type']); + $invoices = Invoice::where('status', 'sent') + ->orderByDesc('issue_date')->get(['id', 'number']); + $bills = Bill::where('status', 'received') + ->orderByDesc('issue_date')->get(['id', 'number']); + + return Inertia::render('Finance/CreditNotes/Create', compact('contacts', 'invoices', 'bills')); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', CreditNote::class); + + // Detect whether this is a Phase 103 request (no legacy required fields) + $isPhase103 = ! $request->has('reference'); + + if ($isPhase103) { + $data = $request->validate([ + 'issue_date' => 'required|date', + 'currency' => 'nullable|string|max:3', + 'reason' => 'nullable|string', + 'notes' => 'nullable|string', + 'original_invoice_id' => 'nullable|integer', + 'items' => 'required|array|min:1', + 'items.*.description' => 'required|string|max:255', + 'items.*.quantity' => 'required|numeric|min:0.01', + 'items.*.unit_price' => 'required|numeric|min:0', + ]); + + $creditNoteNumber = CreditNote::generateCreditNoteNumber(); + + $cn = CreditNote::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'credit_note_number' => $creditNoteNumber, + 'reference' => $creditNoteNumber, // satisfy NOT NULL if column exists + 'type' => 'sale', // satisfy enum NOT NULL + 'original_invoice_id' => $data['original_invoice_id'] ?? null, + 'status' => 'draft', + 'issue_date' => $data['issue_date'], + 'currency_code' => $data['currency'] ?? 'USD', + 'currency' => $data['currency'] ?? 'USD', + 'exchange_rate' => 1, + 'subtotal' => 0, + 'tax' => 0, + 'tax_total' => 0, + 'total' => 0, + 'amount_applied' => 0, + 'reason' => $data['reason'] ?? null, + 'notes' => $data['notes'] ?? null, + 'created_by' => auth()->id(), + ]); + + foreach ($data['items'] as $item) { + $cn->items()->create([ + 'description' => $item['description'], + 'quantity' => $item['quantity'], + 'unit_price' => $item['unit_price'], + 'tax_rate' => 0, + ]); + } + + $cn->recalculateTotals(); + } else { + // Legacy path (original implementation) + $data = $request->validate([ + 'reference' => 'required|string|max:100', + 'contact_id' => 'nullable|exists:contacts,id', + 'original_invoice_id' => 'nullable|exists:invoices,id', + 'original_bill_id' => 'nullable|exists:bills,id', + 'type' => 'required|in:sale,purchase', + 'issue_date' => 'required|date', + 'currency_code' => 'required|string|size:3', + 'exchange_rate' => 'required|numeric|min:0.000001', + 'notes' => 'nullable|string', + 'items' => 'required|array|min:1', + 'items.*.description' => 'required|string', + 'items.*.quantity' => 'required|numeric|min:0.01', + 'items.*.unit_price' => 'required|numeric|min:0', + 'items.*.tax_rate' => 'required|numeric|min:0|max:100', + ]); + + $cn = CreditNote::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'reference' => $data['reference'], + 'contact_id' => $data['contact_id'] ?? null, + 'original_invoice_id' => $data['original_invoice_id'] ?? null, + 'original_bill_id' => $data['original_bill_id'] ?? null, + 'type' => $data['type'], + 'status' => 'draft', + 'issue_date' => $data['issue_date'], + 'currency_code' => $data['currency_code'], + 'exchange_rate' => $data['exchange_rate'], + 'notes' => $data['notes'] ?? null, + 'subtotal' => 0, + 'tax_total' => 0, + 'total' => 0, + 'amount_applied' => 0, + ]); + + foreach ($data['items'] as $item) { + $cn->items()->create([ + 'description' => $item['description'], + 'quantity' => $item['quantity'], + 'unit_price' => $item['unit_price'], + 'tax_rate' => $item['tax_rate'], + 'line_total' => round($item['quantity'] * $item['unit_price'], 2), + ]); + } + + // Recalculate totals explicitly after items are created + $cn->refresh()->load('items'); + $subtotal = $cn->items->sum('line_total'); + $tax_total = $cn->items->sum(fn ($i) => $i->line_total * $i->tax_rate / 100); + $cn->update([ + 'subtotal' => $subtotal, + 'tax_total' => $tax_total, + 'total' => $subtotal + $tax_total, + ]); + } + + return redirect()->route('finance.credit-notes.show', $cn) + ->with('success', 'Credit note created.'); + } + + public function show(CreditNote $creditNote): Response + { + $this->authorize('view', $creditNote); + + $creditNote->load(['contact', 'invoice', 'bill', 'items']); + + return Inertia::render('Finance/CreditNotes/Show', compact('creditNote')); + } + + public function issue(CreditNote $creditNote): RedirectResponse + { + $this->authorize('update', $creditNote); + + abort_unless($creditNote->status === 'draft', 422, 'Only draft credit notes can be issued.'); + $creditNote->issue(); + + return back()->with('success', 'Credit note issued.'); + } + + public function apply(CreditNote $creditNote): RedirectResponse + { + $this->authorize('update', $creditNote); + + abort_unless($creditNote->status === 'issued', 422, 'Only issued credit notes can be applied.'); + $creditNote->apply(); + + return back()->with('success', 'Credit note applied.'); + } + + public function void(CreditNote $creditNote): RedirectResponse + { + $this->authorize('update', $creditNote); + + abort_unless(in_array($creditNote->status, ['draft', 'issued']), 422, 'Cannot void applied credit notes.'); + $creditNote->void(); + + return back()->with('success', 'Credit note voided.'); + } + + public function destroy(CreditNote $creditNote): RedirectResponse + { + $this->authorize('delete', $creditNote); + + abort_unless($creditNote->status === 'draft', 422, 'Only draft credit notes can be deleted.'); + $creditNote->delete(); + + return redirect()->route('finance.credit-notes.index') + ->with('success', 'Credit note deleted.'); + } +} diff --git a/erp/app/Modules/Finance/Http/Controllers/CurrencyController.php b/erp/app/Modules/Finance/Http/Controllers/CurrencyController.php new file mode 100644 index 00000000000..31da6e6a6aa --- /dev/null +++ b/erp/app/Modules/Finance/Http/Controllers/CurrencyController.php @@ -0,0 +1,121 @@ +authorize('viewAny', Currency::class); + + $currencies = Currency::withoutGlobalScopes() + ->where('tenant_id', app('tenant')->id) + ->orderBy('code') + ->get(); + + return Inertia::render('Finance/Currencies/Index', [ + 'currencies' => $currencies, + ]); + } + + public function create(): Response + { + $this->authorize('create', Currency::class); + + return Inertia::render('Finance/Currencies/Create'); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', Currency::class); + + $validated = $request->validate([ + 'code' => ['required', 'string', 'size:3', 'regex:/^[A-Z]{3}$/'], + 'name' => ['required', 'string', 'max:100'], + 'symbol' => ['required', 'string', 'max:10'], + 'decimal_places' => ['sometimes', 'integer', 'min:0', 'max:4'], + 'rounding' => ['sometimes', 'numeric', 'min:0'], + 'is_base' => ['sometimes', 'boolean'], + 'is_active' => ['sometimes', 'boolean'], + ]); + + $tenantId = app('tenant')->id; + $validated['tenant_id'] = $tenantId; + $validated['code'] = strtoupper($validated['code']); + + $isBase = $validated['is_base'] ?? false; + unset($validated['is_base']); + + $currency = Currency::create($validated); + + if ($isBase) { + $currency->setAsBase(); + } + + return redirect()->route('finance.currencies.index')->with('success', 'Currency created.'); + } + + public function edit(Currency $currency): Response + { + $this->authorize('update', $currency); + + return Inertia::render('Finance/Currencies/Edit', [ + 'currency' => $currency, + ]); + } + + public function update(Request $request, Currency $currency): RedirectResponse + { + $this->authorize('update', $currency); + + $validated = $request->validate([ + 'code' => ['required', 'string', 'size:3', 'regex:/^[A-Z]{3}$/'], + 'name' => ['required', 'string', 'max:100'], + 'symbol' => ['required', 'string', 'max:10'], + 'decimal_places' => ['sometimes', 'integer', 'min:0', 'max:4'], + 'rounding' => ['sometimes', 'numeric', 'min:0'], + 'is_base' => ['sometimes', 'boolean'], + 'is_active' => ['sometimes', 'boolean'], + ]); + + $isBase = $validated['is_base'] ?? false; + unset($validated['is_base']); + + $currency->update($validated); + + if ($isBase) { + $currency->setAsBase(); + } + + return redirect()->route('finance.currencies.index')->with('success', 'Currency updated.'); + } + + public function destroy(Currency $currency): RedirectResponse + { + $this->authorize('delete', $currency); + + if ($currency->is_base) { + abort(422, 'Cannot delete the base currency.'); + } + + $currency->delete(); + + return redirect()->route('finance.currencies.index')->with('success', 'Currency deleted.'); + } + + public function setBase(Request $request, Currency $currency): RedirectResponse + { + $this->authorize('update', $currency); + + $currency->setAsBase(); + + return redirect()->route('finance.currencies.index')->with('success', 'Base currency updated.'); + } +} diff --git a/erp/app/Modules/Finance/Http/Controllers/CustomerCreditController.php b/erp/app/Modules/Finance/Http/Controllers/CustomerCreditController.php new file mode 100644 index 00000000000..611b4162874 --- /dev/null +++ b/erp/app/Modules/Finance/Http/Controllers/CustomerCreditController.php @@ -0,0 +1,100 @@ +authorize('viewAny', CustomerCredit::class); + $customerCredits = CustomerCredit::where('tenant_id', app('tenant')->id) + ->latest() + ->paginate(20); + return Inertia::render('Finance/CustomerCredits/Index', compact('customerCredits')); + } + + public function create(): Response + { + $this->authorize('create', CustomerCredit::class); + return Inertia::render('Finance/CustomerCredits/Create'); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', CustomerCredit::class); + $validated = $request->validate([ + 'customer_name' => 'required|string|max:255', + 'credit_amount' => 'required|numeric|min:0', + 'expiry_date' => 'nullable|date', + ]); + $validated['tenant_id'] = app('tenant')->id; + $validated['created_by'] = auth()->id(); + CustomerCredit::create($validated); + return redirect()->route('finance.customer-credits.index') + ->with('success', 'Customer credit created.'); + } + + public function show(CustomerCredit $customerCredit): Response + { + $this->authorize('view', $customerCredit); + return Inertia::render('Finance/CustomerCredits/Show', compact('customerCredit')); + } + + public function edit(CustomerCredit $customerCredit): Response + { + $this->authorize('update', $customerCredit); + return Inertia::render('Finance/CustomerCredits/Edit', compact('customerCredit')); + } + + public function update(Request $request, CustomerCredit $customerCredit): RedirectResponse + { + $this->authorize('update', $customerCredit); + $validated = $request->validate([ + 'customer_name' => 'sometimes|required|string|max:255', + 'credit_amount' => 'sometimes|required|numeric|min:0', + 'expiry_date' => 'nullable|date', + ]); + $customerCredit->update($validated); + return redirect()->route('finance.customer-credits.index') + ->with('success', 'Customer credit updated.'); + } + + public function destroy(CustomerCredit $customerCredit): RedirectResponse + { + $this->authorize('delete', $customerCredit); + $customerCredit->delete(); + return redirect()->route('finance.customer-credits.index') + ->with('success', 'Customer credit deleted.'); + } + + public function issue(CustomerCredit $customerCredit): RedirectResponse + { + $this->authorize('issue', $customerCredit); + $customerCredit->issue(auth()->id()); + return redirect()->route('finance.customer-credits.index') + ->with('success', 'Customer credit issued.'); + } + + public function expire(CustomerCredit $customerCredit): RedirectResponse + { + $this->authorize('expire', $customerCredit); + $customerCredit->expire(); + return redirect()->route('finance.customer-credits.index') + ->with('success', 'Customer credit expired.'); + } + + public function cancel(CustomerCredit $customerCredit): RedirectResponse + { + $this->authorize('cancel', $customerCredit); + $customerCredit->cancel(); + return redirect()->route('finance.customer-credits.index') + ->with('success', 'Customer credit cancelled.'); + } +} diff --git a/erp/app/Modules/Finance/Http/Controllers/CustomerGroupController.php b/erp/app/Modules/Finance/Http/Controllers/CustomerGroupController.php new file mode 100644 index 00000000000..8f66787c05d --- /dev/null +++ b/erp/app/Modules/Finance/Http/Controllers/CustomerGroupController.php @@ -0,0 +1,114 @@ +authorize('viewAny', CustomerGroup::class); + + $customerGroups = CustomerGroup::latest() + ->paginate(20) + ->withQueryString(); + + return Inertia::render('Finance/CustomerGroups/Index', [ + 'customerGroups' => $customerGroups, + ]); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', CustomerGroup::class); + + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'description' => 'nullable|string', + 'discount_percent' => 'nullable|numeric|min:0|max:100', + 'credit_limit' => 'nullable|numeric|min:0', + 'payment_term_id' => 'nullable|exists:payment_terms,id', + 'currency' => 'nullable|string|max:3', + 'is_active' => 'boolean', + ]); + + $customerGroup = CustomerGroup::create([ + ...$validated, + 'tenant_id' => auth()->user()->tenant_id, + ]); + + return redirect()->route('finance.customer-groups.show', $customerGroup) + ->with('success', 'Customer group created successfully.'); + } + + public function show(CustomerGroup $customerGroup): Response + { + $this->authorize('view', $customerGroup); + + $customerGroup->load('paymentTerm'); + $members = $customerGroup->members()->paginate(10); + + return Inertia::render('Finance/CustomerGroups/Show', [ + 'customerGroup' => $customerGroup, + 'members' => $members, + ]); + } + + public function update(Request $request, CustomerGroup $customerGroup): RedirectResponse + { + $this->authorize('update', $customerGroup); + + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'description' => 'nullable|string', + 'discount_percent' => 'nullable|numeric|min:0|max:100', + 'credit_limit' => 'nullable|numeric|min:0', + 'payment_term_id' => 'nullable|exists:payment_terms,id', + 'currency' => 'nullable|string|max:3', + 'is_active' => 'boolean', + ]); + + $customerGroup->update($validated); + + return redirect()->back()->with('success', 'Customer group updated successfully.'); + } + + public function addMember(Request $request, CustomerGroup $customerGroup): RedirectResponse + { + $this->authorize('update', $customerGroup); + + $validated = $request->validate([ + 'contact_id' => 'required|exists:contacts,id', + ]); + + $customerGroup->members()->syncWithoutDetaching([$validated['contact_id']]); + + return redirect()->back()->with('success', 'Contact added to group successfully.'); + } + + public function removeMember(CustomerGroup $customerGroup, Contact $contact): RedirectResponse + { + $this->authorize('update', $customerGroup); + + $customerGroup->members()->detach($contact->id); + + return redirect()->back()->with('success', 'Contact removed from group successfully.'); + } + + public function destroy(CustomerGroup $customerGroup): RedirectResponse + { + $this->authorize('delete', $customerGroup); + + $customerGroup->delete(); + + return redirect()->route('finance.customer-groups.index') + ->with('success', 'Customer group deleted successfully.'); + } +} diff --git a/erp/app/Modules/Finance/Http/Controllers/CustomerPortalController.php b/erp/app/Modules/Finance/Http/Controllers/CustomerPortalController.php new file mode 100644 index 00000000000..c0c047f30ae --- /dev/null +++ b/erp/app/Modules/Finance/Http/Controllers/CustomerPortalController.php @@ -0,0 +1,125 @@ +authorize('create', Invoice::class); + + $validated = $request->validate([ + 'email' => ['required', 'email'], + 'expires_days' => ['nullable', 'integer', 'min:1', 'max:365'], + ]); + + $days = $validated['expires_days'] ?? 30; + + $token = CustomerPortalToken::generate( + $contact->tenant_id, + $contact->id, + $validated['email'], + (int) $days, + ); + + return redirect()->back()->with('portal_url', route('portal.show', $token->token)); + } + + /** + * Public portal: show the customer's invoices. + */ + public function show(string $token): Response + { + $portalToken = CustomerPortalToken::where('token', $token)->firstOrFail(); + + if ($portalToken->is_expired) { + abort(403, 'This portal link has expired.'); + } + + $portalToken->update(['last_accessed_at' => now()]); + + $contact = $portalToken->contact()->with([ + 'invoices' => function ($query) { + $query->whereIn('status', ['sent', 'partial', 'paid']) + ->latest('issue_date') + ->limit(20); + }, + ])->firstOrFail(); + + return Inertia::render('Portal/Show', [ + 'token' => $token, + 'contact' => [ + 'id' => $contact->id, + 'name' => $contact->name, + ], + 'invoices' => $contact->invoices->map(fn (Invoice $inv) => [ + 'id' => $inv->id, + 'number' => $inv->number, + 'issue_date' => $inv->issue_date?->toDateString(), + 'due_date' => $inv->due_date?->toDateString(), + 'status' => $inv->status, + 'total' => $inv->total, + 'amount_due' => $inv->amount_due, + ]), + ]); + } + + /** + * Public portal: show a specific invoice detail. + */ + public function invoice(string $token, int $invoiceId): Response + { + $portalToken = CustomerPortalToken::where('token', $token)->firstOrFail(); + + if ($portalToken->is_expired) { + abort(403, 'This portal link has expired.'); + } + + $invoice = Invoice::with('items')->findOrFail($invoiceId); + + if ($invoice->contact_id !== $portalToken->contact_id) { + abort(403, 'Access denied.'); + } + + return Inertia::render('Portal/Invoice', [ + 'token' => $token, + 'invoice' => [ + 'id' => $invoice->id, + 'number' => $invoice->number, + 'issue_date' => $invoice->issue_date?->toDateString(), + 'due_date' => $invoice->due_date?->toDateString(), + 'status' => $invoice->status, + 'notes' => $invoice->notes, + 'subtotal' => $invoice->subtotal, + 'tax_total' => $invoice->tax_total, + 'total' => $invoice->total, + 'amount_due' => $invoice->amount_due, + 'items' => $invoice->items->map(fn ($item) => [ + 'id' => $item->id, + 'description' => $item->description, + 'quantity' => $item->quantity, + 'unit_price' => $item->unit_price, + 'tax_rate' => $item->tax_rate, + 'line_total' => $item->line_total, + ]), + ], + 'contact' => [ + 'id' => $portalToken->contact->id, + 'name' => $portalToken->contact->name, + ], + ]); + } +} diff --git a/erp/app/Modules/Finance/Http/Controllers/DebitNoteController.php b/erp/app/Modules/Finance/Http/Controllers/DebitNoteController.php new file mode 100644 index 00000000000..0f10b2fd683 --- /dev/null +++ b/erp/app/Modules/Finance/Http/Controllers/DebitNoteController.php @@ -0,0 +1,72 @@ +authorize('viewAny', DebitNote::class); + $debitNotes = DebitNote::where('tenant_id', app('tenant')->id) + ->latest() + ->paginate(20); + return Inertia::render('Finance/DebitNotes/Index', compact('debitNotes')); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', DebitNote::class); + $validated = $request->validate([ + 'vendor_id' => 'nullable|exists:contacts,id', + 'vendor_bill_id'=> 'nullable|exists:vendor_bills,id', + 'issue_date' => 'required|date', + 'currency' => 'nullable|string|max:3', + 'reason' => 'nullable|string', + ]); + $validated['tenant_id'] = app('tenant')->id; + $validated['created_by'] = auth()->id(); + DebitNote::create($validated); + return back()->with('success', 'Debit note created.'); + } + + public function show(DebitNote $debitNote): Response + { + $this->authorize('view', $debitNote); + return Inertia::render('Finance/DebitNotes/Show', compact('debitNote')); + } + + public function issue(DebitNote $debitNote): RedirectResponse + { + $this->authorize('update', $debitNote); + $debitNote->issue(); + return back()->with('success', 'Debit note issued.'); + } + + public function apply(DebitNote $debitNote): RedirectResponse + { + $this->authorize('update', $debitNote); + $debitNote->apply(); + return back()->with('success', 'Debit note applied.'); + } + + public function void(DebitNote $debitNote): RedirectResponse + { + $this->authorize('update', $debitNote); + $debitNote->void(); + return back()->with('success', 'Debit note voided.'); + } + + public function destroy(DebitNote $debitNote): RedirectResponse + { + $this->authorize('delete', $debitNote); + $debitNote->delete(); + return back()->with('success', 'Debit note deleted.'); + } +} diff --git a/erp/app/Modules/Finance/Http/Controllers/DeliveryNoteController.php b/erp/app/Modules/Finance/Http/Controllers/DeliveryNoteController.php new file mode 100644 index 00000000000..1fc448b04e0 --- /dev/null +++ b/erp/app/Modules/Finance/Http/Controllers/DeliveryNoteController.php @@ -0,0 +1,119 @@ +authorize('viewAny', DeliveryNote::class); + $deliveryNotes = DeliveryNote::with(['contact', 'salesOrder', 'invoice']) + ->orderByDesc('created_at') + ->paginate(25); + return Inertia::render('Finance/DeliveryNotes/Index', compact('deliveryNotes')); + } + + public function create(Request $request): Response + { + $this->authorize('create', DeliveryNote::class); + $contacts = Contact::where('type', 'customer')->orWhere('type', 'both')->orderBy('name')->get(['id', 'name']); + $salesOrders = SalesOrder::whereIn('status', ['confirmed', 'invoiced'])->orderByDesc('order_date')->get(['id', 'reference', 'number']); + $invoices = Invoice::whereIn('status', ['sent', 'partial'])->orderByDesc('issue_date')->get(['id', 'reference']); + $products = Product::orderBy('name')->get(['id', 'name', 'sku']); + $salesOrderId = $request->get('sales_order_id'); + $invoiceId = $request->get('invoice_id'); + return Inertia::render('Finance/DeliveryNotes/Create', compact('contacts', 'salesOrders', 'invoices', 'products', 'salesOrderId', 'invoiceId')); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', DeliveryNote::class); + $data = $request->validate([ + 'reference' => 'required|string|max:100|unique:delivery_notes,reference', + 'sales_order_id' => 'nullable|exists:sales_orders,id', + 'invoice_id' => 'nullable|exists:invoices,id', + 'contact_id' => 'nullable|exists:contacts,id', + 'carrier' => 'nullable|string|max:100', + 'tracking_number' => 'nullable|string|max:100', + 'dispatch_date' => 'nullable|date', + 'notes' => 'nullable|string', + 'items' => 'required|array|min:1', + 'items.*.description' => 'required|string', + 'items.*.product_id' => 'nullable|exists:products,id', + 'items.*.quantity' => 'required|numeric|min:0.01', + ]); + + $dn = DeliveryNote::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'reference' => $data['reference'], + 'sales_order_id' => $data['sales_order_id'] ?? null, + 'invoice_id' => $data['invoice_id'] ?? null, + 'contact_id' => $data['contact_id'] ?? null, + 'carrier' => $data['carrier'] ?? null, + 'tracking_number' => $data['tracking_number'] ?? null, + 'dispatch_date' => $data['dispatch_date'] ?? null, + 'status' => 'draft', + 'notes' => $data['notes'] ?? null, + ]); + + foreach ($data['items'] as $item) { + $dn->items()->create([ + 'product_id' => $item['product_id'] ?? null, + 'description' => $item['description'], + 'quantity' => $item['quantity'], + ]); + } + + return redirect()->route('finance.delivery-notes.show', $dn)->with('success', 'Delivery note created.'); + } + + public function show(DeliveryNote $deliveryNote): Response + { + $this->authorize('view', $deliveryNote); + $deliveryNote->load(['contact', 'salesOrder', 'invoice', 'items.product']); + return Inertia::render('Finance/DeliveryNotes/Show', compact('deliveryNote')); + } + + public function dispatch(DeliveryNote $deliveryNote, Request $request): RedirectResponse + { + $this->authorize('update', $deliveryNote); + abort_unless($deliveryNote->status === 'draft', 422, 'Only draft notes can be dispatched.'); + $request->validate(['dispatch_date' => 'nullable|date']); + $deliveryNote->update([ + 'status' => 'dispatched', + 'dispatch_date' => $request->dispatch_date ?? now()->toDateString(), + ]); + return back()->with('success', 'Delivery note dispatched.'); + } + + public function deliver(DeliveryNote $deliveryNote, Request $request): RedirectResponse + { + $this->authorize('update', $deliveryNote); + abort_unless($deliveryNote->status === 'dispatched', 422, 'Only dispatched notes can be marked delivered.'); + $request->validate(['delivery_date' => 'nullable|date']); + $deliveryNote->update([ + 'status' => 'delivered', + 'delivery_date' => $request->delivery_date ?? now()->toDateString(), + ]); + return back()->with('success', 'Delivery confirmed.'); + } + + public function destroy(DeliveryNote $deliveryNote): RedirectResponse + { + $this->authorize('delete', $deliveryNote); + abort_unless($deliveryNote->status === 'draft', 422, 'Only draft delivery notes can be deleted.'); + $deliveryNote->delete(); + return redirect()->route('finance.delivery-notes.index')->with('success', 'Delivery note deleted.'); + } +} diff --git a/erp/app/Modules/Finance/Http/Controllers/DocumentTemplateController.php b/erp/app/Modules/Finance/Http/Controllers/DocumentTemplateController.php new file mode 100644 index 00000000000..70a10eb7cba --- /dev/null +++ b/erp/app/Modules/Finance/Http/Controllers/DocumentTemplateController.php @@ -0,0 +1,133 @@ +authorize('viewAny', DocumentTemplate::class); + + $query = DocumentTemplate::orderByDesc('created_at'); + + if ($request->filled('type')) { + $query->forType($request->type); + } + + $templates = $query->paginate(15)->withQueryString(); + + return Inertia::render('Finance/DocumentTemplates/Index', [ + 'templates' => $templates, + 'filter' => ['type' => $request->get('type', '')], + ]); + } + + public function create(): Response + { + $this->authorize('create', DocumentTemplate::class); + + return Inertia::render('Finance/DocumentTemplates/Create'); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', DocumentTemplate::class); + + $data = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'type' => ['required', Rule::in(['invoice', 'quote', 'letter', 'receipt', 'purchase_order'])], + 'subject' => ['nullable', 'string', 'max:255'], + 'body' => ['required', 'string'], + 'variables' => ['nullable', 'array'], + 'is_default' => ['boolean'], + 'is_active' => ['boolean'], + ]); + + $template = DocumentTemplate::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'name' => $data['name'], + 'type' => $data['type'], + 'subject' => $data['subject'] ?? null, + 'body' => $data['body'], + 'variables' => $data['variables'] ?? null, + 'is_default' => $data['is_default'] ?? false, + 'is_active' => $data['is_active'] ?? true, + ]); + + return redirect()->route('finance.document-templates.show', $template) + ->with('success', 'Document template created.'); + } + + public function show(DocumentTemplate $documentTemplate): Response + { + $this->authorize('view', $documentTemplate); + + return Inertia::render('Finance/DocumentTemplates/Show', [ + 'template' => $documentTemplate, + ]); + } + + public function edit(DocumentTemplate $documentTemplate): Response + { + $this->authorize('create', DocumentTemplate::class); + + return Inertia::render('Finance/DocumentTemplates/Edit', [ + 'template' => $documentTemplate, + ]); + } + + public function update(Request $request, DocumentTemplate $documentTemplate): RedirectResponse + { + $this->authorize('create', DocumentTemplate::class); + + $data = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'type' => ['required', Rule::in(['invoice', 'quote', 'letter', 'receipt', 'purchase_order'])], + 'subject' => ['nullable', 'string', 'max:255'], + 'body' => ['required', 'string'], + 'variables' => ['nullable', 'array'], + 'is_default' => ['boolean'], + 'is_active' => ['boolean'], + ]); + + $documentTemplate->update($data); + + return redirect()->route('finance.document-templates.show', $documentTemplate) + ->with('success', 'Document template updated.'); + } + + public function destroy(DocumentTemplate $documentTemplate): RedirectResponse + { + $this->authorize('delete', $documentTemplate); + + $documentTemplate->delete(); + + return redirect()->route('finance.document-templates.index') + ->with('success', 'Document template deleted.'); + } + + public function preview(Request $request): JsonResponse + { + $this->authorize('viewAny', DocumentTemplate::class); + + $request->validate([ + 'type' => ['required', Rule::in(['invoice', 'quote', 'letter', 'receipt', 'purchase_order'])], + 'data' => ['nullable', 'json'], + ]); + + $template = DocumentTemplate::forType($request->type)->active()->firstOrFail(); + + $data = $request->filled('data') ? json_decode($request->data, true) : []; + + return response()->json(['html' => $template->render($data)]); + } +} diff --git a/erp/app/Modules/Finance/Http/Controllers/ExchangeRateController.php b/erp/app/Modules/Finance/Http/Controllers/ExchangeRateController.php new file mode 100644 index 00000000000..03355871d41 --- /dev/null +++ b/erp/app/Modules/Finance/Http/Controllers/ExchangeRateController.php @@ -0,0 +1,178 @@ +authorize('viewAny', ExchangeRate::class); + + $tenantId = app('tenant')->id; + + $query = ExchangeRate::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->orderByDesc('effective_date') + ->orderBy('base_currency') + ->orderBy('quote_currency'); + + if ($request->filled('from_currency')) { + $from = $request->input('from_currency'); + $query->where(function ($q) use ($from) { + $q->where('from_currency', $from) + ->orWhere(function ($q2) use ($from) { + $q2->whereNull('from_currency')->where('base_currency', $from); + }); + }); + } + + $rates = $query->paginate(20); + + $currencies = Currency::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->active() + ->orderBy('code') + ->get(); + + return Inertia::render('Finance/ExchangeRates/Index', [ + 'rates' => $rates, + 'currencies' => $currencies, + ]); + } + + public function create(): Response + { + $this->authorize('create', ExchangeRate::class); + + return Inertia::render('Finance/ExchangeRates/Create'); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', ExchangeRate::class); + + $tenantId = app('tenant')->id; + + $validated = $request->validate([ + 'from_currency' => ['sometimes', 'nullable', 'string', 'size:3'], + 'to_currency' => ['sometimes', 'nullable', 'string', 'size:3'], + 'base_currency' => ['required_without:from_currency', 'nullable', 'string', 'size:3'], + 'quote_currency' => ['required_without:to_currency', 'nullable', 'string', 'size:3', 'different:base_currency'], + 'rate' => ['required', 'numeric', 'min:0.000001'], + 'effective_date' => [ + 'required', + 'date', + Rule::unique('exchange_rates')->where(fn ($q) => $q + ->where('base_currency', $request->input('base_currency') ?? $request->input('from_currency')) + ->where('quote_currency', $request->input('quote_currency') ?? $request->input('to_currency')) + ->where('tenant_id', $tenantId) + ->whereDate('effective_date', $request->input('effective_date')) + ), + ], + 'source' => ['nullable', 'string', 'max:100'], + ]); + + ExchangeRate::create(array_merge($validated, ['tenant_id' => $tenantId])); + + return redirect()->back()->with('success', 'Exchange rate created.'); + } + + public function destroy(ExchangeRate $exchangeRate): RedirectResponse + { + $this->authorize('delete', $exchangeRate); + + $exchangeRate->delete(); + + return redirect()->back()->with('success', 'Exchange rate deleted.'); + } + + public function convert(Request $request): Response + { + $this->authorize('viewAny', ExchangeRate::class); + + $tenantId = app('tenant')->id; + + $validated = $request->validate([ + 'amount' => ['nullable', 'numeric', 'min:0'], + 'from' => ['nullable', 'string', 'size:3'], + 'to' => ['nullable', 'string', 'size:3'], + 'date' => ['nullable', 'date'], + ]); + + $result = null; + $rate = null; + + if (!empty($validated['amount']) && !empty($validated['from']) && !empty($validated['to'])) { + $date = !empty($validated['date']) ? Carbon::parse($validated['date']) : null; + $rate = ExchangeRate::getRate($tenantId, $validated['from'], $validated['to'], $date); + if ($rate !== null) { + $result = ExchangeRate::convert($tenantId, (float) $validated['amount'], $validated['from'], $validated['to'], $date); + } + } + + $currencies = Currency::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->active() + ->orderBy('code') + ->get(); + + return Inertia::render('Finance/ExchangeRates/Convert', [ + 'currencies' => $currencies, + 'result' => $result, + 'rate' => $rate, + 'input' => $validated, + ]); + } + + public function report(Request $request): Response + { + $this->authorize('viewAny', ExchangeRate::class); + + $tenantId = $request->user()->tenant_id; + $today = now()->toDateString(); + $ago30 = now()->subDays(30)->toDateString(); + + // Get all unique currency pairs for this tenant + $pairs = ExchangeRate::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->select('base_currency', 'quote_currency') + ->distinct() + ->get(); + + $rows = []; + foreach ($pairs as $pair) { + $currentRate = ExchangeRate::getRate($tenantId, $pair->base_currency, $pair->quote_currency, $today); + $priorRate = ExchangeRate::getRate($tenantId, $pair->base_currency, $pair->quote_currency, $ago30); + + $change = null; + if ($priorRate !== null && $priorRate != 0 && $currentRate !== null) { + $change = round((($currentRate - $priorRate) / $priorRate) * 100, 2); + } + + $rows[] = [ + 'pair' => $pair->base_currency . '/' . $pair->quote_currency, + 'base_currency' => $pair->base_currency, + 'quote_currency' => $pair->quote_currency, + 'current_rate' => $currentRate, + 'prior_rate' => $priorRate, + 'change_pct' => $change, + ]; + } + + return Inertia::render('Finance/ExchangeRates/Report', [ + 'rows' => $rows, + 'asOf' => $today, + 'ago30' => $ago30, + ]); + } +} diff --git a/erp/app/Modules/Finance/Http/Controllers/ExpenseBudgetController.php b/erp/app/Modules/Finance/Http/Controllers/ExpenseBudgetController.php new file mode 100644 index 00000000000..a2544188112 --- /dev/null +++ b/erp/app/Modules/Finance/Http/Controllers/ExpenseBudgetController.php @@ -0,0 +1,146 @@ +authorize('viewAny', ExpenseBudget::class); + + $query = ExpenseBudget::query(); + + if ($request->filled('department')) { + $query->where('department', $request->input('department')); + } + + if ($request->filled('status')) { + $query->where('status', $request->input('status')); + } + + $expenseBudgets = $query->orderByDesc('id')->paginate(20); + + return Inertia::render('Finance/ExpenseBudgets/Index', [ + 'expenseBudgets' => $expenseBudgets, + 'filters' => $request->only(['department', 'status']), + ]); + } + + public function create(): Response + { + $this->authorize('create', ExpenseBudget::class); + + return Inertia::render('Finance/ExpenseBudgets/Create'); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', ExpenseBudget::class); + + $validated = $request->validate([ + 'department' => ['required', 'string', 'max:255'], + 'period' => ['required', 'string', 'max:255'], + 'allocated_amount' => ['nullable', 'numeric', 'min:0'], + 'category' => ['nullable', 'string', 'max:255'], + 'currency' => ['nullable', 'string', 'max:10'], + 'status' => ['nullable', 'string'], + 'notes' => ['nullable', 'string'], + 'owner_id' => ['nullable', 'integer'], + 'budget_code' => ['nullable', 'string', 'max:255'], + ]); + + ExpenseBudget::create([ + 'tenant_id' => app('tenant')->id, + 'created_by' => auth()->id(), + 'department' => $validated['department'], + 'period' => $validated['period'], + 'allocated_amount' => $validated['allocated_amount'] ?? 0, + 'category' => $validated['category'] ?? null, + 'currency' => $validated['currency'] ?? 'USD', + 'status' => $validated['status'] ?? 'active', + 'notes' => $validated['notes'] ?? null, + 'owner_id' => $validated['owner_id'] ?? null, + 'budget_code' => $validated['budget_code'] ?? null, + ]); + + return redirect()->route('finance.expense-budgets.index'); + } + + public function show(ExpenseBudget $expenseBudget): Response + { + $this->authorize('view', $expenseBudget); + + return Inertia::render('Finance/ExpenseBudgets/Show', [ + 'expenseBudget' => array_merge($expenseBudget->toArray(), [ + 'remaining_amount' => $expenseBudget->remaining_amount, + 'utilization_percent' => $expenseBudget->utilization_percent, + 'is_over_budget' => $expenseBudget->is_over_budget, + 'is_active' => $expenseBudget->is_active, + ]), + ]); + } + + public function edit(ExpenseBudget $expenseBudget): Response + { + $this->authorize('update', $expenseBudget); + + return Inertia::render('Finance/ExpenseBudgets/Edit', [ + 'expenseBudget' => $expenseBudget, + ]); + } + + public function update(Request $request, ExpenseBudget $expenseBudget): RedirectResponse + { + $this->authorize('update', $expenseBudget); + + $validated = $request->validate([ + 'department' => ['required', 'string', 'max:255'], + 'period' => ['required', 'string', 'max:255'], + 'allocated_amount' => ['nullable', 'numeric', 'min:0'], + 'category' => ['nullable', 'string', 'max:255'], + 'currency' => ['nullable', 'string', 'max:10'], + 'status' => ['nullable', 'string'], + 'notes' => ['nullable', 'string'], + 'owner_id' => ['nullable', 'integer'], + 'budget_code' => ['nullable', 'string', 'max:255'], + ]); + + $expenseBudget->update($validated); + + return redirect()->route('finance.expense-budgets.index'); + } + + public function destroy(ExpenseBudget $expenseBudget): RedirectResponse + { + $this->authorize('delete', $expenseBudget); + + $expenseBudget->delete(); + + return redirect()->route('finance.expense-budgets.index'); + } + + public function freeze(ExpenseBudget $expenseBudget): RedirectResponse + { + $this->authorize('freeze', $expenseBudget); + + $expenseBudget->freeze(); + + return redirect()->back(); + } + + public function close(ExpenseBudget $expenseBudget): RedirectResponse + { + $this->authorize('close', $expenseBudget); + + $expenseBudget->close(); + + return redirect()->back(); + } +} diff --git a/erp/app/Modules/Finance/Http/Controllers/ExpenseClaimController.php b/erp/app/Modules/Finance/Http/Controllers/ExpenseClaimController.php new file mode 100644 index 00000000000..da6cbbd1ae5 --- /dev/null +++ b/erp/app/Modules/Finance/Http/Controllers/ExpenseClaimController.php @@ -0,0 +1,140 @@ +authorize('viewAny', ExpenseClaim::class); + + $query = ExpenseClaim::with(['submittedBy']); + + if ($request->filled('status')) { + $query->where('status', $request->status); + } + + $claims = $query->latest()->paginate(20)->withQueryString(); + + return Inertia::render('Finance/ExpenseClaims/Index', [ + 'claims' => $claims, + 'filters' => $request->only('status'), + ]); + } + + public function create(): Response + { + $this->authorize('create', ExpenseClaim::class); + + return Inertia::render('Finance/ExpenseClaims/Create'); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', ExpenseClaim::class); + + $data = $request->validate([ + 'claim_date' => ['required', 'date'], + 'currency' => ['nullable', 'string', 'max:3'], + 'notes' => ['nullable', 'string'], + 'items' => ['required', 'array', 'min:1'], + 'items.*.category' => ['required', 'string'], + 'items.*.expense_date'=> ['required', 'date'], + 'items.*.description' => ['required', 'string'], + 'items.*.amount' => ['required', 'numeric', 'min:0.01'], + ]); + + $tenantId = app('tenant')->id; + + $claim = ExpenseClaim::create([ + 'tenant_id' => $tenantId, + 'reference' => ExpenseClaim::generateReference(), + 'submitted_by' => auth()->id(), + 'status' => 'draft', + 'claim_date' => $data['claim_date'], + 'currency' => $data['currency'] ?? 'USD', + 'notes' => $data['notes'] ?? null, + 'total_amount' => 0, + ]); + + foreach ($data['items'] as $item) { + ExpenseItem::create([ + 'tenant_id' => $tenantId, + 'expense_claim_id' => $claim->id, + 'category' => $item['category'], + 'expense_date' => $item['expense_date'], + 'description' => $item['description'], + 'amount' => $item['amount'], + 'receipt_url' => $item['receipt_url'] ?? null, + ]); + } + + $claim->recalculate(); + + return redirect()->route('finance.expense-claims.show', $claim); + } + + public function show(ExpenseClaim $expenseClaim): Response + { + $this->authorize('view', $expenseClaim); + + $expenseClaim->load(['submittedBy', 'approvedBy', 'items']); + + return Inertia::render('Finance/ExpenseClaims/Show', [ + 'claim' => $expenseClaim, + ]); + } + + public function destroy(ExpenseClaim $expenseClaim): RedirectResponse + { + $this->authorize('delete', $expenseClaim); + + $expenseClaim->delete(); + + return redirect()->route('finance.expense-claims.index'); + } + + public function submit(ExpenseClaim $expenseClaim): RedirectResponse + { + $this->authorize('update', $expenseClaim); + + $expenseClaim->submit(); + + return back()->with('success', 'Claim submitted.'); + } + + public function approve(ExpenseClaim $expenseClaim): RedirectResponse + { + $this->authorize('update', $expenseClaim); + + $expenseClaim->approve(auth()->id()); + + return back()->with('success', 'Claim approved.'); + } + + public function reject(ExpenseClaim $expenseClaim): RedirectResponse + { + $this->authorize('update', $expenseClaim); + + $expenseClaim->reject(); + + return back()->with('success', 'Claim rejected.'); + } + + public function markPaid(ExpenseClaim $expenseClaim): RedirectResponse + { + $this->authorize('update', $expenseClaim); + + $expenseClaim->markPaid(); + + return back()->with('success', 'Claim marked as paid.'); + } +} diff --git a/erp/app/Modules/Finance/Http/Controllers/FinanceDashboardController.php b/erp/app/Modules/Finance/Http/Controllers/FinanceDashboardController.php new file mode 100644 index 00000000000..13a1317daa5 --- /dev/null +++ b/erp/app/Modules/Finance/Http/Controllers/FinanceDashboardController.php @@ -0,0 +1,63 @@ +user()->tenant_id; + + $openInvoices = Invoice::where('tenant_id', $tenantId)->whereNotIn('status', ['paid', 'cancelled'])->count(); + $openInvoicesTotal = Invoice::where('tenant_id', $tenantId)->whereNotIn('status', ['paid', 'cancelled']) + ->with(['items', 'payments'])->get()->sum(fn($i) => $i->amount_due); + $overdueCount = Invoice::where('tenant_id', $tenantId)->whereNotIn('status', ['paid', 'cancelled']) + ->whereNotNull('due_date')->where('due_date', '<', now())->count(); + $unpaidBills = Bill::where('tenant_id', $tenantId)->whereNotIn('status', ['paid', 'cancelled']) + ->with(['items', 'payments'])->get()->sum(fn($b) => $b->amount_due); + $totalContacts = Contact::where('tenant_id', $tenantId)->count(); + $revenueThisMonth = Invoice::where('tenant_id', $tenantId)->whereNotIn('status', ['cancelled']) + ->whereYear('issue_date', now()->year)->whereMonth('issue_date', now()->month) + ->with('items')->get()->sum(fn($i) => $i->total); + + $recentInvoices = Invoice::where('tenant_id', $tenantId) + ->with(['contact', 'items', 'payments']) + ->latest() + ->take(6) + ->get() + ->map(fn($i) => [ + 'id' => $i->id, + 'number' => $i->number, + 'contact' => $i->contact?->name, + 'total' => round($i->total, 2), + 'amount_due' => round($i->amount_due, 2), + 'status' => $i->status, + 'issue_date' => $i->issue_date, + ]); + + $recentBills = Bill::where('tenant_id', $tenantId) + ->with(['items', 'payments']) + ->latest() + ->take(5) + ->get() + ->map(fn($b) => [ + 'id' => $b->id, + 'status' => $b->status, + 'total' => round($b->total ?? 0, 2), + 'amount_due' => round($b->amount_due ?? 0, 2), + 'issue_date' => $b->issue_date, + ]); + + return Inertia::render('Finance/Dashboard', compact( + 'openInvoices', 'openInvoicesTotal', 'overdueCount', 'unpaidBills', + 'totalContacts', 'revenueThisMonth', 'recentInvoices', 'recentBills' + )); + } +} diff --git a/erp/app/Modules/Finance/Http/Controllers/FinanceReportController.php b/erp/app/Modules/Finance/Http/Controllers/FinanceReportController.php new file mode 100644 index 00000000000..2efa1549a2a --- /dev/null +++ b/erp/app/Modules/Finance/Http/Controllers/FinanceReportController.php @@ -0,0 +1,307 @@ +authorize('viewAny', Invoice::class); + + $tenantId = app('tenant')->id; + $dateFrom = $request->get('date_from', now()->startOfYear()->toDateString()); + $dateTo = $request->get('date_to', now()->toDateString()); + + // Revenue: sum payments for paid invoices + $revenueRows = DB::table('invoices') + ->join('payments', 'payments.invoice_id', '=', 'invoices.id') + ->where('invoices.tenant_id', $tenantId) + ->where('invoices.status', 'paid') + ->whereNull('invoices.deleted_at') + ->whereBetween('payments.payment_date', [$dateFrom, $dateTo]) + ->select( + DB::raw('YEAR(payments.payment_date) as year'), + DB::raw('MONTH(payments.payment_date) as month'), + DB::raw('SUM(payments.amount) as revenue') + ) + ->groupBy('year', 'month') + ->orderBy('year') + ->orderBy('month') + ->get(); + + // Expenses: sum bill payments for paid bills + $expenseRows = DB::table('bills') + ->join('bill_payments', 'bill_payments.bill_id', '=', 'bills.id') + ->where('bills.tenant_id', $tenantId) + ->where('bills.status', 'paid') + ->whereNull('bills.deleted_at') + ->whereBetween('bill_payments.payment_date', [$dateFrom, $dateTo]) + ->select( + DB::raw('YEAR(bill_payments.payment_date) as year'), + DB::raw('MONTH(bill_payments.payment_date) as month'), + DB::raw('SUM(bill_payments.amount) as expenses') + ) + ->groupBy('year', 'month') + ->orderBy('year') + ->orderBy('month') + ->get() + ->keyBy(fn ($r) => $r->year . '-' . $r->month); + + $totalRevenue = (float) $revenueRows->sum('revenue'); + $totalExpenses = (float) $expenseRows->sum('expenses'); + $netProfit = $totalRevenue - $totalExpenses; + + $breakdown = $revenueRows->map(function ($row) use ($expenseRows) { + $key = $row->year . '-' . $row->month; + $expenses = (float) ($expenseRows->get($key)?->expenses ?? 0); + $revenue = (float) $row->revenue; + $net = $revenue - $expenses; + $margin = $revenue > 0 ? round($net / $revenue * 100, 2) : 0; + return [ + 'year' => $row->year, + 'month' => $row->month, + 'label' => Carbon::createFromDate($row->year, $row->month, 1)->format('M Y'), + 'revenue' => $revenue, + 'expenses' => $expenses, + 'net' => $net, + 'margin' => $margin, + ]; + })->values(); + + return Inertia::render('Finance/Reports/ProfitLossSummary', [ + 'date_from' => $dateFrom, + 'date_to' => $dateTo, + 'total_revenue' => $totalRevenue, + 'total_expenses' => $totalExpenses, + 'net_profit' => $netProfit, + 'breakdown' => $breakdown, + ]); + } + + // GET /finance/reports/aged-receivables + public function agedReceivables(Request $request): Response + { + $this->authorize('viewAny', Invoice::class); + + $tenantId = app('tenant')->id; + $asOf = $request->get('as_of', now()->toDateString()); + $asOfDate = Carbon::parse($asOf); + + $invoices = Invoice::with(['contact', 'items', 'payments']) + ->where('tenant_id', $tenantId) + ->whereNotIn('status', ['draft', 'paid', 'cancelled']) + ->get() + ->map(function ($inv) use ($asOfDate) { + $daysOverdue = 0; + $bucket = 'current'; + if ($inv->due_date) { + $diff = $asOfDate->diffInDays($inv->due_date, false); + $daysOverdue = (int) max(0, $diff * -1); + $bucket = match (true) { + $daysOverdue === 0 => 'current', + $daysOverdue <= 30 => '1-30', + $daysOverdue <= 60 => '31-60', + $daysOverdue <= 90 => '61-90', + default => '91+', + }; + } + return [ + 'id' => $inv->id, + 'number' => $inv->number, + 'customer' => $inv->contact?->name ?? '—', + 'due_date' => $inv->due_date?->toDateString(), + 'amount' => (float) $inv->total, + 'amount_due' => (float) $inv->amount_due, + 'days_overdue' => $daysOverdue, + 'bucket' => $bucket, + ]; + }); + + $bucketKeys = ['current', '1-30', '31-60', '61-90', '91+']; + $totals = collect($bucketKeys)->mapWithKeys(fn ($k) => [ + $k => (float) $invoices->where('bucket', $k)->sum('amount_due'), + ])->all(); + + return Inertia::render('Finance/Reports/AgedReceivablesSummary', [ + 'rows' => $invoices->values(), + 'totals' => $totals, + 'grand_total' => (float) $invoices->sum('amount_due'), + 'as_of' => $asOf, + ]); + } + + // GET /finance/reports/aged-payables + public function agedPayables(Request $request): Response + { + $this->authorize('viewAny', Bill::class); + + $tenantId = app('tenant')->id; + $asOf = $request->get('as_of', now()->toDateString()); + $asOfDate = Carbon::parse($asOf); + + $bills = Bill::with(['contact', 'items', 'payments']) + ->where('tenant_id', $tenantId) + ->whereNotIn('status', ['draft', 'paid', 'cancelled']) + ->get() + ->map(function ($bill) use ($asOfDate) { + $daysOverdue = 0; + $bucket = 'current'; + if ($bill->due_date) { + $diff = $asOfDate->diffInDays($bill->due_date, false); + $daysOverdue = (int) max(0, $diff * -1); + $bucket = match (true) { + $daysOverdue === 0 => 'current', + $daysOverdue <= 30 => '1-30', + $daysOverdue <= 60 => '31-60', + $daysOverdue <= 90 => '61-90', + default => '91+', + }; + } + return [ + 'id' => $bill->id, + 'number' => $bill->number, + 'supplier' => $bill->contact?->name ?? '—', + 'due_date' => $bill->due_date?->toDateString(), + 'amount' => (float) $bill->total, + 'amount_due' => (float) $bill->amount_due, + 'days_overdue' => $daysOverdue, + 'bucket' => $bucket, + ]; + }); + + $bucketKeys = ['current', '1-30', '31-60', '61-90', '91+']; + $totals = collect($bucketKeys)->mapWithKeys(fn ($k) => [ + $k => (float) $bills->where('bucket', $k)->sum('amount_due'), + ])->all(); + + return Inertia::render('Finance/Reports/AgedPayablesSummary', [ + 'rows' => $bills->values(), + 'totals' => $totals, + 'grand_total' => (float) $bills->sum('amount_due'), + 'as_of' => $asOf, + ]); + } + + // GET /finance/reports/invoice-summary + public function invoiceSummary(Request $request): Response + { + $this->authorize('viewAny', Invoice::class); + + $tenantId = app('tenant')->id; + $dateFrom = $request->get('date_from', now()->startOfMonth()->toDateString()); + $dateTo = $request->get('date_to', now()->toDateString()); + + $invoices = Invoice::with(['contact', 'items', 'payments']) + ->where('tenant_id', $tenantId) + ->whereBetween('issue_date', [$dateFrom, $dateTo]) + ->get(); + + $statuses = ['draft', 'sent', 'partial', 'paid', 'cancelled']; + + $summary = collect($statuses)->mapWithKeys(function ($status) use ($invoices) { + $group = $invoices->where('status', $status); + return [$status => [ + 'count' => $group->count(), + 'amount' => (float) $group->sum(fn ($i) => $i->total), + ]]; + })->all(); + + // Overdue: sent/partial with due_date in the past + $overdueInvoices = $invoices->filter(fn ($i) => + in_array($i->status, ['sent', 'partial']) && + $i->due_date !== null && + $i->due_date->isPast() + ); + + $summary['overdue'] = [ + 'count' => $overdueInvoices->count(), + 'amount' => (float) $overdueInvoices->sum(fn ($i) => $i->amount_due), + ]; + + $table = $invoices->map(fn ($inv) => [ + 'id' => $inv->id, + 'number' => $inv->number, + 'customer' => $inv->contact?->name ?? '—', + 'issue_date'=> $inv->issue_date?->toDateString(), + 'due_date' => $inv->due_date?->toDateString(), + 'status' => $inv->status, + 'total' => (float) $inv->total, + 'amount_due'=> (float) $inv->amount_due, + ])->values(); + + return Inertia::render('Finance/Reports/InvoiceSummary', [ + 'date_from' => $dateFrom, + 'date_to' => $dateTo, + 'summary' => $summary, + 'total_count' => $invoices->count(), + 'total_amount'=> (float) $invoices->sum(fn ($i) => $i->total), + 'invoices' => $table, + ]); + } + + // GET /finance/reports/expense-summary + public function expenseSummary(Request $request): Response + { + $this->authorize('viewAny', Bill::class); + + $tenantId = app('tenant')->id; + $dateFrom = $request->get('date_from', now()->startOfMonth()->toDateString()); + $dateTo = $request->get('date_to', now()->toDateString()); + + $bills = Bill::with(['contact', 'items', 'payments']) + ->where('tenant_id', $tenantId) + ->whereBetween('issue_date', [$dateFrom, $dateTo]) + ->get(); + + $statuses = ['draft', 'received', 'partial', 'paid', 'cancelled']; + + $summary = collect($statuses)->mapWithKeys(function ($status) use ($bills) { + $group = $bills->where('status', $status); + return [$status => [ + 'count' => $group->count(), + 'amount' => (float) $group->sum(fn ($b) => $b->total), + ]]; + })->all(); + + $overdueBills = $bills->filter(fn ($b) => + in_array($b->status, ['received', 'partial']) && + $b->due_date !== null && + $b->due_date->isPast() + ); + + $summary['overdue'] = [ + 'count' => $overdueBills->count(), + 'amount' => (float) $overdueBills->sum(fn ($b) => $b->amount_due), + ]; + + $table = $bills->map(fn ($bill) => [ + 'id' => $bill->id, + 'number' => $bill->number, + 'supplier' => $bill->contact?->name ?? '—', + 'issue_date'=> $bill->issue_date?->toDateString(), + 'due_date' => $bill->due_date?->toDateString(), + 'status' => $bill->status, + 'total' => (float) $bill->total, + 'amount_due'=> (float) $bill->amount_due, + ])->values(); + + return Inertia::render('Finance/Reports/ExpenseSummary', [ + 'date_from' => $dateFrom, + 'date_to' => $dateTo, + 'summary' => $summary, + 'total_count' => $bills->count(), + 'total_amount'=> (float) $bills->sum(fn ($b) => $b->total), + 'bills' => $table, + ]); + } +} diff --git a/erp/app/Modules/Finance/Http/Controllers/FixedAssetController.php b/erp/app/Modules/Finance/Http/Controllers/FixedAssetController.php new file mode 100644 index 00000000000..d8e9f8cbca0 --- /dev/null +++ b/erp/app/Modules/Finance/Http/Controllers/FixedAssetController.php @@ -0,0 +1,174 @@ +authorize('viewAny', FixedAsset::class); + + $assets = FixedAsset::orderBy('name') + ->get() + ->map(fn ($a) => [ + 'id' => $a->id, + 'code' => $a->code, + 'name' => $a->name, + 'category' => $a->category, + 'purchase_cost' => $a->purchase_cost, + 'accumulated_depreciation' => $a->accumulated_depreciation, + 'net_book_value' => $a->net_book_value, + 'status' => $a->status, + ]); + + return Inertia::render('Finance/FixedAssets/Index', [ + 'assets' => $assets, + 'breadcrumbs' => [ + ['label' => 'Finance'], + ['label' => 'Fixed Assets'], + ], + ]); + } + + public function create(): Response + { + $this->authorize('create', FixedAsset::class); + + $assetAccounts = Account::where('type', 'asset') + ->where('is_active', true) + ->orderBy('code') + ->get(['id', 'code', 'name']); + + $expenseAccounts = Account::where('type', 'expense') + ->where('is_active', true) + ->orderBy('code') + ->get(['id', 'code', 'name']); + + return Inertia::render('Finance/FixedAssets/Create', [ + 'assetAccounts' => $assetAccounts, + 'expenseAccounts' => $expenseAccounts, + 'breadcrumbs' => [ + ['label' => 'Finance'], + ['label' => 'Fixed Assets', 'href' => '/finance/fixed-assets'], + ['label' => 'New Asset'], + ], + ]); + } + + public function store(StoreFixedAssetRequest $request): RedirectResponse + { + $this->authorize('create', FixedAsset::class); + + $asset = FixedAsset::create(array_merge( + $request->validated(), + [ + 'tenant_id' => $request->user()->tenant_id, + 'created_by' => $request->user()->id, + ] + )); + + $asset->code = 'FA-' . str_pad($asset->id, 5, '0', STR_PAD_LEFT); + $asset->save(); + + return redirect()->route('finance.fixed-assets.show', $asset) + ->with('success', 'Fixed asset created successfully.'); + } + + public function show(FixedAsset $fixedAsset): Response + { + $this->authorize('view', $fixedAsset); + + $entries = $fixedAsset->depreciationEntries() + ->orderByDesc('period_date') + ->get() + ->map(fn ($e) => [ + 'id' => $e->id, + 'period_date' => $e->period_date->toDateString(), + 'amount' => (float) $e->amount, + 'journal_entry_id' => $e->journal_entry_id, + ]); + + return Inertia::render('Finance/FixedAssets/Show', [ + 'asset' => [ + 'id' => $fixedAsset->id, + 'code' => $fixedAsset->code, + 'name' => $fixedAsset->name, + 'category' => $fixedAsset->category, + 'description' => $fixedAsset->description, + 'purchase_date' => $fixedAsset->purchase_date->toDateString(), + 'purchase_cost' => $fixedAsset->purchase_cost, + 'salvage_value' => $fixedAsset->salvage_value, + 'useful_life_years' => $fixedAsset->useful_life_years, + 'accumulated_depreciation' => $fixedAsset->accumulated_depreciation, + 'net_book_value' => $fixedAsset->net_book_value, + 'annual_depreciation' => $fixedAsset->annual_depreciation, + 'status' => $fixedAsset->status, + 'disposal_date' => $fixedAsset->disposal_date?->toDateString(), + 'disposal_proceeds' => $fixedAsset->disposal_proceeds, + 'asset_account_id' => $fixedAsset->asset_account_id, + 'depreciation_account_id' => $fixedAsset->depreciation_account_id, + ], + 'entries' => $entries, + 'breadcrumbs' => [ + ['label' => 'Finance'], + ['label' => 'Fixed Assets', 'href' => '/finance/fixed-assets'], + ['label' => $fixedAsset->name], + ], + ]); + } + + public function depreciate(Request $request, FixedAsset $fixedAsset): RedirectResponse + { + $this->authorize('update', $fixedAsset); + + $validated = $request->validate([ + 'period_date' => ['required', 'date'], + ]); + + try { + $fixedAsset->runDepreciation($validated['period_date']); + } catch (\DomainException $e) { + return back()->withErrors(['period_date' => $e->getMessage()]); + } + + return redirect()->route('finance.fixed-assets.show', $fixedAsset) + ->with('success', 'Depreciation recorded successfully.'); + } + + public function dispose(Request $request, FixedAsset $fixedAsset): RedirectResponse + { + $this->authorize('update', $fixedAsset); + + $validated = $request->validate([ + 'disposal_date' => ['required', 'date'], + 'disposal_proceeds' => ['nullable', 'numeric', 'min:0'], + ]); + + $fixedAsset->status = 'disposed'; + $fixedAsset->disposal_date = $validated['disposal_date']; + $fixedAsset->disposal_proceeds = $validated['disposal_proceeds'] ?? null; + $fixedAsset->save(); + + return redirect()->route('finance.fixed-assets.show', $fixedAsset) + ->with('success', 'Asset disposed successfully.'); + } + + public function destroy(FixedAsset $fixedAsset): RedirectResponse + { + $this->authorize('delete', $fixedAsset); + + $fixedAsset->delete(); + + return redirect()->route('finance.fixed-assets.index') + ->with('success', 'Fixed asset deleted.'); + } +} diff --git a/erp/app/Modules/Finance/Http/Controllers/IntercompanyController.php b/erp/app/Modules/Finance/Http/Controllers/IntercompanyController.php new file mode 100644 index 00000000000..619bc0bb059 --- /dev/null +++ b/erp/app/Modules/Finance/Http/Controllers/IntercompanyController.php @@ -0,0 +1,74 @@ +authorize('viewAny', IntercompanyTransaction::class); + $transactions = IntercompanyTransaction::where('tenant_id', app('tenant')->id) + ->latest() + ->paginate(20); + return Inertia::render('Finance/Intercompany/Index', compact('transactions')); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', IntercompanyTransaction::class); + $validated = $request->validate([ + 'from_entity' => 'required|string|max:255', + 'to_entity' => 'required|string|max:255', + 'amount' => 'required|numeric|min:0.01', + 'currency' => 'nullable|string|max:3', + 'transaction_date' => 'required|date', + 'transaction_type' => 'required|string|max:50', + 'description' => 'nullable|string', + ]); + $validated['tenant_id'] = app('tenant')->id; + $validated['created_by'] = auth()->id(); + IntercompanyTransaction::create($validated); + return back()->with('success', 'Transaction created.'); + } + + public function show(IntercompanyTransaction $intercompany): Response + { + $this->authorize('view', $intercompany); + return Inertia::render('Finance/Intercompany/Show', ['transaction' => $intercompany]); + } + + public function post(IntercompanyTransaction $intercompany): RedirectResponse + { + $this->authorize('update', $intercompany); + $intercompany->post(); + return back()->with('success', 'Transaction posted.'); + } + + public function reconcile(IntercompanyTransaction $intercompany): RedirectResponse + { + $this->authorize('update', $intercompany); + $intercompany->reconcile(); + return back()->with('success', 'Transaction reconciled.'); + } + + public function reverse(IntercompanyTransaction $intercompany): RedirectResponse + { + $this->authorize('update', $intercompany); + $intercompany->reverse(); + return back()->with('success', 'Transaction reversed.'); + } + + public function destroy(IntercompanyTransaction $intercompany): RedirectResponse + { + $this->authorize('delete', $intercompany); + $intercompany->delete(); + return back()->with('success', 'Transaction deleted.'); + } +} diff --git a/erp/app/Modules/Finance/Http/Controllers/InvoiceController.php b/erp/app/Modules/Finance/Http/Controllers/InvoiceController.php new file mode 100644 index 00000000000..aa30e2b51ef --- /dev/null +++ b/erp/app/Modules/Finance/Http/Controllers/InvoiceController.php @@ -0,0 +1,234 @@ +authorize('viewAny', Invoice::class); + + $invoices = Invoice::with('contact') + ->when($request->status, fn ($q) => $q->where('status', $request->status)) + ->when($request->contact_id, fn ($q) => $q->where('contact_id', $request->contact_id)) + ->when($request->search, fn ($q) => $q->where('number', 'like', "%{$request->search}%")) + ->latest('issue_date') + ->paginate(25) + ->withQueryString(); + + return Inertia::render('Finance/Invoices/Index', [ + 'invoices' => InvoiceResource::collection($invoices), + 'contacts' => Contact::customers()->active()->orderBy('name')->get(['id', 'name']), + 'filters' => $request->only(['status', 'contact_id', 'search']), + 'currencies' => $this->currencies, + 'breadcrumbs' => [ + ['label' => 'Finance'], + ['label' => 'Invoices', 'href' => route('finance.invoices.index')], + ], + ]); + } + + public function create(): Response + { + $this->authorize('create', Invoice::class); + + return Inertia::render('Finance/Invoices/Create', [ + 'contacts' => Contact::customers()->active()->orderBy('name')->get(['id', 'name']), + 'currencies' => $this->currencies, + 'breadcrumbs' => [ + ['label' => 'Finance'], + ['label' => 'Invoices', 'href' => route('finance.invoices.index')], + ['label' => 'New Invoice'], + ], + ]); + } + + public function store(StoreInvoiceRequest $request): RedirectResponse + { + $this->authorize('create', Invoice::class); + + $data = $request->validated(); + + $invoice = DB::transaction(function () use ($data) { + $invoice = Invoice::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'contact_id' => $data['contact_id'] ?? null, + 'issue_date' => $data['issue_date'], + 'due_date' => $data['due_date'] ?? null, + 'notes' => $data['notes'] ?? null, + 'created_by' => auth()->id(), + 'currency_code' => $data['currency_code'] ?? 'USD', + 'exchange_rate' => $data['exchange_rate'] ?? 1.0, + ]); + + $invoice->update([ + 'number' => 'INV-' . now()->format('Y') . '-' . str_pad((string) $invoice->id, 5, '0', STR_PAD_LEFT), + ]); + + foreach ($data['items'] as $item) { + InvoiceItem::create([ + 'invoice_id' => $invoice->id, + 'description' => $item['description'], + 'quantity' => $item['quantity'], + 'unit_price' => $item['unit_price'], + 'tax_rate' => $item['tax_rate'], + ]); + } + + return $invoice; + }); + + return redirect()->route('finance.invoices.show', $invoice) + ->with('success', 'Invoice created.'); + } + + public function show(Invoice $invoice): Response + { + $this->authorize('view', $invoice); + + $invoice->load(['contact', 'items', 'payments', 'creator', 'attachments']); + + return Inertia::render('Finance/Invoices/Show', [ + 'invoice' => new InvoiceResource($invoice), + 'breadcrumbs' => [ + ['label' => 'Finance'], + ['label' => 'Invoices', 'href' => route('finance.invoices.index')], + ['label' => $invoice->number ?? "Invoice #{$invoice->id}"], + ], + ]); + } + + public function send(Invoice $invoice): RedirectResponse + { + $this->authorize('update', $invoice); + + try { + $invoice->transitionTo('sent'); + } catch (\DomainException $e) { + return back()->withErrors(['status' => $e->getMessage()]); + } + + return back()->with('success', 'Invoice marked as sent.'); + } + + public function cancel(Invoice $invoice): RedirectResponse + { + $this->authorize('update', $invoice); + + try { + $invoice->transitionTo('cancelled'); + } catch (\DomainException $e) { + return back()->withErrors(['status' => $e->getMessage()]); + } + + return back()->with('success', 'Invoice cancelled.'); + } + + public function recordPayment(StorePaymentRequest $request, Invoice $invoice): RedirectResponse + { + $this->authorize('update', $invoice); + + $data = $request->validated(); + + DB::transaction(function () use ($data, $invoice) { + Payment::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'invoice_id' => $invoice->id, + 'amount' => $data['amount'], + 'payment_date' => $data['payment_date'], + 'method' => $data['method'], + 'reference' => $data['reference'] ?? null, + 'notes' => $data['notes'] ?? null, + ]); + + $invoice->load(['items', 'payments']); + + if ($invoice->amount_due <= 0 && $invoice->canTransitionTo('paid')) { + $invoice->transitionTo('paid'); + } + }); + + return back()->with('success', 'Payment recorded.'); + } + + public function print(Invoice $invoice): Response + { + $this->authorize('view', $invoice); + + $invoice->load(['contact', 'items', 'payments', 'creator', 'attachments']); + + $tenantId = auth()->user()->tenant_id; + + return Inertia::render('Finance/Invoices/Print', [ + 'invoice' => new InvoiceResource($invoice), + 'company' => TenantSetting::getValue($tenantId, 'company_name', 'My Company'), + 'currency' => TenantSetting::getValue($tenantId, 'currency', 'USD'), + ]); + } + + public function destroy(Invoice $invoice): RedirectResponse + { + $this->authorize('delete', $invoice); + + $invoice->delete(); + + return redirect()->route('finance.invoices.index') + ->with('success', 'Invoice deleted.'); + } + + public function pdf(Invoice $invoice): \Illuminate\Http\Response + { + $this->authorize('view', $invoice); + $invoice->load(['items', 'contact', 'payments']); + $pdf = $this->renderDocumentPdf('pdf.invoice', [ + 'invoice' => $invoice, + 'company' => $this->resolveCompanyName(), + ]); + $filename = 'invoice-' . $invoice->number . '.pdf'; + return response($pdf, 200, [ + 'Content-Type' => 'application/pdf', + 'Content-Disposition' => 'attachment; filename="' . $filename . '"', + ]); + } + + public function email(Request $request, Invoice $invoice): \Illuminate\Http\RedirectResponse + { + $this->authorize('update', $invoice); + $request->validate(['email' => 'required|email', 'message' => 'nullable|string|max:1000']); + + $invoice->load(['items', 'contact', 'payments']); + $pdf = $this->renderDocumentPdf('pdf.invoice', [ + 'invoice' => $invoice, + 'company' => $this->resolveCompanyName(), + ]); + $filename = 'invoice-' . $invoice->number . '.pdf'; + + $this->sendDocumentEmail($request, $request->input('email'), 'Invoice ' . $invoice->number, $pdf, $filename); + + if ($invoice->status === 'draft') { + $invoice->transitionTo('sent'); + } + + return back()->with('success', 'Invoice emailed successfully.'); + } +} diff --git a/erp/app/Modules/Finance/Http/Controllers/JournalEntryController.php b/erp/app/Modules/Finance/Http/Controllers/JournalEntryController.php new file mode 100644 index 00000000000..b27af22f9e8 --- /dev/null +++ b/erp/app/Modules/Finance/Http/Controllers/JournalEntryController.php @@ -0,0 +1,127 @@ +authorize('viewAny', JournalEntry::class); + + $entries = JournalEntry::with('lines') + ->when($request->status, fn ($q) => $q->where('status', $request->status)) + ->when($request->search, fn ($q) => $q->where('description', 'like', "%{$request->search}%") + ->orWhere('reference', 'like', "%{$request->search}%")) + ->latest('date') + ->paginate(25) + ->withQueryString(); + + return Inertia::render('Finance/JournalEntries/Index', [ + 'entries' => JournalEntryResource::collection($entries), + 'filters' => $request->only(['status', 'search']), + 'breadcrumbs' => [ + ['label' => 'Finance'], + ['label' => 'Journal Entries', 'href' => route('finance.journal-entries.index')], + ], + ]); + } + + public function create(): Response + { + $this->authorize('create', JournalEntry::class); + + return Inertia::render('Finance/JournalEntries/Create', [ + 'accounts' => Account::active()->orderBy('code')->get(['id', 'code', 'name', 'type']), + 'breadcrumbs' => [ + ['label' => 'Finance'], + ['label' => 'Journal Entries', 'href' => route('finance.journal-entries.index')], + ['label' => 'New Entry'], + ], + ]); + } + + public function store(StoreJournalEntryRequest $request): RedirectResponse + { + $this->authorize('create', JournalEntry::class); + + $data = $request->validated(); + + $entry = DB::transaction(function () use ($data) { + $entry = JournalEntry::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'date' => $data['date'], + 'reference' => $data['reference'] ?? null, + 'description' => $data['description'], + 'created_by' => auth()->id(), + ]); + + foreach ($data['lines'] as $line) { + JournalLine::create([ + 'journal_entry_id' => $entry->id, + 'account_id' => $line['account_id'], + 'debit' => $line['debit'], + 'credit' => $line['credit'], + 'description' => $line['description'] ?? null, + ]); + } + + return $entry; + }); + + return redirect()->route('finance.journal-entries.show', $entry) + ->with('success', 'Journal entry created.'); + } + + public function show(JournalEntry $journalEntry): Response + { + $this->authorize('view', $journalEntry); + + $journalEntry->load(['lines.account', 'creator']); + + return Inertia::render('Finance/JournalEntries/Show', [ + 'entry' => new JournalEntryResource($journalEntry), + 'breadcrumbs' => [ + ['label' => 'Finance'], + ['label' => 'Journal Entries', 'href' => route('finance.journal-entries.index')], + ['label' => "JE #{$journalEntry->id}"], + ], + ]); + } + + public function post(JournalEntry $journalEntry): RedirectResponse + { + $this->authorize('update', $journalEntry); + + $journalEntry->load('lines'); + + try { + $journalEntry->post(); + } catch (\DomainException $e) { + return back()->withErrors(['status' => $e->getMessage()]); + } + + return back()->with('success', 'Journal entry posted.'); + } + + public function destroy(JournalEntry $journalEntry): RedirectResponse + { + $this->authorize('delete', $journalEntry); + + $journalEntry->delete(); + + return redirect()->route('finance.journal-entries.index') + ->with('success', 'Journal entry deleted.'); + } +} diff --git a/erp/app/Modules/Finance/Http/Controllers/LeadController.php b/erp/app/Modules/Finance/Http/Controllers/LeadController.php new file mode 100644 index 00000000000..5aa51099cc8 --- /dev/null +++ b/erp/app/Modules/Finance/Http/Controllers/LeadController.php @@ -0,0 +1,155 @@ +authorize('viewAny', Lead::class); + + $query = Lead::with('assignedTo'); + + if ($request->filled('stage')) { + $query->where('stage', $request->stage); + } + + $leads = $query->latest()->paginate(20)->withQueryString(); + + return Inertia::render('Finance/Leads/Index', [ + 'leads' => $leads, + 'filters' => $request->only('stage'), + ]); + } + + public function create(): Response + { + $this->authorize('create', Lead::class); + + $users = User::orderBy('name')->get(['id', 'name']); + + return Inertia::render('Finance/Leads/Create', [ + 'users' => $users, + ]); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', Lead::class); + + $data = $request->validate([ + 'name' => ['required', 'string'], + 'email' => ['nullable', 'email'], + 'phone' => ['nullable', 'string', 'max:30'], + 'company' => ['nullable', 'string'], + 'source' => ['required', 'in:website,referral,cold_call,trade_show,social_media,other'], + 'stage' => ['nullable', 'in:new,contacted,qualified,proposal,negotiation,won,lost'], + 'estimated_value' => ['nullable', 'numeric'], + 'probability' => ['nullable', 'integer', 'min:0', 'max:100'], + 'notes' => ['nullable', 'string'], + 'assigned_to' => ['nullable', 'exists:users,id'], + 'expected_close_date' => ['nullable', 'date'], + ]); + + $data['tenant_id'] = app('tenant')->id; + $data['stage'] = $data['stage'] ?? 'new'; + + $lead = Lead::create($data); + + return redirect()->route('finance.leads.show', $lead); + } + + public function show(Lead $lead): Response + { + $this->authorize('view', $lead); + + $lead->load(['activities' => function ($q) { + $q->with('user')->orderBy('activity_date', 'desc'); + }]); + + $lead->append('weighted_value'); + + return Inertia::render('Finance/Leads/Show', [ + 'lead' => $lead, + ]); + } + + public function update(Request $request, Lead $lead): RedirectResponse + { + $this->authorize('update', $lead); + + $data = $request->validate([ + 'stage' => ['nullable', 'in:new,contacted,qualified,proposal,negotiation,won,lost'], + 'probability' => ['nullable', 'integer', 'min:0', 'max:100'], + 'estimated_value' => ['nullable', 'numeric'], + 'notes' => ['nullable', 'string'], + 'expected_close_date' => ['nullable', 'date'], + 'assigned_to' => ['nullable', 'exists:users,id'], + ]); + + $lead->update($data); + + return back()->with('success', 'Lead updated successfully.'); + } + + public function destroy(Lead $lead): RedirectResponse + { + $this->authorize('delete', $lead); + + $lead->delete(); + + return redirect()->route('finance.leads.index'); + } + + public function markWon(Lead $lead): RedirectResponse + { + $this->authorize('update', $lead); + + $lead->markWon(); + + return back()->with('success', 'Lead marked as won.'); + } + + public function markLost(Request $request, Lead $lead): RedirectResponse + { + $this->authorize('update', $lead); + + $data = $request->validate([ + 'reason' => ['required', 'string'], + ]); + + $lead->markLost($data['reason']); + + return back()->with('success', 'Lead marked as lost.'); + } + + public function addActivity(Request $request, Lead $lead): RedirectResponse + { + $this->authorize('create', Lead::class); + + $data = $request->validate([ + 'type' => ['required', 'in:call,email,meeting,note,task'], + 'description' => ['required', 'string'], + 'activity_date' => ['required', 'date'], + 'outcome' => ['nullable', 'string'], + 'duration_minutes' => ['nullable', 'integer', 'min:1'], + ]); + + $data['tenant_id'] = app('tenant')->id; + $data['lead_id'] = $lead->id; + $data['user_id'] = auth()->id(); + + LeadActivity::create($data); + + return back()->with('success', 'Activity added successfully.'); + } +} diff --git a/erp/app/Modules/Finance/Http/Controllers/LoyaltyProgramController.php b/erp/app/Modules/Finance/Http/Controllers/LoyaltyProgramController.php new file mode 100644 index 00000000000..f4dd625bc16 --- /dev/null +++ b/erp/app/Modules/Finance/Http/Controllers/LoyaltyProgramController.php @@ -0,0 +1,154 @@ +authorize('viewAny', LoyaltyProgram::class); + + $loyaltyPrograms = LoyaltyProgram::withCount('enrollments') + ->latest() + ->paginate(15); + + return Inertia::render('Finance/Loyalty/Index', [ + 'loyaltyPrograms' => $loyaltyPrograms, + 'breadcrumbs' => [ + ['label' => 'Finance'], + ['label' => 'Loyalty Programs', 'href' => route('finance.loyalty-programs.index')], + ], + ]); + } + + public function create(): Response + { + $this->authorize('create', LoyaltyProgram::class); + + return Inertia::render('Finance/Loyalty/Create', [ + 'breadcrumbs' => [ + ['label' => 'Finance'], + ['label' => 'Loyalty Programs', 'href' => route('finance.loyalty-programs.index')], + ['label' => 'New Program'], + ], + ]); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', LoyaltyProgram::class); + + $data = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'description' => ['nullable', 'string'], + 'points_per_currency_unit' => ['required', 'numeric', 'min:0.0001'], + 'points_to_currency_rate' => ['required', 'numeric', 'min:0.000001'], + 'minimum_redemption_points' => ['required', 'integer', 'min:1'], + 'is_active' => ['boolean'], + ]); + + $data['tenant_id'] = app('tenant')->id; + + $program = LoyaltyProgram::create($data); + + return redirect()->route('finance.loyalty-programs.show', $program); + } + + public function show(LoyaltyProgram $loyaltyProgram): Response + { + $this->authorize('view', $loyaltyProgram); + + $enrollments = $loyaltyProgram->enrollments() + ->with('contact') + ->paginate(20); + + return Inertia::render('Finance/Loyalty/Show', [ + 'loyaltyProgram' => $loyaltyProgram, + 'enrollments' => $enrollments, + 'contacts' => Contact::customers()->active()->orderBy('name')->get(['id', 'name']), + 'breadcrumbs' => [ + ['label' => 'Finance'], + ['label' => 'Loyalty Programs', 'href' => route('finance.loyalty-programs.index')], + ['label' => $loyaltyProgram->name], + ], + ]); + } + + public function destroy(LoyaltyProgram $loyaltyProgram): RedirectResponse + { + $this->authorize('delete', $loyaltyProgram); + + $loyaltyProgram->delete(); + + return redirect()->route('finance.loyalty-programs.index'); + } + + public function enroll(Request $request, LoyaltyProgram $loyaltyProgram): RedirectResponse + { + $this->authorize('create', LoyaltyProgram::class); + + $data = $request->validate([ + 'contact_id' => ['required', 'exists:contacts,id'], + ]); + + $tenantId = app('tenant')->id; + + LoyaltyEnrollment::firstOrCreate( + [ + 'loyalty_program_id' => $loyaltyProgram->id, + 'contact_id' => $data['contact_id'], + ], + [ + 'tenant_id' => $tenantId, + 'enrolled_at' => now(), + ] + ); + + return back()->with('success', 'Contact enrolled successfully.'); + } + + public function earnPoints(Request $request, LoyaltyProgram $loyaltyProgram): RedirectResponse + { + $this->authorize('create', LoyaltyProgram::class); + + $data = $request->validate([ + 'enrollment_id' => ['required', 'exists:loyalty_enrollments,id'], + 'points' => ['required', 'integer', 'min:1'], + 'description' => ['nullable', 'string'], + ]); + + $enrollment = LoyaltyEnrollment::findOrFail($data['enrollment_id']); + $enrollment->earnPoints($data['points'], $data['description'] ?? ''); + + return back()->with('success', 'Points earned successfully.'); + } + + public function redeemPoints(Request $request, LoyaltyProgram $loyaltyProgram): RedirectResponse + { + $this->authorize('create', LoyaltyProgram::class); + + $data = $request->validate([ + 'enrollment_id' => ['required', 'exists:loyalty_enrollments,id'], + 'points' => ['required', 'integer', 'min:1'], + ]); + + $enrollment = LoyaltyEnrollment::findOrFail($data['enrollment_id']); + + try { + $enrollment->redeemPoints($data['points']); + } catch (\Exception $e) { + return back()->with('error', $e->getMessage()); + } + + return back()->with('success', 'Points redeemed successfully.'); + } +} diff --git a/erp/app/Modules/Finance/Http/Controllers/MultiCurrencyReportController.php b/erp/app/Modules/Finance/Http/Controllers/MultiCurrencyReportController.php new file mode 100644 index 00000000000..98242afb492 --- /dev/null +++ b/erp/app/Modules/Finance/Http/Controllers/MultiCurrencyReportController.php @@ -0,0 +1,79 @@ +id; + $service = new CurrencyConversionService($tenantId); + $base = $service->getBaseCurrency(); + $currencies = $service->getSupportedCurrencies(); + + // Gather invoice totals per currency; `total` is a computed accessor so we load items + $invoiceSummary = Invoice::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where('status', 'paid') + ->with('items') + ->get() + ->groupBy('currency_code') + ->map(function ($invoices, $currencyCode) use ($service) { + $totalAmount = $invoices->sum(fn ($inv) => $inv->total); + $converted = $service->convertToBase((float) $totalAmount, (string) $currencyCode); + return [ + 'currency_code' => $currencyCode, + 'total_amount' => $totalAmount, + 'count' => $invoices->count(), + 'base_amount' => $converted, + ]; + }) + ->values(); + + // Recent exchange rates + $recentRates = ExchangeRate::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->orderByDesc('effective_date') + ->limit(20) + ->get(['from_currency', 'to_currency', 'base_currency', 'quote_currency', 'rate', 'effective_date']); + + return Inertia::render('Finance/MultiCurrency/ConsolidationReport', [ + 'baseCurrency' => $base, + 'currencies' => $currencies, + 'invoiceSummary' => $invoiceSummary, + 'recentRates' => $recentRates, + ]); + } + + public function convertPreview(Request $request): \Illuminate\Http\JsonResponse { + $validated = $request->validate([ + 'amount' => 'required|numeric|min:0', + 'from' => 'required|string|size:3', + 'to' => 'required|string|size:3', + 'date' => 'nullable|date', + ]); + + $tenantId = app('tenant')->id; + $service = new CurrencyConversionService($tenantId); + $rawDate = $validated['date'] ?? null; + $date = $rawDate ? \Carbon\Carbon::parse($rawDate) : null; + + $result = $service->convert((float) $validated['amount'], $validated['from'], $validated['to'], $date); + $rate = $service->getRate($validated['from'], $validated['to'], $date); + + return response()->json([ + 'from' => $validated['from'], + 'to' => $validated['to'], + 'amount' => $validated['amount'], + 'result' => $result, + 'rate' => $rate, + 'date' => $rawDate ?? now()->toDateString(), + ]); + } +} diff --git a/erp/app/Modules/Finance/Http/Controllers/PaymentScheduleController.php b/erp/app/Modules/Finance/Http/Controllers/PaymentScheduleController.php new file mode 100644 index 00000000000..b9679d4ad19 --- /dev/null +++ b/erp/app/Modules/Finance/Http/Controllers/PaymentScheduleController.php @@ -0,0 +1,134 @@ +authorize('viewAny', PaymentSchedule::class); + + $schedules = PaymentSchedule::query() + ->when($request->status, fn ($q) => $q->where('status', $request->status)) + ->latest() + ->paginate(25) + ->withQueryString(); + + return Inertia::render('Finance/PaymentSchedules/Index', [ + 'paymentSchedules' => $schedules, + 'filters' => $request->only(['status']), + ]); + } + + public function create(): Response + { + $this->authorize('create', PaymentSchedule::class); + + return Inertia::render('Finance/PaymentSchedules/Create'); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', PaymentSchedule::class); + + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'total_amount' => 'required|numeric|min:0', + 'installments' => 'required|integer|min:1', + 'start_date' => 'required|date', + 'frequency' => 'required|in:monthly,quarterly,annual,custom', + ]); + + PaymentSchedule::create([ + ...$validated, + 'tenant_id' => app('tenant')->id, + 'created_by' => auth()->id(), + ]); + + return redirect()->route('finance.payment-schedules.index') + ->with('success', 'Payment schedule created.'); + } + + public function show(PaymentSchedule $paymentSchedule): Response + { + $this->authorize('view', $paymentSchedule); + + return Inertia::render('Finance/PaymentSchedules/Show', [ + 'paymentSchedule' => $paymentSchedule->load('items'), + ]); + } + + public function edit(PaymentSchedule $paymentSchedule): Response + { + $this->authorize('update', $paymentSchedule); + + return Inertia::render('Finance/PaymentSchedules/Edit', [ + 'paymentSchedule' => $paymentSchedule, + ]); + } + + public function update(Request $request, PaymentSchedule $paymentSchedule): RedirectResponse + { + $this->authorize('update', $paymentSchedule); + + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'total_amount' => 'required|numeric|min:0', + 'installments' => 'required|integer|min:1', + 'start_date' => 'required|date', + 'frequency' => 'required|in:monthly,quarterly,annual,custom', + ]); + + $paymentSchedule->update($validated); + + return redirect()->route('finance.payment-schedules.index') + ->with('success', 'Payment schedule updated.'); + } + + public function destroy(PaymentSchedule $paymentSchedule): RedirectResponse + { + $this->authorize('delete', $paymentSchedule); + + $paymentSchedule->delete(); + + return redirect()->route('finance.payment-schedules.index') + ->with('success', 'Payment schedule deleted.'); + } + + public function pause(PaymentSchedule $paymentSchedule): RedirectResponse + { + $this->authorize('pause', $paymentSchedule); + + $paymentSchedule->pause(); + + return redirect()->route('finance.payment-schedules.index') + ->with('success', 'Payment schedule paused.'); + } + + public function resume(PaymentSchedule $paymentSchedule): RedirectResponse + { + $this->authorize('resume', $paymentSchedule); + + $paymentSchedule->resume(); + + return redirect()->route('finance.payment-schedules.index') + ->with('success', 'Payment schedule resumed.'); + } + + public function cancel(PaymentSchedule $paymentSchedule): RedirectResponse + { + $this->authorize('cancel', $paymentSchedule); + + $paymentSchedule->cancel(); + + return redirect()->route('finance.payment-schedules.index') + ->with('success', 'Payment schedule cancelled.'); + } +} diff --git a/erp/app/Modules/Finance/Http/Controllers/PaymentTermController.php b/erp/app/Modules/Finance/Http/Controllers/PaymentTermController.php new file mode 100644 index 00000000000..26c901f276f --- /dev/null +++ b/erp/app/Modules/Finance/Http/Controllers/PaymentTermController.php @@ -0,0 +1,85 @@ +authorize('viewAny', PaymentTerm::class); + + $paymentTerms = PaymentTerm::where('is_active', true) + ->latest() + ->paginate(20) + ->withQueryString(); + + return Inertia::render('Finance/PaymentTerms/Index', [ + 'paymentTerms' => $paymentTerms, + ]); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', PaymentTerm::class); + + $validated = $request->validate([ + 'name' => 'required|string|max:100', + 'days' => 'required|integer|min:0', + 'discount_days' => 'nullable|integer|min:0', + 'discount_percent' => 'nullable|numeric|min:0|max:100', + 'description' => 'nullable|string', + 'is_active' => 'boolean', + ]); + + PaymentTerm::create([ + ...$validated, + 'tenant_id' => auth()->user()->tenant_id, + ]); + + return redirect()->back()->with('success', 'Payment term created successfully.'); + } + + public function show(PaymentTerm $paymentTerm): Response + { + $this->authorize('view', $paymentTerm); + + return Inertia::render('Finance/PaymentTerms/Show', [ + 'paymentTerm' => $paymentTerm, + ]); + } + + public function update(Request $request, PaymentTerm $paymentTerm): RedirectResponse + { + $this->authorize('update', $paymentTerm); + + $validated = $request->validate([ + 'name' => 'required|string|max:100', + 'days' => 'required|integer|min:0', + 'discount_days' => 'nullable|integer|min:0', + 'discount_percent' => 'nullable|numeric|min:0|max:100', + 'description' => 'nullable|string', + 'is_active' => 'boolean', + ]); + + $paymentTerm->update($validated); + + return redirect()->back()->with('success', 'Payment term updated successfully.'); + } + + public function destroy(PaymentTerm $paymentTerm): RedirectResponse + { + $this->authorize('delete', $paymentTerm); + + $paymentTerm->delete(); + + return redirect()->route('finance.payment-terms.index') + ->with('success', 'Payment term deleted successfully.'); + } +} diff --git a/erp/app/Modules/Finance/Http/Controllers/PettyCashFundController.php b/erp/app/Modules/Finance/Http/Controllers/PettyCashFundController.php new file mode 100644 index 00000000000..c2f269989ed --- /dev/null +++ b/erp/app/Modules/Finance/Http/Controllers/PettyCashFundController.php @@ -0,0 +1,112 @@ +authorize('viewAny', PettyCashFund::class); + + $funds = PettyCashFund::with('custodian') + ->latest() + ->paginate(20) + ->withQueryString(); + + return Inertia::render('Finance/PettyCash/Index', [ + 'funds' => $funds, + ]); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', PettyCashFund::class); + + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'authorized_amount' => ['required', 'numeric', 'min:0'], + 'currency' => ['nullable', 'string', 'max:3'], + 'custodian_id' => ['nullable', 'exists:users,id'], + ]); + + $fund = PettyCashFund::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'name' => $validated['name'], + 'authorized_amount' => $validated['authorized_amount'], + 'current_balance' => $validated['authorized_amount'], + 'currency' => $validated['currency'] ?? 'USD', + 'custodian_id' => $validated['custodian_id'] ?? null, + 'is_active' => true, + ]); + + return redirect()->route('finance.petty-cash.show', $fund); + } + + public function show(PettyCashFund $pettyCash): Response + { + $this->authorize('view', $pettyCash); + + $pettyCash->load(['custodian']); + + $transactions = $pettyCash->transactions() + ->latest() + ->take(20) + ->get(); + + return Inertia::render('Finance/PettyCash/Show', [ + 'fund' => $pettyCash, + 'transactions' => $transactions, + ]); + } + + public function replenish(Request $request, PettyCashFund $pettyCashFund): RedirectResponse + { + $this->authorize('update', $pettyCashFund); + + $validated = $request->validate([ + 'amount' => ['required', 'numeric', 'min:0.01'], + ]); + + $pettyCashFund->replenish((float) $validated['amount'], auth()->id()); + + return back()->with('success', 'Fund replenished successfully.'); + } + + public function expense(Request $request, PettyCashFund $pettyCashFund): RedirectResponse + { + $this->authorize('update', $pettyCashFund); + + $validated = $request->validate([ + 'amount' => ['required', 'numeric', 'min:0.01'], + 'description' => ['required', 'string'], + 'transaction_date' => ['required', 'date'], + 'category' => ['nullable', 'string'], + ]); + + $pettyCashFund->addExpense( + (float) $validated['amount'], + $validated['description'], + $validated['transaction_date'], + auth()->id(), + $validated['category'] ?? null, + ); + + return back()->with('success', 'Expense recorded successfully.'); + } + + public function destroy(PettyCashFund $pettyCash): RedirectResponse + { + $this->authorize('delete', $pettyCash); + + $pettyCash->delete(); + + return redirect()->route('finance.petty-cash.index'); + } +} diff --git a/erp/app/Modules/Finance/Http/Controllers/PriceListController.php b/erp/app/Modules/Finance/Http/Controllers/PriceListController.php new file mode 100644 index 00000000000..0e107bc227b --- /dev/null +++ b/erp/app/Modules/Finance/Http/Controllers/PriceListController.php @@ -0,0 +1,229 @@ +authorize('viewAny', PriceList::class); + + $priceLists = PriceList::withCount(['items', 'contacts']) + ->orderBy('name') + ->paginate(15); + + return Inertia::render('Finance/PriceLists/Index', [ + 'priceLists' => $priceLists, + 'breadcrumbs' => [ + ['label' => 'Finance'], + ['label' => 'Price Lists', 'href' => route('finance.price-lists.index')], + ], + ]); + } + + public function create(): Response + { + $this->authorize('create', PriceList::class); + + return Inertia::render('Finance/PriceLists/Create', [ + 'products' => Product::orderBy('name')->get(['id', 'name', 'sku', 'sale_price']), + 'breadcrumbs' => [ + ['label' => 'Finance'], + ['label' => 'Price Lists', 'href' => route('finance.price-lists.index')], + ['label' => 'New Price List'], + ], + ]); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', PriceList::class); + + $data = $request->validate([ + 'name' => 'required|string|max:191', + 'description' => 'nullable|string', + 'currency_code' => 'required|string|size:3', + 'discount_percent' => 'nullable|numeric|min:0|max:100', + 'is_active' => 'boolean', + 'is_default' => 'boolean', + 'valid_from' => 'nullable|date', + 'valid_to' => 'nullable|date', + 'items' => 'nullable|array', + 'items.*.product_id' => 'required|exists:products,id', + 'items.*.unit_price' => 'required|numeric|min:0', + ]); + + $tenantId = auth()->user()->tenant_id; + + $priceList = DB::transaction(function () use ($data, $tenantId) { + // If setting as default, clear existing defaults + if (!empty($data['is_default'])) { + PriceList::where('tenant_id', $tenantId) + ->where('is_default', true) + ->update(['is_default' => false]); + } + + $list = PriceList::create([ + 'tenant_id' => $tenantId, + 'name' => $data['name'], + 'description' => $data['description'] ?? null, + 'currency_code' => $data['currency_code'], + 'discount_percent' => $data['discount_percent'] ?? 0, + 'is_active' => $data['is_active'] ?? true, + 'is_default' => $data['is_default'] ?? false, + 'valid_from' => $data['valid_from'] ?? null, + 'valid_to' => $data['valid_to'] ?? null, + ]); + + foreach ($data['items'] ?? [] as $item) { + PriceListItem::create([ + 'tenant_id' => $tenantId, + 'price_list_id' => $list->id, + 'product_id' => $item['product_id'], + 'unit_price' => $item['unit_price'], + 'min_quantity' => $item['min_quantity'] ?? 1, + ]); + } + + return $list; + }); + + return redirect()->route('finance.price-lists.show', $priceList) + ->with('success', 'Price list created.'); + } + + public function show(PriceList $priceList): Response + { + $this->authorize('view', $priceList); + + $items = $priceList->items()->with('product')->paginate(15); + + return Inertia::render('Finance/PriceLists/Show', [ + 'priceList' => $priceList->load('contacts'), + 'items' => $items, + 'breadcrumbs' => [ + ['label' => 'Finance'], + ['label' => 'Price Lists', 'href' => route('finance.price-lists.index')], + ['label' => $priceList->name], + ], + ]); + } + + public function update(Request $request, PriceList $priceList): RedirectResponse + { + $this->authorize('update', $priceList); + + $data = $request->validate([ + 'name' => 'required|string|max:191', + 'description' => 'nullable|string', + 'currency_code' => 'nullable|string|size:3', + 'discount_percent' => 'nullable|numeric|min:0|max:100', + 'is_active' => 'boolean', + 'is_default' => 'boolean', + 'valid_from' => 'nullable|date', + 'valid_to' => 'nullable|date', + 'items' => 'nullable|array', + 'items.*.product_id' => 'required|exists:products,id', + 'items.*.unit_price' => 'required|numeric|min:0', + ]); + + DB::transaction(function () use ($data, $priceList) { + $priceList->update([ + 'name' => $data['name'], + 'description' => $data['description'] ?? null, + 'currency_code' => $data['currency_code'] ?? 'USD', + 'discount_percent' => $data['discount_percent'] ?? 0, + 'is_active' => $data['is_active'] ?? true, + 'is_default' => $data['is_default'] ?? false, + 'valid_from' => $data['valid_from'] ?? null, + 'valid_to' => $data['valid_to'] ?? null, + ]); + + // Sync items + $priceList->items()->delete(); + foreach ($data['items'] ?? [] as $item) { + PriceListItem::create([ + 'tenant_id' => $priceList->tenant_id, + 'price_list_id' => $priceList->id, + 'product_id' => $item['product_id'], + 'unit_price' => $item['unit_price'], + 'min_quantity' => $item['min_quantity'] ?? 1, + ]); + } + }); + + return redirect()->route('finance.price-lists.show', $priceList) + ->with('success', 'Price list updated.'); + } + + public function destroy(PriceList $priceList): RedirectResponse + { + $this->authorize('delete', $priceList); + + $priceList->delete(); + + return redirect()->route('finance.price-lists.index') + ->with('success', 'Price list deleted.'); + } + + public function addItem(Request $request, PriceList $priceList): RedirectResponse + { + $this->authorize('create', PriceList::class); + + $data = $request->validate([ + 'product_id' => 'required|exists:products,id', + 'unit_price' => 'required|numeric|min:0', + 'min_quantity' => 'required|integer|min:1', + ]); + + PriceListItem::create([ + 'tenant_id' => $priceList->tenant_id, + 'price_list_id' => $priceList->id, + 'product_id' => $data['product_id'], + 'unit_price' => $data['unit_price'], + 'min_quantity' => $data['min_quantity'], + ]); + + return redirect()->back()->with('success', 'Item added to price list.'); + } + + public function removeItem(PriceList $priceList, PriceListItem $item): RedirectResponse + { + $this->authorize('delete', $priceList); + + $item->delete(); + + return redirect()->back()->with('success', 'Item removed from price list.'); + } + + public function priceForContact(Request $request) + { + $this->authorize('viewAny', PriceList::class); + + $data = $request->validate([ + 'contact_id' => 'required|exists:contacts,id', + 'product_id' => 'required|exists:products,id', + ]); + + $contact = Contact::find($data['contact_id']); + $product = Product::find($data['product_id']); + + if (!$contact->price_list_id) { + return response()->json(['price' => (float) $product->sale_price]); + } + + $price = PriceList::priceFor($contact->price_list_id, $data['product_id'], (float) $product->sale_price); + return response()->json(['price' => $price]); + } +} diff --git a/erp/app/Modules/Finance/Http/Controllers/ProfitCenterController.php b/erp/app/Modules/Finance/Http/Controllers/ProfitCenterController.php new file mode 100644 index 00000000000..85c113a109c --- /dev/null +++ b/erp/app/Modules/Finance/Http/Controllers/ProfitCenterController.php @@ -0,0 +1,93 @@ +orderBy('code') + ->paginate(20); + + return Inertia::render('Finance/ProfitCenters/Index', compact('profitCenters')); + } + + public function create(): Response + { + $parents = ProfitCenter::orderBy('name')->get(['id', 'name', 'code']); + return Inertia::render('Finance/ProfitCenters/Create', compact('parents')); + } + + public function store(Request $request): RedirectResponse + { + $data = $request->validate([ + 'code' => 'required|string|max:50|unique:profit_centers,code', + 'name' => 'required|string|max:255', + 'type' => 'required|string|in:profit,cost,investment', + 'parent_id' => 'nullable|exists:profit_centers,id', + 'manager_id' => 'nullable|exists:users,id', + 'budget' => 'nullable|numeric|min:0', + 'description' => 'nullable|string', + ]); + + $data['tenant_id'] = app('tenant')->id; + + ProfitCenter::create($data); + + return redirect()->route('finance.profit-centers.index'); + } + + public function show(ProfitCenter $profitCenter): Response + { + $profitCenter->load('parent', 'children', 'manager'); + return Inertia::render('Finance/ProfitCenters/Show', compact('profitCenter')); + } + + public function edit(ProfitCenter $profitCenter): Response + { + $parents = ProfitCenter::where('id', '!=', $profitCenter->id)->orderBy('name')->get(['id', 'name', 'code']); + return Inertia::render('Finance/ProfitCenters/Edit', compact('profitCenter', 'parents')); + } + + public function update(Request $request, ProfitCenter $profitCenter): RedirectResponse + { + $data = $request->validate([ + 'code' => 'required|string|max:50|unique:profit_centers,code,' . $profitCenter->id, + 'name' => 'required|string|max:255', + 'type' => 'required|string|in:profit,cost,investment', + 'parent_id' => 'nullable|exists:profit_centers,id', + 'manager_id' => 'nullable|exists:users,id', + 'budget' => 'nullable|numeric|min:0', + 'description' => 'nullable|string', + ]); + + $profitCenter->update($data); + + return redirect()->route('finance.profit-centers.index'); + } + + public function destroy(ProfitCenter $profitCenter): RedirectResponse + { + $profitCenter->delete(); + return redirect()->route('finance.profit-centers.index'); + } + + public function activate(ProfitCenter $profitCenter): RedirectResponse + { + $profitCenter->activate(); + return redirect()->route('finance.profit-centers.index'); + } + + public function deactivate(ProfitCenter $profitCenter): RedirectResponse + { + $profitCenter->deactivate(); + return redirect()->route('finance.profit-centers.index'); + } +} diff --git a/erp/app/Modules/Finance/Http/Controllers/ProjectController.php b/erp/app/Modules/Finance/Http/Controllers/ProjectController.php new file mode 100644 index 00000000000..4e356c9c98d --- /dev/null +++ b/erp/app/Modules/Finance/Http/Controllers/ProjectController.php @@ -0,0 +1,157 @@ +authorize('viewAny', Project::class); + + $query = Project::with('contact')->orderByDesc('created_at'); + + if ($request->filled('status')) { + $query->where('status', $request->status); + } + + $projects = $query->paginate(15)->withQueryString(); + + return Inertia::render('Finance/Projects/Index', [ + 'projects' => $projects, + 'filters' => $request->only('status'), + ]); + } + + public function create(): Response + { + $this->authorize('create', Project::class); + + $contacts = Contact::orderBy('name')->get(['id', 'name']); + + return Inertia::render('Finance/Projects/Create', [ + 'contacts' => $contacts, + ]); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', Project::class); + + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'contact_id' => ['nullable', 'exists:contacts,id'], + 'status' => ['nullable', 'in:planning,active,on_hold,completed,cancelled'], + 'start_date' => ['nullable', 'date'], + 'end_date' => ['nullable', 'date'], + 'budget' => ['nullable', 'numeric', 'min:0'], + 'billing_type' => ['required', 'in:fixed,hourly,non_billable'], + 'hourly_rate' => ['nullable', 'numeric', 'min:0'], + 'description' => ['nullable', 'string'], + ]); + + $validated['status'] = $validated['status'] ?? 'planning'; + + $project = Project::create(array_merge($validated, [ + 'tenant_id' => $request->user()->tenant_id, + ])); + + return redirect()->route('finance.projects.show', $project); + } + + public function show(Project $project): Response + { + $this->authorize('view', $project); + + $project->load(['tasks.assignedTo', 'timeEntries.user', 'timeEntries.task', 'contact']); + + $projectData = $project->toArray(); + $projectData['total_hours'] = $project->total_hours; + $projectData['total_billed'] = $project->total_billed; + $projectData['completion_percent'] = $project->completion_percent; + + return Inertia::render('Finance/Projects/Show', [ + 'project' => $projectData, + ]); + } + + public function destroy(Project $project): RedirectResponse + { + $this->authorize('delete', $project); + + $project->delete(); + + return redirect()->route('finance.projects.index'); + } + + public function activate(Project $project): RedirectResponse + { + $this->authorize('update', $project); + + $project->activate(); + + return redirect()->back(); + } + + public function complete(Project $project): RedirectResponse + { + $this->authorize('update', $project); + + $project->complete(); + + return redirect()->back(); + } + + public function addTask(Request $request, Project $project): RedirectResponse + { + $this->authorize('create', Project::class); + + $validated = $request->validate([ + 'title' => ['required', 'string', 'max:255'], + 'priority' => ['nullable', 'in:low,medium,high'], + 'due_date' => ['nullable', 'date'], + 'estimated_hours' => ['nullable', 'numeric'], + 'description' => ['nullable', 'string'], + 'assigned_to' => ['nullable', 'exists:users,id'], + ]); + + $validated['priority'] = $validated['priority'] ?? 'medium'; + + ProjectTask::create(array_merge($validated, [ + 'project_id' => $project->id, + 'tenant_id' => $project->tenant_id, + ])); + + return redirect()->back(); + } + + public function addTimeEntry(Request $request, Project $project): RedirectResponse + { + $this->authorize('create', Project::class); + + $validated = $request->validate([ + 'hours' => ['required', 'numeric', 'min:0.01'], + 'entry_date' => ['required', 'date'], + 'description' => ['nullable', 'string'], + 'task_id' => ['nullable', 'exists:project_tasks,id'], + 'is_billable' => ['boolean'], + ]); + + ProjectTimeEntry::create(array_merge($validated, [ + 'project_id' => $project->id, + 'tenant_id' => $project->tenant_id, + 'user_id' => auth()->id(), + ])); + + return redirect()->back(); + } +} diff --git a/erp/app/Modules/Finance/Http/Controllers/ProjectTaskController.php b/erp/app/Modules/Finance/Http/Controllers/ProjectTaskController.php new file mode 100644 index 00000000000..3f58033ef35 --- /dev/null +++ b/erp/app/Modules/Finance/Http/Controllers/ProjectTaskController.php @@ -0,0 +1,35 @@ +authorize('create', Project::class); + + $validated = $request->validate([ + 'status' => ['nullable', 'in:todo,in_progress,done,cancelled'], + 'actual_hours' => ['nullable', 'numeric'], + ]); + + $task->update($validated); + + return redirect()->back(); + } + + public function destroy(Project $project, ProjectTask $task): RedirectResponse + { + $this->authorize('delete', $project); + + $task->delete(); + + return redirect()->back(); + } +} diff --git a/erp/app/Modules/Finance/Http/Controllers/QuoteController.php b/erp/app/Modules/Finance/Http/Controllers/QuoteController.php new file mode 100644 index 00000000000..b49323b9063 --- /dev/null +++ b/erp/app/Modules/Finance/Http/Controllers/QuoteController.php @@ -0,0 +1,244 @@ +authorize('viewAny', Quote::class); + + $quotes = Quote::with('contact') + ->when($request->status, fn ($q) => $q->where('status', $request->status)) + ->when($request->contact_id, fn ($q) => $q->where('contact_id', $request->contact_id)) + ->when($request->search, fn ($q) => $q->where('number', 'like', "%{$request->search}%")) + ->latest('issue_date') + ->paginate(25) + ->withQueryString(); + + return Inertia::render('Finance/Quotes/Index', [ + 'quotes' => QuoteResource::collection($quotes), + 'contacts' => Contact::customers()->active()->orderBy('name')->get(['id', 'name']), + 'filters' => $request->only(['status', 'contact_id', 'search']), + 'currencies' => $this->currencies, + 'breadcrumbs' => [ + ['label' => 'Finance'], + ['label' => 'Quotes', 'href' => route('finance.quotes.index')], + ], + ]); + } + + public function create(): Response + { + $this->authorize('create', Quote::class); + + return Inertia::render('Finance/Quotes/Create', [ + 'contacts' => Contact::customers()->active()->orderBy('name')->get(['id', 'name']), + 'currencies' => $this->currencies, + 'breadcrumbs' => [ + ['label' => 'Finance'], + ['label' => 'Quotes', 'href' => route('finance.quotes.index')], + ['label' => 'New Quote'], + ], + ]); + } + + public function store(StoreQuoteRequest $request): RedirectResponse + { + $this->authorize('create', Quote::class); + + $data = $request->validated(); + + $quote = DB::transaction(function () use ($data) { + $quote = Quote::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'contact_id' => $data['contact_id'] ?? null, + 'issue_date' => $data['issue_date'], + 'expiry_date' => $data['expiry_date'] ?? null, + 'notes' => $data['notes'] ?? null, + 'created_by' => auth()->id(), + 'currency_code' => $data['currency_code'] ?? 'USD', + 'exchange_rate' => $data['exchange_rate'] ?? 1.0, + ]); + + $quote->update([ + 'number' => 'QUOTE-' . now()->format('Y') . '-' . str_pad((string) $quote->id, 5, '0', STR_PAD_LEFT), + ]); + + foreach ($data['items'] as $item) { + QuoteItem::create([ + 'quote_id' => $quote->id, + 'description' => $item['description'], + 'quantity' => $item['quantity'], + 'unit_price' => $item['unit_price'], + 'tax_rate' => $item['tax_rate'], + ]); + } + + return $quote; + }); + + return redirect()->route('finance.quotes.show', $quote) + ->with('success', 'Quote created.'); + } + + public function show(Quote $quote): Response + { + $this->authorize('view', $quote); + + $quote->load(['contact', 'items', 'creator']); + + return Inertia::render('Finance/Quotes/Show', [ + 'quote' => new QuoteResource($quote), + 'breadcrumbs' => [ + ['label' => 'Finance'], + ['label' => 'Quotes', 'href' => route('finance.quotes.index')], + ['label' => $quote->number ?? "Quote #{$quote->id}"], + ], + ]); + } + + public function send(Quote $quote): RedirectResponse + { + $this->authorize('update', $quote); + + try { + $quote->transitionTo('sent'); + } catch (\DomainException $e) { + return back()->withErrors(['status' => $e->getMessage()]); + } + + return back()->with('success', 'Quote marked as sent.'); + } + + public function accept(Quote $quote): RedirectResponse + { + $this->authorize('update', $quote); + + try { + $quote->transitionTo('accepted'); + } catch (\DomainException $e) { + return back()->withErrors(['status' => $e->getMessage()]); + } + + return back()->with('success', 'Quote accepted.'); + } + + public function decline(Quote $quote): RedirectResponse + { + $this->authorize('update', $quote); + + try { + $quote->transitionTo('declined'); + } catch (\DomainException $e) { + return back()->withErrors(['status' => $e->getMessage()]); + } + + return back()->with('success', 'Quote declined.'); + } + + public function convertToInvoice(Quote $quote): RedirectResponse + { + $this->authorize('update', $quote); + + if ($quote->status !== 'accepted') { + return back()->withErrors(['status' => 'Only accepted quotes can be converted to invoices.']); + } + + $invoice = DB::transaction(function () use ($quote) { + $quote->load('items'); + + $invoice = Invoice::create([ + 'tenant_id' => $quote->tenant_id, + 'contact_id' => $quote->contact_id, + 'issue_date' => now()->toDateString(), + 'status' => 'draft', + 'created_by' => auth()->id(), + ]); + + $invoice->update([ + 'number' => 'INV-' . now()->format('Y') . '-' . str_pad((string) $invoice->id, 5, '0', STR_PAD_LEFT), + ]); + + foreach ($quote->items as $item) { + InvoiceItem::create([ + 'invoice_id' => $invoice->id, + 'description' => $item->description, + 'quantity' => $item->quantity, + 'unit_price' => $item->unit_price, + 'tax_rate' => $item->tax_rate, + ]); + } + + return $invoice; + }); + + return redirect()->route('finance.invoices.show', $invoice) + ->with('success', 'Quote converted to invoice.'); + } + + public function destroy(Quote $quote): RedirectResponse + { + $this->authorize('delete', $quote); + + $quote->delete(); + + return redirect()->route('finance.quotes.index') + ->with('success', 'Quote deleted.'); + } + + public function pdf(Quote $quote): \Illuminate\Http\Response + { + $this->authorize('view', $quote); + $quote->load(['items', 'contact']); + $pdf = $this->renderDocumentPdf('pdf.quote', [ + 'quote' => $quote, + 'company' => $this->resolveCompanyName(), + ]); + $filename = 'quote-' . $quote->number . '.pdf'; + return response($pdf, 200, [ + 'Content-Type' => 'application/pdf', + 'Content-Disposition' => 'attachment; filename="' . $filename . '"', + ]); + } + + public function email(Request $request, Quote $quote): \Illuminate\Http\RedirectResponse + { + $this->authorize('update', $quote); + $request->validate(['email' => 'required|email', 'message' => 'nullable|string|max:1000']); + + $quote->load(['items', 'contact']); + $pdf = $this->renderDocumentPdf('pdf.quote', [ + 'quote' => $quote, + 'company' => $this->resolveCompanyName(), + ]); + $filename = 'quote-' . $quote->number . '.pdf'; + + $this->sendDocumentEmail($request, $request->input('email'), 'Quote ' . $quote->number, $pdf, $filename); + + if ($quote->status === 'draft') { + $quote->transitionTo('sent'); + } + + return back()->with('success', 'Quote emailed successfully.'); + } +} diff --git a/erp/app/Modules/Finance/Http/Controllers/ReconciliationController.php b/erp/app/Modules/Finance/Http/Controllers/ReconciliationController.php new file mode 100644 index 00000000000..d729604c150 --- /dev/null +++ b/erp/app/Modules/Finance/Http/Controllers/ReconciliationController.php @@ -0,0 +1,70 @@ +authorize('viewAny', BankTransaction::class); + $tenantId = $request->user()->tenant_id; + $accountId = $request->query('account_id'); + + $query = BankTransaction::where('tenant_id', $tenantId) + ->where('reconciled', false) + ->with(['bankAccount']) + ->orderBy('transaction_date'); + + if ($accountId) { + $query->where('bank_account_id', $accountId); + } + + $accounts = BankAccount::where('tenant_id', $tenantId)->get(); + + return Inertia::render('Finance/Reconciliation/Index', [ + 'transactions' => $query->paginate(50), + 'accounts' => $accounts, + 'account_id' => $accountId ? (int) $accountId : null, + ]); + } + + public function match(Request $request, BankTransaction $bankTransaction): RedirectResponse + { + $this->authorize('update', $bankTransaction); + $data = $request->validate([ + 'payment_id' => 'nullable|exists:payments,id', + 'journal_entry_id' => 'nullable|exists:journal_entries,id', + ]); + + if (empty($data['payment_id']) && empty($data['journal_entry_id'])) { + return back()->withErrors(['match' => 'Select a payment or journal entry to match.']); + } + + $bankTransaction->update([ + 'payment_id' => $data['payment_id'] ?? null, + 'journal_entry_id' => $data['journal_entry_id'] ?? null, + 'reconciled' => true, + ]); + + return back()->with('success', 'Transaction reconciled.'); + } + + public function unmatch(BankTransaction $bankTransaction): RedirectResponse + { + $this->authorize('update', $bankTransaction); + $bankTransaction->update([ + 'payment_id' => null, + 'journal_entry_id' => null, + 'reconciled' => false, + ]); + return back()->with('success', 'Transaction unmatched.'); + } +} diff --git a/erp/app/Modules/Finance/Http/Controllers/RecurringExpenseController.php b/erp/app/Modules/Finance/Http/Controllers/RecurringExpenseController.php new file mode 100644 index 00000000000..c08371c1136 --- /dev/null +++ b/erp/app/Modules/Finance/Http/Controllers/RecurringExpenseController.php @@ -0,0 +1,153 @@ +authorize('viewAny', RecurringExpense::class); + + $expenses = RecurringExpense::query() + ->when($request->status, fn ($q) => $q->where('status', $request->status)) + ->latest() + ->paginate(25) + ->withQueryString(); + + return Inertia::render('Finance/RecurringExpenses/Index', [ + 'recurringExpenses' => $expenses, + 'filters' => $request->only(['status']), + 'breadcrumbs' => [ + ['label' => 'Finance'], + ['label' => 'Recurring Expenses', 'href' => route('finance.recurring-expenses.index')], + ], + ]); + } + + public function create(): Response + { + $this->authorize('create', RecurringExpense::class); + + return Inertia::render('Finance/RecurringExpenses/Create', [ + 'breadcrumbs' => [ + ['label' => 'Finance'], + ['label' => 'Recurring Expenses', 'href' => route('finance.recurring-expenses.index')], + ['label' => 'New Recurring Expense'], + ], + ]); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', RecurringExpense::class); + + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'amount' => 'required|numeric|min:0', + 'frequency' => 'required|in:monthly,quarterly,annual,weekly', + 'start_date' => 'required|date', + ]); + + RecurringExpense::create([ + ...$validated, + 'tenant_id' => app('tenant')->id, + 'created_by' => auth()->id(), + ]); + + return redirect()->route('finance.recurring-expenses.index') + ->with('success', 'Recurring expense created.'); + } + + public function show(RecurringExpense $recurringExpense): Response + { + $this->authorize('view', $recurringExpense); + + return Inertia::render('Finance/RecurringExpenses/Show', [ + 'recurringExpense' => $recurringExpense, + 'breadcrumbs' => [ + ['label' => 'Finance'], + ['label' => 'Recurring Expenses', 'href' => route('finance.recurring-expenses.index')], + ['label' => $recurringExpense->name], + ], + ]); + } + + public function edit(RecurringExpense $recurringExpense): Response + { + $this->authorize('update', $recurringExpense); + + return Inertia::render('Finance/RecurringExpenses/Edit', [ + 'recurringExpense' => $recurringExpense, + 'breadcrumbs' => [ + ['label' => 'Finance'], + ['label' => 'Recurring Expenses', 'href' => route('finance.recurring-expenses.index')], + ['label' => $recurringExpense->name], + ['label' => 'Edit'], + ], + ]); + } + + public function update(Request $request, RecurringExpense $recurringExpense): RedirectResponse + { + $this->authorize('update', $recurringExpense); + + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'amount' => 'required|numeric|min:0', + 'frequency' => 'required|in:monthly,quarterly,annual,weekly', + 'start_date' => 'required|date', + ]); + + $recurringExpense->update($validated); + + return redirect()->route('finance.recurring-expenses.index') + ->with('success', 'Recurring expense updated.'); + } + + public function destroy(RecurringExpense $recurringExpense): RedirectResponse + { + $this->authorize('delete', $recurringExpense); + + $recurringExpense->delete(); + + return redirect()->route('finance.recurring-expenses.index') + ->with('success', 'Recurring expense deleted.'); + } + + public function pause(RecurringExpense $recurringExpense): RedirectResponse + { + $this->authorize('pause', $recurringExpense); + + $recurringExpense->pause(); + + return redirect()->route('finance.recurring-expenses.index') + ->with('success', 'Recurring expense paused.'); + } + + public function resume(RecurringExpense $recurringExpense): RedirectResponse + { + $this->authorize('resume', $recurringExpense); + + $recurringExpense->resume(); + + return redirect()->route('finance.recurring-expenses.index') + ->with('success', 'Recurring expense resumed.'); + } + + public function cancel(RecurringExpense $recurringExpense): RedirectResponse + { + $this->authorize('cancel', $recurringExpense); + + $recurringExpense->cancel(); + + return redirect()->route('finance.recurring-expenses.index') + ->with('success', 'Recurring expense cancelled.'); + } +} diff --git a/erp/app/Modules/Finance/Http/Controllers/RecurringInvoiceController.php b/erp/app/Modules/Finance/Http/Controllers/RecurringInvoiceController.php new file mode 100644 index 00000000000..656ce287267 --- /dev/null +++ b/erp/app/Modules/Finance/Http/Controllers/RecurringInvoiceController.php @@ -0,0 +1,166 @@ +authorize('viewAny', RecurringInvoice::class); + + $recurringInvoices = RecurringInvoice::with('contact') + ->when($request->status, fn ($q) => $q->where('status', $request->status)) + ->when($request->contact_id, fn ($q) => $q->where('contact_id', $request->contact_id)) + ->latest() + ->paginate(25) + ->withQueryString(); + + return Inertia::render('Finance/RecurringInvoices/Index', [ + 'recurringInvoices' => RecurringInvoiceResource::collection($recurringInvoices), + 'contacts' => Contact::customers()->active()->orderBy('name')->get(['id', 'name']), + 'filters' => $request->only(['status', 'contact_id']), + 'breadcrumbs' => [ + ['label' => 'Finance'], + ['label' => 'Recurring Invoices', 'href' => route('finance.recurring-invoices.index')], + ], + ]); + } + + public function create(): Response + { + $this->authorize('create', RecurringInvoice::class); + + return Inertia::render('Finance/RecurringInvoices/Create', [ + 'contacts' => Contact::customers()->active()->orderBy('name')->get(['id', 'name']), + 'breadcrumbs' => [ + ['label' => 'Finance'], + ['label' => 'Recurring Invoices', 'href' => route('finance.recurring-invoices.index')], + ['label' => 'New Recurring Invoice'], + ], + ]); + } + + public function store(StoreRecurringInvoiceRequest $request): RedirectResponse + { + $this->authorize('create', RecurringInvoice::class); + + $data = $request->validated(); + + $recurringInvoice = DB::transaction(function () use ($data) { + $recurringInvoice = RecurringInvoice::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'contact_id' => $data['contact_id'] ?? null, + 'reference_prefix' => $data['reference_prefix'] ?? 'REC-INV', + 'frequency' => $data['frequency'], + 'interval' => $data['interval'] ?? 1, + 'start_date' => $data['start_date'], + 'next_run_date' => $data['start_date'], + 'end_date' => $data['end_date'] ?? null, + 'due_days' => $data['due_days'], + 'auto_send' => (bool) ($data['auto_send'] ?? false), + 'currency_code' => $data['currency_code'] ?? 'USD', + 'exchange_rate' => $data['exchange_rate'] ?? 1, + 'notes' => $data['notes'] ?? null, + 'created_by' => auth()->id(), + ]); + + foreach ($data['items'] as $item) { + RecurringInvoiceItem::create([ + 'recurring_invoice_id' => $recurringInvoice->id, + 'description' => $item['description'], + 'quantity' => $item['quantity'], + 'unit_price' => $item['unit_price'], + 'tax_rate' => $item['tax_rate'], + ]); + } + + return $recurringInvoice; + }); + + return redirect()->route('finance.recurring-invoices.show', $recurringInvoice) + ->with('success', 'Recurring invoice created.'); + } + + public function show(RecurringInvoice $recurringInvoice): Response + { + $this->authorize('view', $recurringInvoice); + + $recurringInvoice->load(['contact', 'items', 'creator']); + + $generatedInvoices = $recurringInvoice->invoices() + ->latest('issue_date') + ->take(20) + ->get(['id', 'number', 'issue_date', 'status']); + + return Inertia::render('Finance/RecurringInvoices/Show', [ + 'recurringInvoice' => new RecurringInvoiceResource($recurringInvoice), + 'generatedInvoices' => $generatedInvoices, + 'breadcrumbs' => [ + ['label' => 'Finance'], + ['label' => 'Recurring Invoices', 'href' => route('finance.recurring-invoices.index')], + ['label' => "Recurring #{$recurringInvoice->id}"], + ], + ]); + } + + public function pause(RecurringInvoice $recurringInvoice): RedirectResponse + { + $this->authorize('update', $recurringInvoice); + + if ($recurringInvoice->status === 'active') { + $recurringInvoice->status = 'paused'; + $recurringInvoice->save(); + } + + return back()->with('success', 'Recurring invoice paused.'); + } + + public function resume(RecurringInvoice $recurringInvoice): RedirectResponse + { + $this->authorize('update', $recurringInvoice); + + if ($recurringInvoice->status === 'paused') { + $recurringInvoice->status = 'active'; + $recurringInvoice->save(); + } + + return back()->with('success', 'Recurring invoice resumed.'); + } + + public function generateNow(RecurringInvoice $recurringInvoice): RedirectResponse + { + $this->authorize('update', $recurringInvoice); + + if ($recurringInvoice->status !== 'active') { + return back()->withErrors(['status' => 'Only active templates can generate invoices.']); + } + + $invoice = $recurringInvoice->generateInvoice(); + + return redirect()->route('finance.invoices.show', $invoice) + ->with('success', 'Invoice generated.'); + } + + public function destroy(RecurringInvoice $recurringInvoice): RedirectResponse + { + $this->authorize('delete', $recurringInvoice); + + $recurringInvoice->delete(); + + return redirect()->route('finance.recurring-invoices.index') + ->with('success', 'Recurring invoice deleted.'); + } +} diff --git a/erp/app/Modules/Finance/Http/Controllers/ReportController.php b/erp/app/Modules/Finance/Http/Controllers/ReportController.php new file mode 100644 index 00000000000..47e83ad3732 --- /dev/null +++ b/erp/app/Modules/Finance/Http/Controllers/ReportController.php @@ -0,0 +1,1432 @@ +authorize('viewAny', Account::class); + + $totals = JournalLine::select('account_id', + DB::raw('SUM(debit) as total_debit'), + DB::raw('SUM(credit) as total_credit') + ) + ->whereHas('journalEntry', fn ($q) => $q->where('status', 'posted')) + ->groupBy('account_id') + ->get() + ->keyBy('account_id'); + + $accounts = Account::with('parent') + ->orderBy('code') + ->get() + ->map(function (Account $account) use ($totals) { + $row = $totals->get($account->id); + return [ + 'id' => $account->id, + 'code' => $account->code, + 'name' => $account->name, + 'type' => $account->type, + 'parent_name' => $account->parent?->name, + 'total_debit' => (float) ($row?->total_debit ?? 0), + 'total_credit' => (float) ($row?->total_credit ?? 0), + ]; + }); + + return Inertia::render('Finance/Reports/TrialBalance', [ + 'accounts' => $accounts, + 'breadcrumbs' => [ + ['label' => 'Finance'], + ['label' => 'Reports'], + ['label' => 'Trial Balance'], + ], + ]); + } + + public function profitAndLoss(Request $request): Response + { + $this->authorize('viewAny', Account::class); + + $from = $request->from ?? now()->startOfYear()->toDateString(); + $to = $request->to ?? now()->toDateString(); + + $totals = $this->aggregateJournalLines($from, $to); + + $accounts = Account::whereIn('type', ['income', 'expense']) + ->orderBy('code') + ->get(); + + $revenue = []; + $expenses = []; + + foreach ($accounts as $account) { + $row = $totals->get($account->id); + $debit = (float) ($row?->total_debit ?? 0); + $credit = (float) ($row?->total_credit ?? 0); + + $net = $account->type === 'income' + ? $credit - $debit + : $debit - $credit; + + $entry = [ + 'id' => $account->id, + 'code' => $account->code, + 'name' => $account->name, + 'type' => $account->type, + 'net' => $net, + ]; + + if ($account->type === 'income') { + $revenue[] = $entry; + } else { + $expenses[] = $entry; + } + } + + $totalRevenue = (float) array_sum(array_column($revenue, 'net')); + $totalExpenses = (float) array_sum(array_column($expenses, 'net')); + + return Inertia::render('Finance/Reports/ProfitLoss', [ + 'revenue' => $revenue, + 'expenses' => $expenses, + 'total_revenue' => $totalRevenue, + 'total_expenses' => $totalExpenses, + 'net' => $totalRevenue - $totalExpenses, + 'from' => $from, + 'to' => $to, + 'breadcrumbs' => [ + ['label' => 'Finance'], + ['label' => 'Reports'], + ['label' => 'Profit & Loss'], + ], + ]); + } + + public function balanceSheet(Request $request): Response + { + $this->authorize('viewAny', Account::class); + + $asOf = $request->as_of ?? now()->toDateString(); + + $totals = $this->aggregateJournalLines(null, $asOf); + + $accounts = Account::whereIn('type', ['asset', 'liability', 'equity']) + ->orderBy('code') + ->get(); + + $assets = []; + $liabilities = []; + $equity = []; + + foreach ($accounts as $account) { + $row = $totals->get($account->id); + $debit = (float) ($row?->total_debit ?? 0); + $credit = (float) ($row?->total_credit ?? 0); + + $net = $account->type === 'asset' + ? $debit - $credit + : $credit - $debit; + + $entry = [ + 'id' => $account->id, + 'code' => $account->code, + 'name' => $account->name, + 'type' => $account->type, + 'net' => $net, + ]; + + if ($account->type === 'asset') { + $assets[] = $entry; + } elseif ($account->type === 'liability') { + $liabilities[] = $entry; + } else { + $equity[] = $entry; + } + } + + $totalAssets = (float) array_sum(array_column($assets, 'net')); + $totalLiabilities = (float) array_sum(array_column($liabilities, 'net')); + $totalEquity = (float) array_sum(array_column($equity, 'net')); + + return Inertia::render('Finance/Reports/BalanceSheet', [ + 'assets' => $assets, + 'liabilities' => $liabilities, + 'equity' => $equity, + 'total_assets' => $totalAssets, + 'total_liabilities' => $totalLiabilities, + 'total_equity' => $totalEquity, + 'as_of' => $asOf, + 'breadcrumbs' => [ + ['label' => 'Finance'], + ['label' => 'Reports'], + ['label' => 'Balance Sheet'], + ], + ]); + } + + public function agedReceivables(Request $request): Response + { + $this->authorize('viewAny', Account::class); + $asOf = $request->as_of ?? now()->toDateString(); + + $invoices = Invoice::with(['contact', 'items', 'payments']) + ->whereNotIn('status', ['draft', 'paid', 'cancelled']) + ->get() + ->map(function ($inv) use ($asOf) { + $daysOverdue = 0; + if ($inv->due_date) { + $diff = \Carbon\Carbon::parse($asOf)->diffInDays($inv->due_date, false); + $daysOverdue = (int) max(0, $diff * -1); + } + $bucket = match(true) { + $daysOverdue === 0 => 'current', + $daysOverdue <= 30 => '1-30', + $daysOverdue <= 60 => '31-60', + $daysOverdue <= 90 => '61-90', + default => '90+', + }; + return [ + 'id' => $inv->id, + 'number' => $inv->number, + 'contact' => $inv->contact?->name ?? '—', + 'due_date' => $inv->due_date?->toDateString(), + 'amount_due' => (float) $inv->amount_due, + 'days_overdue'=> $daysOverdue, + 'bucket' => $bucket, + ]; + }); + + $bucketKeys = ['current', '1-30', '31-60', '61-90', '90+']; + $totals = collect($bucketKeys)->mapWithKeys(fn ($k) => + [$k => (float) $invoices->where('bucket', $k)->sum('amount_due')] + )->all(); + + return Inertia::render('Finance/Reports/AgedReceivables', [ + 'rows' => $invoices->values(), + 'totals' => $totals, + 'grand_total' => (float) $invoices->sum('amount_due'), + 'as_of' => $asOf, + 'breadcrumbs' => [ + ['label' => 'Finance'], + ['label' => 'Reports'], + ['label' => 'Aged Receivables'], + ], + ]); + } + + public function agedPayables(Request $request): Response + { + $this->authorize('viewAny', Account::class); + $asOf = $request->as_of ?? now()->toDateString(); + + $bills = Bill::with(['contact', 'items', 'payments']) + ->whereNotIn('status', ['draft', 'paid', 'cancelled']) + ->get() + ->map(function ($bill) use ($asOf) { + $daysOverdue = 0; + if ($bill->due_date) { + $diff = \Carbon\Carbon::parse($asOf)->diffInDays($bill->due_date, false); + $daysOverdue = (int) max(0, $diff * -1); + } + $bucket = match(true) { + $daysOverdue === 0 => 'current', + $daysOverdue <= 30 => '1-30', + $daysOverdue <= 60 => '31-60', + $daysOverdue <= 90 => '61-90', + default => '90+', + }; + return [ + 'id' => $bill->id, + 'number' => $bill->number, + 'contact' => $bill->contact?->name ?? '—', + 'due_date' => $bill->due_date?->toDateString(), + 'amount_due' => (float) $bill->amount_due, + 'days_overdue'=> $daysOverdue, + 'bucket' => $bucket, + ]; + }); + + $bucketKeys = ['current', '1-30', '31-60', '61-90', '90+']; + $totals = collect($bucketKeys)->mapWithKeys(fn ($k) => + [$k => (float) $bills->where('bucket', $k)->sum('amount_due')] + )->all(); + + return Inertia::render('Finance/Reports/AgedPayables', [ + 'rows' => $bills->values(), + 'totals' => $totals, + 'grand_total' => (float) $bills->sum('amount_due'), + 'as_of' => $asOf, + 'breadcrumbs' => [ + ['label' => 'Finance'], + ['label' => 'Reports'], + ['label' => 'Aged Payables'], + ], + ]); + } + + public function accountLedgerIndex(Request $request): Response + { + $this->authorize('viewAny', Account::class); + $accounts = Account::orderBy('code')->get(['id', 'code', 'name', 'type']); + return Inertia::render('Finance/Reports/AccountLedger', [ + 'accounts' => $accounts, + 'account' => null, + 'rows' => [], + 'from' => now()->startOfYear()->toDateString(), + 'to' => now()->toDateString(), + 'breadcrumbs' => [['label' => 'Finance'], ['label' => 'Reports'], ['label' => 'Account Ledger']], + ]); + } + + public function accountLedger(Request $request, Account $account): Response + { + $this->authorize('viewAny', Account::class); + $from = $request->from ?? now()->startOfYear()->toDateString(); + $to = $request->to ?? now()->toDateString(); + + // Use JOIN (not whereHas) so ordering by journal_entries.date works correctly + $lines = JournalLine::join('journal_entries', 'journal_entries.id', '=', 'journal_lines.journal_entry_id') + ->where('journal_lines.account_id', $account->id) + ->where('journal_entries.status', 'posted') + ->when($from, fn ($q) => $q->whereDate('journal_entries.date', '>=', $from)) + ->when($to, fn ($q) => $q->whereDate('journal_entries.date', '<=', $to)) + ->orderBy('journal_entries.date') + ->orderBy('journal_lines.id') + ->select('journal_lines.*', 'journal_entries.date as entry_date', + 'journal_entries.reference as entry_reference', + 'journal_entries.description as entry_description') + ->get(); + + $isDebitNormal = in_array($account->type, ['asset', 'expense'], true); + $runningBalance = 0.0; + $rows = []; + foreach ($lines as $line) { + $debit = (float) $line->debit; + $credit = (float) $line->credit; + $runningBalance += $isDebitNormal ? ($debit - $credit) : ($credit - $debit); + $rows[] = [ + 'id' => $line->id, + 'date' => $line->entry_date instanceof \Carbon\Carbon + ? $line->entry_date->toDateString() + : (string) $line->entry_date, + 'reference' => $line->entry_reference, + 'description' => $line->description ?? $line->entry_description, + 'debit' => $debit, + 'credit' => $credit, + 'balance' => $runningBalance, + ]; + } + + $accounts = Account::orderBy('code')->get(['id', 'code', 'name', 'type']); + + return Inertia::render('Finance/Reports/AccountLedger', [ + 'accounts' => $accounts, + 'account' => ['id' => $account->id, 'code' => $account->code, 'name' => $account->name, 'type' => $account->type], + 'rows' => $rows, + 'from' => $from, + 'to' => $to, + 'breadcrumbs' => [ + ['label' => 'Finance'], ['label' => 'Reports'], + ['label' => "Ledger: {$account->name}"], + ], + ]); + } + + public function customerStatementIndex(Request $request): Response + { + $this->authorize('viewAny', Account::class); + + $contactId = $request->get('contact_id'); + $from = $request->get('from', now()->startOfMonth()->toDateString()); + $to = $request->get('to', now()->toDateString()); + + $contacts = Contact::customers()->orderBy('name')->get(['id', 'name', 'email']); + + if (!$contactId) { + return Inertia::render('Finance/Reports/CustomerStatement', [ + 'contacts' => $contacts, + 'contact' => null, + 'lines' => [], + 'summary' => null, + 'from' => $from, + 'to' => $to, + 'breadcrumbs' => [['label' => 'Finance'], ['label' => 'Reports'], ['label' => 'Customer Statement']], + ]); + } + + $contact = Contact::findOrFail($contactId); + + // Opening balance: sum of (total - amount_paid) for invoices before $from + $priorInvoices = Invoice::with(['items', 'payments']) + ->where('contact_id', $contactId) + ->where('issue_date', '<', $from) + ->whereNotIn('status', ['draft', 'cancelled']) + ->get(); + + $openingBalance = (float) $priorInvoices->sum(fn ($inv) => $inv->total - $inv->amount_paid); + + // All invoices within date range + $invoices = Invoice::with(['items', 'payments']) + ->where('contact_id', $contactId) + ->whereBetween('issue_date', [$from, $to]) + ->whereNotIn('status', ['draft', 'cancelled']) + ->orderBy('issue_date') + ->get(); + + $lines = []; + $balance = $openingBalance; + + foreach ($invoices as $inv) { + $balance += $inv->total; + $lines[] = [ + 'date' => $inv->issue_date instanceof \Carbon\Carbon ? $inv->issue_date->toDateString() : (string) $inv->issue_date, + 'type' => 'Invoice', + 'reference' => $inv->number, + 'debit' => $inv->total, + 'credit' => 0, + 'balance' => round($balance, 2), + 'status' => $inv->status, + ]; + + if ($inv->amount_paid > 0) { + $balance -= $inv->amount_paid; + $lines[] = [ + 'date' => $inv->issue_date instanceof \Carbon\Carbon ? $inv->issue_date->toDateString() : (string) $inv->issue_date, + 'type' => 'Payment', + 'reference' => 'PMT-' . $inv->number, + 'debit' => 0, + 'credit' => $inv->amount_paid, + 'balance' => round($balance, 2), + 'status' => '', + ]; + } + } + + $summary = [ + 'opening_balance' => round($openingBalance, 2), + 'total_invoiced' => round($invoices->sum('total'), 2), + 'total_paid' => round($invoices->sum('amount_paid'), 2), + 'closing_balance' => round($balance, 2), + ]; + + return Inertia::render('Finance/Reports/CustomerStatement', [ + 'contacts' => $contacts, + 'contact' => $contact, + 'lines' => $lines, + 'summary' => $summary, + 'from' => $from, + 'to' => $to, + 'breadcrumbs' => [ + ['label' => 'Finance'], ['label' => 'Reports'], + ['label' => "Statement: {$contact->name}"], + ], + ]); + } + + public function customerStatement(Request $request, Contact $contact): Response + { + $this->authorize('viewAny', Account::class); + + $from = $request->from ?? now()->startOfYear()->toDateString(); + $to = $request->to ?? now()->toDateString(); + + $transactions = []; + + $invoices = Invoice::with(['items', 'payments']) + ->where('contact_id', $contact->id) + ->where('status', '!=', 'cancelled') + ->whereDate('issue_date', '>=', $from) + ->whereDate('issue_date', '<=', $to) + ->get(); + + foreach ($invoices as $invoice) { + $transactions[] = [ + 'date' => $invoice->issue_date?->toDateString(), + 'type' => 'Invoice', + 'reference' => $invoice->number, + 'debit' => (float) $invoice->total, + 'credit' => 0.0, + ]; + } + + $paymentInvoices = Invoice::with('payments') + ->where('contact_id', $contact->id) + ->where('status', '!=', 'cancelled') + ->get(); + + foreach ($paymentInvoices as $invoice) { + foreach ($invoice->payments as $payment) { + $paymentDate = $payment->payment_date instanceof \Carbon\Carbon + ? $payment->payment_date->toDateString() + : (string) $payment->payment_date; + + if ($paymentDate < $from || $paymentDate > $to) { + continue; + } + + $transactions[] = [ + 'date' => $paymentDate, + 'type' => 'Payment', + 'reference' => $invoice->number, + 'debit' => 0.0, + 'credit' => (float) $payment->amount, + ]; + } + } + + $creditNotes = CreditNote::with('items') + ->where('contact_id', $contact->id) + ->whereIn('status', ['issued', 'applied']) + ->whereDate('issue_date', '>=', $from) + ->whereDate('issue_date', '<=', $to) + ->get(); + + foreach ($creditNotes as $creditNote) { + $transactions[] = [ + 'date' => $creditNote->issue_date?->toDateString(), + 'type' => 'Credit Note', + 'reference' => $creditNote->number, + 'debit' => 0.0, + 'credit' => (float) $creditNote->total, + ]; + } + + usort($transactions, fn ($a, $b) => strcmp((string) $a['date'], (string) $b['date'])); + + $balance = 0.0; + $totalDebit = 0.0; + $totalCredit = 0.0; + $rows = []; + foreach ($transactions as $txn) { + $balance += $txn['debit'] - $txn['credit']; + $totalDebit += $txn['debit']; + $totalCredit += $txn['credit']; + $rows[] = array_merge($txn, ['balance' => $balance]); + } + + return Inertia::render('Finance/Reports/CustomerStatement', [ + 'contacts' => Contact::customers()->orderBy('name')->get(['id', 'name']), + 'contact' => ['id' => $contact->id, 'name' => $contact->name], + 'rows' => $rows, + 'from' => $from, + 'to' => $to, + 'total_debit' => $totalDebit, + 'total_credit' => $totalCredit, + 'closing_balance' => $balance, + 'breadcrumbs' => [ + ['label' => 'Finance'], ['label' => 'Reports'], + ['label' => "Statement: {$contact->name}"], + ], + ]); + } + + public function exportCustomerStatement(Request $request): \Symfony\Component\HttpFoundation\StreamedResponse + { + $this->authorize('viewAny', Invoice::class); + + $contactId = $request->get('contact_id'); + $from = $request->get('from', now()->startOfMonth()->toDateString()); + $to = $request->get('to', now()->toDateString()); + + abort_unless($contactId, 422, 'contact_id is required.'); + $contact = Contact::findOrFail($contactId); + + $priorInvoices = Invoice::with(['items', 'payments']) + ->where('contact_id', $contactId) + ->where('issue_date', '<', $from) + ->whereNotIn('status', ['draft', 'cancelled']) + ->get(); + + $openingBalance = (float) $priorInvoices->sum(fn ($inv) => $inv->total - $inv->amount_paid); + + $invoices = Invoice::with(['items', 'payments']) + ->where('contact_id', $contactId) + ->whereBetween('issue_date', [$from, $to]) + ->whereNotIn('status', ['draft', 'cancelled']) + ->orderBy('issue_date') + ->get(); + + $balance = $openingBalance; + $rows = [['Opening Balance', '', '', '', '', round($balance, 2)]]; + + foreach ($invoices as $inv) { + $balance += $inv->total; + $issueDate = $inv->issue_date instanceof \Carbon\Carbon ? $inv->issue_date->toDateString() : (string) $inv->issue_date; + $rows[] = [$issueDate, 'Invoice', $inv->number, $inv->total, 0, round($balance, 2)]; + if ($inv->amount_paid > 0) { + $balance -= $inv->amount_paid; + $rows[] = [$issueDate, 'Payment', 'PMT-' . $inv->number, 0, $inv->amount_paid, round($balance, 2)]; + } + } + + $filename = "statement-{$contact->name}-{$from}-{$to}.csv"; + + return $this->streamCsv( + $filename, + ['Date', 'Type', 'Reference', 'Debit', 'Credit', 'Balance'], + $rows + ); + } + + public function vatReport(Request $request): Response + { + $this->authorize('viewAny', Invoice::class); + + $tenantId = $request->user()->tenant_id; + $from = $request->query('from', now()->startOfQuarter()->toDateString()); + $to = $request->query('to', now()->endOfQuarter()->toDateString()); + + // Output VAT: tax collected on invoices (not cancelled) within the period + $invoices = Invoice::where('tenant_id', $tenantId) + ->whereNotIn('status', ['cancelled']) + ->whereBetween('issue_date', [$from, $to]) + ->with('items') + ->get(); + + $outputLines = $invoices->map(function ($invoice) { + $net = $invoice->subtotal; + $tax = $invoice->tax_total; + return [ + 'id' => $invoice->id, + 'number' => $invoice->number, + 'date' => $invoice->issue_date, + 'contact' => $invoice->contact?->name, + 'net' => round($net, 2), + 'tax' => round($tax, 2), + 'type' => 'invoice', + ]; + })->filter(fn ($line) => $line['tax'] != 0)->values(); + + // Input VAT: tax paid on bills (not cancelled) within the period + $bills = Bill::where('tenant_id', $tenantId) + ->whereNotIn('status', ['cancelled']) + ->whereBetween('issue_date', [$from, $to]) + ->with('items') + ->get(); + + $inputLines = $bills->map(function ($bill) { + $net = $bill->subtotal; + $tax = $bill->tax_total; + return [ + 'id' => $bill->id, + 'number' => $bill->number, + 'date' => $bill->issue_date, + 'contact' => $bill->contact?->name, + 'net' => round($net, 2), + 'tax' => round($tax, 2), + 'type' => 'bill', + ]; + })->filter(fn ($line) => $line['tax'] != 0)->values(); + + $totalOutputVat = round($outputLines->sum('tax'), 2); + $totalInputVat = round($inputLines->sum('tax'), 2); + $netVat = round($totalOutputVat - $totalInputVat, 2); + + return Inertia::render('Finance/Reports/VatReport', [ + 'output_lines' => $outputLines, + 'input_lines' => $inputLines, + 'total_output_vat' => $totalOutputVat, + 'total_input_vat' => $totalInputVat, + 'net_vat' => $netVat, + 'from' => $from, + 'to' => $to, + ]); + } + + public function cashFlowForecast(Request $request): Response + { + $this->authorize('viewAny', Invoice::class); + + $weeks = (int) $request->get('weeks', 12); + $openingBalance = (float) $request->get('opening_balance', 0); + $from = now()->startOfDay(); + $to = now()->addWeeks($weeks)->endOfDay(); + + // Collect open invoices (inflows) due within horizon + $invoices = Invoice::with('contact') + ->whereIn('status', ['sent', 'partial']) + ->whereBetween('due_date', [$from->toDateString(), $to->toDateString()]) + ->get(); + + // Collect open bills (outflows) due within horizon + $bills = Bill::with('contact') + ->whereIn('status', ['received', 'partial']) + ->whereBetween('due_date', [$from->toDateString(), $to->toDateString()]) + ->get(); + + // Build weekly buckets + $buckets = []; + for ($i = 0; $i < $weeks; $i++) { + $weekStart = now()->addWeeks($i)->startOfWeek()->toDateString(); + $weekEnd = now()->addWeeks($i)->endOfWeek()->toDateString(); + $buckets[$weekStart] = [ + 'week_start' => $weekStart, + 'week_end' => $weekEnd, + 'inflows' => [], + 'outflows' => [], + ]; + } + + // Place invoices into their week bucket + foreach ($invoices as $inv) { + $due = $inv->due_date instanceof \Carbon\Carbon + ? $inv->due_date->toDateString() + : (string) $inv->due_date; + foreach ($buckets as $weekStart => $bucket) { + if ($due >= $bucket['week_start'] && $due <= $bucket['week_end']) { + $buckets[$weekStart]['inflows'][] = [ + 'reference' => $inv->reference, + 'contact' => $inv->contact?->name ?? '—', + 'due_date' => $due, + 'amount' => $inv->total - $inv->amount_paid, + ]; + break; + } + } + } + + // Place bills into their week bucket + foreach ($bills as $bill) { + $due = $bill->due_date instanceof \Carbon\Carbon + ? $bill->due_date->toDateString() + : (string) $bill->due_date; + foreach ($buckets as $weekStart => $bucket) { + if ($due >= $bucket['week_start'] && $due <= $bucket['week_end']) { + $buckets[$weekStart]['outflows'][] = [ + 'reference' => $bill->reference, + 'contact' => $bill->contact?->name ?? '—', + 'due_date' => $due, + 'amount' => $bill->total - $bill->amount_paid, + ]; + break; + } + } + } + + // Compute running balance per week + $balance = $openingBalance; + $result = []; + foreach ($buckets as $bucket) { + $inflow = array_sum(array_column($bucket['inflows'], 'amount')); + $outflow = array_sum(array_column($bucket['outflows'], 'amount')); + $balance += $inflow - $outflow; + $result[] = [ + 'week_start' => $bucket['week_start'], + 'week_end' => $bucket['week_end'], + 'inflows' => $bucket['inflows'], + 'outflows' => $bucket['outflows'], + 'total_inflow' => round($inflow, 2), + 'total_outflow' => round($outflow, 2), + 'net' => round($inflow - $outflow, 2), + 'closing_balance' => round($balance, 2), + ]; + } + + return Inertia::render('Finance/Reports/CashFlowForecast', [ + 'buckets' => $result, + 'openingBalance' => $openingBalance, + 'weeks' => $weeks, + 'totalInflow' => round(array_sum(array_column($result, 'total_inflow')), 2), + 'totalOutflow' => round(array_sum(array_column($result, 'total_outflow')), 2), + ]); + } + + public function exportCashFlowForecast(Request $request): \Symfony\Component\HttpFoundation\StreamedResponse + { + $this->authorize('viewAny', Invoice::class); + + $weeks = (int) $request->get('weeks', 12); + $openingBalance = (float) $request->get('opening_balance', 0); + $from = now()->startOfDay(); + $to = now()->addWeeks($weeks)->endOfDay(); + + $invoices = Invoice::whereIn('status', ['sent', 'partial']) + ->whereBetween('due_date', [$from->toDateString(), $to->toDateString()])->get(); + $bills = Bill::whereIn('status', ['received', 'partial']) + ->whereBetween('due_date', [$from->toDateString(), $to->toDateString()])->get(); + + // Rebuild buckets same as above, minimal version for export + $buckets = []; + for ($i = 0; $i < $weeks; $i++) { + $ws = now()->addWeeks($i)->startOfWeek()->toDateString(); + $we = now()->addWeeks($i)->endOfWeek()->toDateString(); + $buckets[$ws] = ['week_start' => $ws, 'week_end' => $we, 'inflow' => 0.0, 'outflow' => 0.0]; + } + foreach ($invoices as $inv) { + $due = $inv->due_date instanceof \Carbon\Carbon ? $inv->due_date->toDateString() : (string) $inv->due_date; + foreach ($buckets as $ws => &$b) { + if ($due >= $b['week_start'] && $due <= $b['week_end']) { + $b['inflow'] += $inv->total - $inv->amount_paid; + break; + } + } + } + foreach ($bills as $bill) { + $due = $bill->due_date instanceof \Carbon\Carbon ? $bill->due_date->toDateString() : (string) $bill->due_date; + foreach ($buckets as $ws => &$b) { + if ($due >= $b['week_start'] && $due <= $b['week_end']) { + $b['outflow'] += $bill->total - $bill->amount_paid; + break; + } + } + } + + $balance = $openingBalance; + $rows = []; + foreach ($buckets as $b) { + $balance += $b['inflow'] - $b['outflow']; + $rows[] = [ + $b['week_start'], + $b['week_end'], + round($b['inflow'], 2), + round($b['outflow'], 2), + round($b['inflow'] - $b['outflow'], 2), + round($balance, 2), + ]; + } + + return $this->streamCsv( + 'cash-flow-forecast.csv', + ['Week Start', 'Week End', 'Inflows', 'Outflows', 'Net', 'Closing Balance'], + $rows + ); + } + + public function supplierStatement(Request $request): Response + { + $this->authorize('viewAny', Bill::class); + + $contactId = $request->get('contact_id'); + $from = $request->get('from', now()->startOfMonth()->toDateString()); + $to = $request->get('to', now()->toDateString()); + + $contacts = Contact::vendors()->orderBy('name')->get(['id', 'name', 'email']); + + if (!$contactId) { + return Inertia::render('Finance/Reports/SupplierStatement', [ + 'contacts' => $contacts, + 'contact' => null, + 'lines' => [], + 'summary' => null, + 'from' => $from, + 'to' => $to, + ]); + } + + $contact = Contact::findOrFail($contactId); + + // Opening balance: unpaid bill amounts before $from + $openingBalance = (float) Bill::with(['items', 'payments']) + ->where('contact_id', $contactId) + ->where('issue_date', '<', $from) + ->whereNotIn('status', ['draft', 'cancelled']) + ->get() + ->sum(fn ($b) => $b->total - $b->amount_paid); + + $bills = Bill::with(['items', 'payments']) + ->where('contact_id', $contactId) + ->whereBetween('issue_date', [$from, $to]) + ->whereNotIn('status', ['draft', 'cancelled']) + ->orderBy('issue_date') + ->get(); + + $lines = []; + $balance = $openingBalance; + + foreach ($bills as $bill) { + $balance += $bill->total; + $lines[] = [ + 'date' => $bill->issue_date instanceof \Carbon\Carbon ? $bill->issue_date->toDateString() : (string) $bill->issue_date, + 'type' => 'Bill', + 'reference' => $bill->number, + 'debit' => $bill->total, + 'credit' => 0, + 'balance' => round($balance, 2), + 'status' => $bill->status, + ]; + + if ($bill->amount_paid > 0) { + $balance -= $bill->amount_paid; + $lines[] = [ + 'date' => $bill->issue_date instanceof \Carbon\Carbon ? $bill->issue_date->toDateString() : (string) $bill->issue_date, + 'type' => 'Payment', + 'reference' => 'PMT-' . $bill->number, + 'debit' => 0, + 'credit' => $bill->amount_paid, + 'balance' => round($balance, 2), + 'status' => '', + ]; + } + } + + $summary = [ + 'opening_balance' => round($openingBalance, 2), + 'total_billed' => round($bills->sum('total'), 2), + 'total_paid' => round($bills->sum('amount_paid'), 2), + 'closing_balance' => round($balance, 2), + ]; + + return Inertia::render('Finance/Reports/SupplierStatement', [ + 'contacts' => $contacts, + 'contact' => $contact, + 'lines' => $lines, + 'summary' => $summary, + 'from' => $from, + 'to' => $to, + ]); + } + + public function exportSupplierStatement(Request $request): \Symfony\Component\HttpFoundation\StreamedResponse + { + $this->authorize('viewAny', Bill::class); + + $contactId = $request->get('contact_id'); + $from = $request->get('from', now()->startOfMonth()->toDateString()); + $to = $request->get('to', now()->toDateString()); + + abort_unless($contactId, 422, 'contact_id is required.'); + $contact = Contact::findOrFail($contactId); + + $openingBalance = (float) Bill::with(['items', 'payments']) + ->where('contact_id', $contactId) + ->where('issue_date', '<', $from) + ->whereNotIn('status', ['draft', 'cancelled']) + ->get() + ->sum(fn ($b) => $b->total - $b->amount_paid); + + $bills = Bill::with(['items', 'payments']) + ->where('contact_id', $contactId) + ->whereBetween('issue_date', [$from, $to]) + ->whereNotIn('status', ['draft', 'cancelled']) + ->orderBy('issue_date') + ->get(); + + $balance = $openingBalance; + $rows = [['Opening Balance', '', '', '', '', round($balance, 2)]]; + + foreach ($bills as $bill) { + $balance += $bill->total; + $rows[] = [(string)$bill->issue_date, 'Bill', $bill->number, $bill->total, 0, round($balance, 2)]; + if ($bill->amount_paid > 0) { + $balance -= $bill->amount_paid; + $rows[] = [(string)$bill->issue_date, 'Payment', 'PMT-' . $bill->number, 0, $bill->amount_paid, round($balance, 2)]; + } + } + + $filename = "supplier-statement-{$contact->name}-{$from}-{$to}.csv"; + + return $this->streamCsv( + $filename, + ['Date', 'Type', 'Reference', 'Debit', 'Credit', 'Balance'], + $rows + ); + } + + public function comparativeProfitLoss(Request $request): Response + { + $this->authorize('viewAny', Account::class); + + $currentFrom = $request->get('current_from', now()->startOfMonth()->toDateString()); + $currentTo = $request->get('current_to', now()->toDateString()); + $priorFrom = $request->get('prior_from', now()->subMonth()->startOfMonth()->toDateString()); + $priorTo = $request->get('prior_to', now()->subMonth()->endOfMonth()->toDateString()); + + $buildSection = function (string $type, string $from, string $to): array { + $totals = $this->aggregateJournalLines($from, $to); + + return Account::where('type', $type) + ->orderBy('name') + ->get() + ->map(function (Account $account) use ($totals, $type) { + $row = $totals->get($account->id); + $debit = (float) ($row?->total_debit ?? 0); + $credit = (float) ($row?->total_credit ?? 0); + $balance = $type === 'income' + ? $credit - $debit + : $debit - $credit; + return [ + 'id' => $account->id, + 'name' => $account->name, + 'code' => $account->code ?? '', + 'balance' => round($balance, 2), + ]; + }) + ->filter(fn ($row) => $row['balance'] != 0) + ->values() + ->toArray(); + }; + + $currentIncome = $buildSection('income', $currentFrom, $currentTo); + $currentExpenses = $buildSection('expense', $currentFrom, $currentTo); + $priorIncome = $buildSection('income', $priorFrom, $priorTo); + $priorExpenses = $buildSection('expense', $priorFrom, $priorTo); + + // Merge account lists (union of both periods) + $allIncomeIds = collect(array_merge($currentIncome, $priorIncome))->pluck('id')->unique(); + $allExpenseIds = collect(array_merge($currentExpenses, $priorExpenses))->pluck('id')->unique(); + + $indexBy = fn (array $rows) => collect($rows)->keyBy('id'); + + $currentIncomeIdx = $indexBy($currentIncome); + $priorIncomeIdx = $indexBy($priorIncome); + $currentExpenseIdx = $indexBy($currentExpenses); + $priorExpenseIdx = $indexBy($priorExpenses); + + $mergeRows = function ($ids, $currentIdx, $priorIdx) { + return $ids->map(function ($id) use ($currentIdx, $priorIdx) { + $cur = $currentIdx->get($id); + $pri = $priorIdx->get($id); + return [ + 'id' => $id, + 'name' => ($cur ?? $pri)['name'], + 'code' => ($cur ?? $pri)['code'], + 'current' => $cur['balance'] ?? 0, + 'prior' => $pri['balance'] ?? 0, + 'change' => round(($cur['balance'] ?? 0) - ($pri['balance'] ?? 0), 2), + ]; + })->sortBy('name')->values()->toArray(); + }; + + $incomeRows = $mergeRows($allIncomeIds, $currentIncomeIdx, $priorIncomeIdx); + $expenseRows = $mergeRows($allExpenseIds, $currentExpenseIdx, $priorExpenseIdx); + + $totalCurrentIncome = round(collect($incomeRows)->sum('current'), 2); + $totalPriorIncome = round(collect($incomeRows)->sum('prior'), 2); + $totalCurrentExpenses = round(collect($expenseRows)->sum('current'), 2); + $totalPriorExpenses = round(collect($expenseRows)->sum('prior'), 2); + + return Inertia::render('Finance/Reports/ComparativeProfitLoss', [ + 'incomeRows' => $incomeRows, + 'expenseRows' => $expenseRows, + 'totalCurrentIncome' => $totalCurrentIncome, + 'totalPriorIncome' => $totalPriorIncome, + 'totalCurrentExpenses' => $totalCurrentExpenses, + 'totalPriorExpenses' => $totalPriorExpenses, + 'netCurrentProfit' => round($totalCurrentIncome - $totalCurrentExpenses, 2), + 'netPriorProfit' => round($totalPriorIncome - $totalPriorExpenses, 2), + 'currentFrom' => $currentFrom, + 'currentTo' => $currentTo, + 'priorFrom' => $priorFrom, + 'priorTo' => $priorTo, + ]); + } + + public function exportComparativeProfitLoss(Request $request): \Symfony\Component\HttpFoundation\StreamedResponse + { + $this->authorize('viewAny', Account::class); + + $currentFrom = $request->get('current_from', now()->startOfMonth()->toDateString()); + $currentTo = $request->get('current_to', now()->toDateString()); + $priorFrom = $request->get('prior_from', now()->subMonth()->startOfMonth()->toDateString()); + $priorTo = $request->get('prior_to', now()->subMonth()->endOfMonth()->toDateString()); + + $buildSection = function (string $type, string $from, string $to): array { + $totals = $this->aggregateJournalLines($from, $to); + + return Account::where('type', $type) + ->orderBy('name') + ->get() + ->map(function (Account $account) use ($totals, $type) { + $row = $totals->get($account->id); + $debit = (float) ($row?->total_debit ?? 0); + $credit = (float) ($row?->total_credit ?? 0); + $balance = $type === 'income' + ? $credit - $debit + : $debit - $credit; + return [ + 'id' => $account->id, + 'name' => $account->name, + 'code' => $account->code ?? '', + 'balance' => round($balance, 2), + ]; + }) + ->filter(fn ($row) => $row['balance'] != 0) + ->values() + ->toArray(); + }; + + $currentIncome = $buildSection('income', $currentFrom, $currentTo); + $currentExpenses = $buildSection('expense', $currentFrom, $currentTo); + $priorIncome = $buildSection('income', $priorFrom, $priorTo); + $priorExpenses = $buildSection('expense', $priorFrom, $priorTo); + + $allIncomeIds = collect(array_merge($currentIncome, $priorIncome))->pluck('id')->unique(); + $allExpenseIds = collect(array_merge($currentExpenses, $priorExpenses))->pluck('id')->unique(); + + $indexBy = fn (array $rows) => collect($rows)->keyBy('id'); + + $currentIncomeIdx = $indexBy($currentIncome); + $priorIncomeIdx = $indexBy($priorIncome); + $currentExpenseIdx = $indexBy($currentExpenses); + $priorExpenseIdx = $indexBy($priorExpenses); + + $rows = []; + + foreach ($allIncomeIds as $id) { + $cur = $currentIncomeIdx->get($id); + $pri = $priorIncomeIdx->get($id); + $name = ($cur ?? $pri)['name']; + $code = ($cur ?? $pri)['code']; + $rows[] = [ + 'Income', + $code, + $name, + number_format($cur['balance'] ?? 0, 2, '.', ''), + number_format($pri['balance'] ?? 0, 2, '.', ''), + number_format(($cur['balance'] ?? 0) - ($pri['balance'] ?? 0), 2, '.', ''), + ]; + } + + foreach ($allExpenseIds as $id) { + $cur = $currentExpenseIdx->get($id); + $pri = $priorExpenseIdx->get($id); + $name = ($cur ?? $pri)['name']; + $code = ($cur ?? $pri)['code']; + $rows[] = [ + 'Expense', + $code, + $name, + number_format($cur['balance'] ?? 0, 2, '.', ''), + number_format($pri['balance'] ?? 0, 2, '.', ''), + number_format(($cur['balance'] ?? 0) - ($pri['balance'] ?? 0), 2, '.', ''), + ]; + } + + $totalCurrentIncome = collect($currentIncome)->sum('balance'); + $totalPriorIncome = collect($priorIncome)->sum('balance'); + $totalCurrentExpenses = collect($currentExpenses)->sum('balance'); + $totalPriorExpenses = collect($priorExpenses)->sum('balance'); + + $netCurrent = $totalCurrentIncome - $totalCurrentExpenses; + $netPrior = $totalPriorIncome - $totalPriorExpenses; + $rows[] = ['Net Profit', '', '', number_format($netCurrent, 2, '.', ''), number_format($netPrior, 2, '.', ''), number_format($netCurrent - $netPrior, 2, '.', '')]; + + return $this->streamCsv( + "comparative-profit-loss-{$currentFrom}-{$currentTo}.csv", + ['Section', 'Code', 'Account', 'Current Period', 'Prior Period', 'Change'], + $rows + ); + } + + // ─── CSV Export Methods ─────────────────────────────────────────────────── + + public function exportProfitLoss(Request $request): \Symfony\Component\HttpFoundation\StreamedResponse + { + $this->authorize('viewAny', Account::class); + + $from = $request->from ?? now()->startOfYear()->toDateString(); + $to = $request->to ?? now()->toDateString(); + + $totals = $this->aggregateJournalLines($from, $to); + $accounts = Account::whereIn('type', ['income', 'expense'])->orderBy('code')->get(); + + $revenue = []; + $expenses = []; + + foreach ($accounts as $account) { + $row = $totals->get($account->id); + $debit = (float) ($row?->total_debit ?? 0); + $credit = (float) ($row?->total_credit ?? 0); + $net = $account->type === 'income' ? $credit - $debit : $debit - $credit; + + $entry = ['type' => $account->type === 'income' ? 'Revenue' : 'Expense', 'name' => $account->name, 'net' => $net]; + + if ($account->type === 'income') { + $revenue[] = $entry; + } else { + $expenses[] = $entry; + } + } + + $totalRevenue = array_sum(array_column($revenue, 'net')); + $totalExpenses = array_sum(array_column($expenses, 'net')); + + $rows = []; + foreach ($revenue as $r) { $rows[] = [$r['type'], $r['name'], number_format($r['net'], 2, '.', '')]; } + foreach ($expenses as $r) { $rows[] = [$r['type'], $r['name'], number_format($r['net'], 2, '.', '')]; } + $rows[] = ['Net', 'Net Profit / Loss', number_format($totalRevenue - $totalExpenses, 2, '.', '')]; + + return $this->streamCsv( + "profit-loss-{$from}-{$to}.csv", + ['Type', 'Account', 'Amount'], + $rows + ); + } + + public function exportBalanceSheet(Request $request): \Symfony\Component\HttpFoundation\StreamedResponse + { + $this->authorize('viewAny', Account::class); + + $asOf = $request->as_of ?? now()->toDateString(); + $totals = $this->aggregateJournalLines(null, $asOf); + + $accounts = Account::whereIn('type', ['asset', 'liability', 'equity'])->orderBy('code')->get(); + + $rows = []; + foreach ($accounts as $account) { + $row = $totals->get($account->id); + $debit = (float) ($row?->total_debit ?? 0); + $credit = (float) ($row?->total_credit ?? 0); + $net = $account->type === 'asset' ? $debit - $credit : $credit - $debit; + + $section = ucfirst($account->type); + $rows[] = [$section, $account->name, number_format($net, 2, '.', '')]; + } + + return $this->streamCsv( + "balance-sheet-{$asOf}.csv", + ['Section', 'Account', 'Balance'], + $rows + ); + } + + public function exportAgedReceivables(Request $request): \Symfony\Component\HttpFoundation\StreamedResponse + { + $this->authorize('viewAny', Account::class); + + $asOf = $request->as_of ?? now()->toDateString(); + + $invoices = Invoice::with(['contact', 'items', 'payments']) + ->whereNotIn('status', ['draft', 'paid', 'cancelled']) + ->get(); + + $rows = []; + foreach ($invoices as $inv) { + $daysOverdue = 0; + if ($inv->due_date) { + $diff = \Carbon\Carbon::parse($asOf)->diffInDays($inv->due_date, false); + $daysOverdue = (int) max(0, $diff * -1); + } + $bucket = match (true) { + $daysOverdue === 0 => 'current', + $daysOverdue <= 30 => '1-30', + $daysOverdue <= 60 => '31-60', + $daysOverdue <= 90 => '61-90', + default => '90+', + }; + $amountDue = (float) $inv->amount_due; + $rows[] = [ + $inv->contact?->name ?? '—', + $inv->number ?? '', + $inv->issue_date?->toDateString() ?? '', + $inv->due_date?->toDateString() ?? '', + $bucket === 'current' ? number_format($amountDue, 2, '.', '') : '0.00', + $bucket === '1-30' ? number_format($amountDue, 2, '.', '') : '0.00', + $bucket === '31-60' ? number_format($amountDue, 2, '.', '') : '0.00', + $bucket === '61-90' ? number_format($amountDue, 2, '.', '') : '0.00', + $bucket === '90+' ? number_format($amountDue, 2, '.', '') : '0.00', + number_format($amountDue, 2, '.', ''), + ]; + } + + return $this->streamCsv( + "aged-receivables-{$asOf}.csv", + ['Customer', 'Invoice #', 'Issue Date', 'Due Date', 'Current', '1-30', '31-60', '61-90', '90+', 'Total'], + $rows + ); + } + + public function exportAgedPayables(Request $request): \Symfony\Component\HttpFoundation\StreamedResponse + { + $this->authorize('viewAny', Account::class); + + $asOf = $request->as_of ?? now()->toDateString(); + + $bills = Bill::with(['contact', 'items', 'payments']) + ->whereNotIn('status', ['draft', 'paid', 'cancelled']) + ->get(); + + $rows = []; + foreach ($bills as $bill) { + $daysOverdue = 0; + if ($bill->due_date) { + $diff = \Carbon\Carbon::parse($asOf)->diffInDays($bill->due_date, false); + $daysOverdue = (int) max(0, $diff * -1); + } + $bucket = match (true) { + $daysOverdue === 0 => 'current', + $daysOverdue <= 30 => '1-30', + $daysOverdue <= 60 => '31-60', + $daysOverdue <= 90 => '61-90', + default => '90+', + }; + $amountDue = (float) $bill->amount_due; + $rows[] = [ + $bill->contact?->name ?? '—', + $bill->number ?? '', + $bill->issue_date?->toDateString() ?? '', + $bill->due_date?->toDateString() ?? '', + $bucket === 'current' ? number_format($amountDue, 2, '.', '') : '0.00', + $bucket === '1-30' ? number_format($amountDue, 2, '.', '') : '0.00', + $bucket === '31-60' ? number_format($amountDue, 2, '.', '') : '0.00', + $bucket === '61-90' ? number_format($amountDue, 2, '.', '') : '0.00', + $bucket === '90+' ? number_format($amountDue, 2, '.', '') : '0.00', + number_format($amountDue, 2, '.', ''), + ]; + } + + return $this->streamCsv( + "aged-payables-{$asOf}.csv", + ['Vendor', 'Bill #', 'Issue Date', 'Due Date', 'Current', '1-30', '31-60', '61-90', '90+', 'Total'], + $rows + ); + } + + public function exportAccountLedger(Request $request, Account $account): \Symfony\Component\HttpFoundation\StreamedResponse + { + $this->authorize('viewAny', Account::class); + + $from = $request->from ?? now()->startOfYear()->toDateString(); + $to = $request->to ?? now()->toDateString(); + + $lines = JournalLine::join('journal_entries', 'journal_entries.id', '=', 'journal_lines.journal_entry_id') + ->where('journal_lines.account_id', $account->id) + ->where('journal_entries.status', 'posted') + ->when($from, fn ($q) => $q->whereDate('journal_entries.date', '>=', $from)) + ->when($to, fn ($q) => $q->whereDate('journal_entries.date', '<=', $to)) + ->orderBy('journal_entries.date') + ->orderBy('journal_lines.id') + ->select('journal_lines.*', 'journal_entries.date as entry_date', + 'journal_entries.reference as entry_reference', + 'journal_entries.description as entry_description') + ->get(); + + $isDebitNormal = in_array($account->type, ['asset', 'expense'], true); + $runningBalance = 0.0; + $rows = []; + + foreach ($lines as $line) { + $debit = (float) $line->debit; + $credit = (float) $line->credit; + $runningBalance += $isDebitNormal ? ($debit - $credit) : ($credit - $debit); + $description = $line->description ?? $line->entry_description; + $rows[] = [ + $line->entry_date instanceof \Carbon\Carbon + ? $line->entry_date->toDateString() + : (string) $line->entry_date, + $description ?? '', + number_format($debit, 2, '.', ''), + number_format($credit, 2, '.', ''), + number_format($runningBalance, 2, '.', ''), + ]; + } + + return $this->streamCsv( + "ledger-{$account->code}-{$from}-{$to}.csv", + ['Date', 'Description', 'Debit', 'Credit', 'Balance'], + $rows + ); + } + + public function exportVatReport(Request $request): \Symfony\Component\HttpFoundation\StreamedResponse + { + $this->authorize('viewAny', Invoice::class); + + $tenantId = $request->user()->tenant_id; + $from = $request->query('from', now()->startOfQuarter()->toDateString()); + $to = $request->query('to', now()->endOfQuarter()->toDateString()); + + $invoices = Invoice::where('tenant_id', $tenantId) + ->whereNotIn('status', ['cancelled']) + ->whereBetween('issue_date', [$from, $to]) + ->with('items') + ->get(); + + $outputLines = $invoices->map(function ($invoice) { + return [ + 'number' => $invoice->number, + 'date' => $invoice->issue_date, + 'contact' => $invoice->contact?->name, + 'net' => round((float) $invoice->subtotal, 2), + 'tax' => round((float) $invoice->tax_total, 2), + 'type' => 'Output', + ]; + })->filter(fn ($l) => $l['tax'] != 0)->values(); + + $bills = Bill::where('tenant_id', $tenantId) + ->whereNotIn('status', ['cancelled']) + ->whereBetween('issue_date', [$from, $to]) + ->with('items') + ->get(); + + $inputLines = $bills->map(function ($bill) { + return [ + 'number' => $bill->number, + 'date' => $bill->issue_date, + 'contact' => $bill->contact?->name, + 'net' => round((float) $bill->subtotal, 2), + 'tax' => round((float) $bill->tax_total, 2), + 'type' => 'Input', + ]; + })->filter(fn ($l) => $l['tax'] != 0)->values(); + + $totalOutputVat = round($outputLines->sum('tax'), 2); + $totalInputVat = round($inputLines->sum('tax'), 2); + $netVat = round($totalOutputVat - $totalInputVat, 2); + + $rows = []; + foreach ($outputLines as $line) { + $date = $line['date'] instanceof \Carbon\Carbon ? $line['date']->toDateString() : (string) $line['date']; + $rows[] = [$line['type'], $line['number'] ?? '', $date, $line['contact'] ?? '', number_format($line['net'], 2, '.', ''), number_format($line['tax'], 2, '.', '')]; + } + $rows[] = ['', '', '', '', '', '']; + foreach ($inputLines as $line) { + $date = $line['date'] instanceof \Carbon\Carbon ? $line['date']->toDateString() : (string) $line['date']; + $rows[] = [$line['type'], $line['number'] ?? '', $date, $line['contact'] ?? '', number_format($line['net'], 2, '.', ''), number_format($line['tax'], 2, '.', '')]; + } + $rows[] = ['', '', '', '', '', '']; + $rows[] = ['Total Output VAT', '', '', '', '', number_format($totalOutputVat, 2, '.', '')]; + $rows[] = ['Total Input VAT', '', '', '', '', number_format($totalInputVat, 2, '.', '')]; + $rows[] = ['Net VAT', '', '', '', '', number_format($netVat, 2, '.', '')]; + + return $this->streamCsv( + "vat-report-{$from}-{$to}.csv", + ['Type', 'Document #', 'Date', 'Contact', 'Net', 'Tax'], + $rows + ); + } + + // ─── Private Helpers ───────────────────────────────────────────────────── + + private function streamCsv(string $filename, array $headers, iterable $rows): \Symfony\Component\HttpFoundation\StreamedResponse + { + return response()->streamDownload(function () use ($headers, $rows) { + $handle = fopen('php://output', 'w'); + fputcsv($handle, $headers); + foreach ($rows as $row) { + fputcsv($handle, $row); + } + fclose($handle); + }, $filename, [ + 'Content-Type' => 'text/csv; charset=UTF-8', + ]); + } + + private function aggregateJournalLines(?string $from = null, ?string $to = null): \Illuminate\Support\Collection + { + return JournalLine::select('account_id', + DB::raw('SUM(debit) as total_debit'), + DB::raw('SUM(credit) as total_credit') + ) + ->whereHas('journalEntry', function ($q) use ($from, $to) { + $q->where('status', 'posted'); + if ($from) $q->whereDate('date', '>=', $from); + if ($to) $q->whereDate('date', '<=', $to); + }) + ->groupBy('account_id') + ->get() + ->keyBy('account_id'); + } +} diff --git a/erp/app/Modules/Finance/Http/Controllers/ReturnRequestController.php b/erp/app/Modules/Finance/Http/Controllers/ReturnRequestController.php new file mode 100644 index 00000000000..0366bf83461 --- /dev/null +++ b/erp/app/Modules/Finance/Http/Controllers/ReturnRequestController.php @@ -0,0 +1,137 @@ +authorize('viewAny', ReturnRequest::class); + + $returnRequests = ReturnRequest::with(['contact', 'invoice']) + ->when($request->status, fn ($q) => $q->where('status', $request->status)) + ->latest() + ->paginate(15) + ->withQueryString(); + + return Inertia::render('Finance/ReturnRequests/Index', [ + 'returnRequests' => $returnRequests, + 'filters' => $request->only(['status']), + ]); + } + + public function create(): Response + { + $this->authorize('create', ReturnRequest::class); + + return Inertia::render('Finance/ReturnRequests/Create', [ + 'contacts' => Contact::orderBy('name')->get(['id', 'name']), + 'invoices' => Invoice::latest()->limit(50)->get(['id', 'number']), + ]); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', ReturnRequest::class); + + $data = $request->validate([ + 'invoice_id' => ['nullable', 'exists:invoices,id'], + 'contact_id' => ['nullable', 'exists:contacts,id'], + 'reason' => ['required', 'string'], + 'refund_amount' => ['required', 'numeric', 'min:0'], + 'items' => ['required', 'array', 'min:1'], + 'items.*.product_name' => ['required', 'string', 'max:255'], + 'items.*.quantity' => ['required', 'integer', 'min:1'], + 'items.*.unit_price' => ['required', 'numeric', 'min:0'], + 'items.*.reason' => ['nullable', 'string'], + ]); + + $returnRequest = DB::transaction(function () use ($data, $request) { + $rr = ReturnRequest::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'invoice_id' => $data['invoice_id'] ?? null, + 'contact_id' => $data['contact_id'] ?? null, + 'reason' => $data['reason'], + 'refund_amount' => $data['refund_amount'], + 'status' => 'pending', + 'notes' => $request->notes, + ]); + + foreach ($data['items'] as $item) { + ReturnRequestItem::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'return_request_id' => $rr->id, + 'invoice_item_id' => $item['invoice_item_id'] ?? null, + 'product_name' => $item['product_name'], + 'quantity' => $item['quantity'], + 'unit_price' => $item['unit_price'], + 'reason' => $item['reason'] ?? null, + ]); + } + + return $rr; + }); + + return redirect()->route('finance.return-requests.show', $returnRequest) + ->with('success', 'Return request created.'); + } + + public function show(ReturnRequest $returnRequest): Response + { + $this->authorize('view', $returnRequest); + + $returnRequest->load(['items', 'contact', 'invoice', 'approvedBy']); + + return Inertia::render('Finance/ReturnRequests/Show', [ + 'returnRequest' => $returnRequest, + ]); + } + + public function destroy(ReturnRequest $returnRequest): RedirectResponse + { + $this->authorize('delete', $returnRequest); + + $returnRequest->delete(); + + return redirect()->route('finance.return-requests.index') + ->with('success', 'Return request deleted.'); + } + + public function approve(ReturnRequest $returnRequest): RedirectResponse + { + $this->authorize('create', $returnRequest); + + $returnRequest->approve(auth()->user()); + + return back()->with('success', 'Return request approved.'); + } + + public function reject(ReturnRequest $returnRequest): RedirectResponse + { + $this->authorize('create', $returnRequest); + + $returnRequest->reject(); + + return back()->with('success', 'Return request rejected.'); + } + + public function markRefunded(ReturnRequest $returnRequest): RedirectResponse + { + $this->authorize('create', $returnRequest); + + $returnRequest->markRefunded(); + + return back()->with('success', 'Return request marked as refunded.'); + } +} diff --git a/erp/app/Modules/Finance/Http/Controllers/SalesOrderController.php b/erp/app/Modules/Finance/Http/Controllers/SalesOrderController.php new file mode 100644 index 00000000000..ac27d426ebe --- /dev/null +++ b/erp/app/Modules/Finance/Http/Controllers/SalesOrderController.php @@ -0,0 +1,219 @@ +authorize('viewAny', SalesOrder::class); + + $salesOrders = SalesOrder::with(['contact', 'warehouse']) + ->when($request->status, fn ($q) => $q->where('status', $request->status)) + ->when($request->contact_id, fn ($q) => $q->where('contact_id', $request->contact_id)) + ->when($request->search, fn ($q) => $q->where('number', 'like', "%{$request->search}%")) + ->latest('order_date') + ->paginate(25) + ->withQueryString(); + + return Inertia::render('Finance/SalesOrders/Index', [ + 'salesOrders' => SalesOrderResource::collection($salesOrders), + 'contacts' => Contact::customers()->active()->orderBy('name')->get(['id', 'name']), + 'filters' => $request->only(['status', 'contact_id', 'search']), + 'breadcrumbs' => [ + ['label' => 'Finance'], + ['label' => 'Sales Orders', 'href' => route('finance.sales-orders.index')], + ], + ]); + } + + public function create(): Response + { + $this->authorize('create', SalesOrder::class); + + return Inertia::render('Finance/SalesOrders/Create', [ + 'contacts' => Contact::customers()->active()->orderBy('name')->get(['id', 'name']), + 'warehouses' => Warehouse::where('is_active', true)->orderBy('name')->get(['id', 'name']), + 'products' => Product::active()->orderBy('name')->get(['id', 'name', 'sku', 'sale_price']), + 'breadcrumbs' => [ + ['label' => 'Finance'], + ['label' => 'Sales Orders', 'href' => route('finance.sales-orders.index')], + ['label' => 'New Sales Order'], + ], + ]); + } + + public function store(StoreSalesOrderRequest $request): RedirectResponse + { + $this->authorize('create', SalesOrder::class); + + $data = $request->validated(); + + $salesOrder = DB::transaction(function () use ($data) { + $salesOrder = SalesOrder::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'contact_id' => $data['contact_id'] ?? null, + 'warehouse_id' => $data['warehouse_id'] ?? null, + 'reference' => $data['reference'] ?? null, + 'order_date' => $data['order_date'], + 'expected_date' => $data['expected_date'] ?? null, + 'currency_code' => $data['currency_code'] ?? 'USD', + 'exchange_rate' => $data['exchange_rate'] ?? 1, + 'notes' => $data['notes'] ?? null, + 'created_by' => auth()->id(), + ]); + + $salesOrder->update([ + 'number' => 'SO-' . now()->format('Y') . '-' . str_pad((string) $salesOrder->id, 5, '0', STR_PAD_LEFT), + ]); + + foreach ($data['items'] as $item) { + SalesOrderItem::create([ + 'sales_order_id' => $salesOrder->id, + 'product_id' => $item['product_id'] ?? null, + 'description' => $item['description'], + 'quantity' => $item['quantity'], + 'unit_price' => $item['unit_price'], + 'tax_rate' => $item['tax_rate'], + ]); + } + + return $salesOrder; + }); + + return redirect()->route('finance.sales-orders.show', $salesOrder) + ->with('success', 'Sales order created.'); + } + + public function show(SalesOrder $salesOrder): Response + { + $this->authorize('view', $salesOrder); + + $salesOrder->load(['contact', 'warehouse', 'invoice', 'items.product', 'creator']); + + return Inertia::render('Finance/SalesOrders/Show', [ + 'salesOrder' => new SalesOrderResource($salesOrder), + 'breadcrumbs' => [ + ['label' => 'Finance'], + ['label' => 'Sales Orders', 'href' => route('finance.sales-orders.index')], + ['label' => $salesOrder->number ?? "Sales Order #{$salesOrder->id}"], + ], + ]); + } + + public function confirm(SalesOrder $salesOrder): RedirectResponse + { + $this->authorize('update', $salesOrder); + + try { + $salesOrder->transitionTo('confirmed'); + } catch (\DomainException $e) { + return back()->withErrors(['status' => $e->getMessage()]); + } + + return back()->with('success', 'Sales order confirmed.'); + } + + public function fulfill(SalesOrder $salesOrder): RedirectResponse + { + $this->authorize('update', $salesOrder); + + try { + $salesOrder->fulfill(); + } catch (\DomainException $e) { + return back()->withErrors(['status' => $e->getMessage()]); + } + + return back()->with('success', 'Sales order fulfilled and stock updated.'); + } + + public function cancel(SalesOrder $salesOrder): RedirectResponse + { + $this->authorize('update', $salesOrder); + + try { + $salesOrder->transitionTo('cancelled'); + } catch (\DomainException $e) { + return back()->withErrors(['status' => $e->getMessage()]); + } + + return back()->with('success', 'Sales order cancelled.'); + } + + public function convertToInvoice(SalesOrder $salesOrder): RedirectResponse + { + $this->authorize('update', $salesOrder); + + if (! in_array($salesOrder->status, ['confirmed', 'fulfilled'], true)) { + return back()->withErrors(['status' => 'Only confirmed or fulfilled orders can be invoiced.']); + } + + if ($salesOrder->invoice_id) { + return back()->withErrors(['status' => 'This order has already been invoiced.']); + } + + $invoice = DB::transaction(function () use ($salesOrder) { + $salesOrder->load('items'); + + $invoice = Invoice::create([ + 'tenant_id' => $salesOrder->tenant_id, + 'sales_order_id' => $salesOrder->id, + 'contact_id' => $salesOrder->contact_id, + 'issue_date' => now()->toDateString(), + 'due_date' => now()->addDays(30)->toDateString(), + 'status' => 'draft', + 'notes' => $salesOrder->notes, + 'created_by' => auth()->id(), + 'currency_code' => $salesOrder->currency_code ?? 'USD', + 'exchange_rate' => $salesOrder->exchange_rate ?? 1, + ]); + + $invoice->update([ + 'number' => 'INV-' . now()->format('Y') . '-' . str_pad((string) $invoice->id, 5, '0', STR_PAD_LEFT), + ]); + + foreach ($salesOrder->items as $item) { + InvoiceItem::create([ + 'invoice_id' => $invoice->id, + 'description' => $item->description, + 'quantity' => $item->quantity, + 'unit_price' => $item->unit_price, + 'tax_rate' => $item->tax_rate, + ]); + } + + $salesOrder->update(['invoice_id' => $invoice->id]); + + return $invoice; + }); + + return redirect()->route('finance.invoices.show', $invoice) + ->with('success', 'Invoice created from sales order.'); + } + + public function destroy(SalesOrder $salesOrder): RedirectResponse + { + $this->authorize('delete', $salesOrder); + + $salesOrder->delete(); + + return redirect()->route('finance.sales-orders.index') + ->with('success', 'Sales order deleted.'); + } +} diff --git a/erp/app/Modules/Finance/Http/Controllers/ServiceAgreementController.php b/erp/app/Modules/Finance/Http/Controllers/ServiceAgreementController.php new file mode 100644 index 00000000000..d19456a025e --- /dev/null +++ b/erp/app/Modules/Finance/Http/Controllers/ServiceAgreementController.php @@ -0,0 +1,170 @@ +authorize('viewAny', ServiceAgreement::class); + + $query = ServiceAgreement::with('contact')->orderByDesc('created_at'); + + if ($request->filled('status')) { + $query->where('status', $request->status); + } + + $agreements = $query->paginate(15)->withQueryString(); + + return Inertia::render('Finance/ServiceAgreements/Index', [ + 'agreements' => $agreements, + 'filters' => $request->only('status'), + ]); + } + + public function create(): Response + { + $this->authorize('create', ServiceAgreement::class); + + $contacts = Contact::orderBy('name')->get(['id', 'name', 'email']); + + return Inertia::render('Finance/ServiceAgreements/Create', [ + 'contacts' => $contacts, + ]); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', ServiceAgreement::class); + + $validated = $request->validate([ + 'title' => ['required', 'string', 'max:255'], + 'contact_id' => ['nullable', 'exists:contacts,id'], + 'agreement_type' => ['required', 'in:maintenance,support,sla,retainer'], + 'billing_cycle' => ['required', 'in:monthly,quarterly,annually,one_time'], + 'start_date' => ['nullable', 'date'], + 'end_date' => ['nullable', 'date'], + 'value' => ['nullable', 'numeric', 'min:0'], + 'auto_renew' => ['boolean'], + 'description' => ['nullable', 'string'], + 'terms' => ['nullable', 'string'], + ]); + + $agreement = ServiceAgreement::create(array_merge($validated, [ + 'tenant_id' => $request->user()->tenant_id, + 'status' => 'draft', + ])); + + return redirect()->route('finance.service-agreements.show', $agreement); + } + + public function show(ServiceAgreement $serviceAgreement): Response + { + $this->authorize('view', $serviceAgreement); + + $serviceAgreement->load(['serviceItems', 'maintenanceLogs.technician', 'contact']); + + $data = $serviceAgreement->toArray(); + $data['is_expired'] = $serviceAgreement->is_expired; + $data['is_expiring'] = $serviceAgreement->is_expiring; + $data['days_remaining'] = $serviceAgreement->days_remaining; + + return Inertia::render('Finance/ServiceAgreements/Show', [ + 'agreement' => $data, + ]); + } + + public function destroy(ServiceAgreement $serviceAgreement): RedirectResponse + { + $this->authorize('delete', $serviceAgreement); + + $serviceAgreement->delete(); + + return redirect()->route('finance.service-agreements.index'); + } + + public function activate(Request $request, ServiceAgreement $serviceAgreement): RedirectResponse + { + $this->authorize('update', $serviceAgreement); + + $serviceAgreement->activate(); + + return redirect()->back()->with('success', 'Agreement activated.'); + } + + public function terminate(Request $request, ServiceAgreement $serviceAgreement): RedirectResponse + { + $this->authorize('update', $serviceAgreement); + + $serviceAgreement->terminate(); + + return redirect()->back()->with('success', 'Agreement terminated.'); + } + + public function addItem(Request $request, ServiceAgreement $serviceAgreement): RedirectResponse + { + $this->authorize('create', ServiceAgreement::class); + + $validated = $request->validate([ + 'description' => ['required', 'string'], + 'quantity' => ['required', 'integer', 'min:1'], + 'unit_price' => ['required', 'numeric', 'min:0'], + ]); + + $item = ServiceAgreementItem::create(array_merge($validated, [ + 'service_agreement_id' => $serviceAgreement->id, + 'tenant_id' => $serviceAgreement->tenant_id, + 'total_price' => 0, + ])); + + $item->calculateTotal(); + + return redirect()->back()->with('success', 'Item added.'); + } + + public function addLog(Request $request, ServiceAgreement $serviceAgreement): RedirectResponse + { + $this->authorize('create', ServiceAgreement::class); + + $validated = $request->validate([ + 'log_date' => ['required', 'date'], + 'description' => ['required', 'string'], + 'status' => ['nullable', 'in:scheduled,completed,cancelled'], + 'hours_spent' => ['nullable', 'numeric'], + 'next_service_date' => ['nullable', 'date'], + 'technician_id' => ['nullable', 'exists:users,id'], + ]); + + $validated['status'] = $validated['status'] ?? 'scheduled'; + + MaintenanceLog::create(array_merge($validated, [ + 'service_agreement_id' => $serviceAgreement->id, + 'tenant_id' => $serviceAgreement->tenant_id, + ])); + + return redirect()->back()->with('success', 'Log added.'); + } + + public function completeLog(Request $request, ServiceAgreement $serviceAgreement, MaintenanceLog $log): RedirectResponse + { + $this->authorize('create', ServiceAgreement::class); + + $validated = $request->validate([ + 'resolution' => ['required', 'string'], + ]); + + $log->complete($validated['resolution']); + + return redirect()->back()->with('success', 'Log completed.'); + } +} diff --git a/erp/app/Modules/Finance/Http/Controllers/SubscriptionController.php b/erp/app/Modules/Finance/Http/Controllers/SubscriptionController.php new file mode 100644 index 00000000000..2ea340a4744 --- /dev/null +++ b/erp/app/Modules/Finance/Http/Controllers/SubscriptionController.php @@ -0,0 +1,157 @@ +authorize('viewAny', Subscription::class); + + $subscriptions = Subscription::with(['plan']) + ->when($request->status, fn ($q) => $q->where('status', $request->status)) + ->latest() + ->paginate(15) + ->withQueryString(); + + return Inertia::render('Finance/Subscriptions/Index', [ + 'subscriptions' => $subscriptions, + 'filters' => $request->only(['status']), + 'breadcrumbs' => [ + ['label' => 'Finance'], + ['label' => 'Subscriptions', 'href' => route('finance.subscriptions.index')], + ], + ]); + } + + public function create(): Response + { + $this->authorize('create', Subscription::class); + + return Inertia::render('Finance/Subscriptions/Create', [ + 'contacts' => Contact::customers()->active()->orderBy('name')->get(['id', 'name']), + 'plans' => SubscriptionPlan::where('is_active', true)->orderBy('name')->get(['id', 'name', 'billing_cycle', 'price']), + 'breadcrumbs' => [ + ['label' => 'Finance'], + ['label' => 'Subscriptions', 'href' => route('finance.subscriptions.index')], + ['label' => 'New Subscription'], + ], + ]); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', Subscription::class); + + $data = $request->validate([ + 'plan_id' => ['required', Rule::exists('subscription_plans', 'id')], + 'customer_name' => ['nullable', 'string', 'max:255'], + 'customer_email' => ['nullable', 'email', 'max:255'], + 'current_period_start' => ['nullable', 'date'], + 'current_period_end' => ['nullable', 'date'], + 'notes' => ['nullable', 'string'], + ]); + + $plan = SubscriptionPlan::find($data['plan_id']); + + $trialEndsAt = null; + $status = 'active'; + $today = Carbon::today()->toDateString(); + + if ($plan && $plan->trial_days > 0) { + $status = 'trial'; + $trialEndsAt = Carbon::today()->addDays($plan->trial_days)->toDateString(); + } + + $subscription = Subscription::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'plan_id' => $data['plan_id'], + 'customer_name' => $data['customer_name'] ?? null, + 'customer_email' => $data['customer_email'] ?? null, + 'status' => $status, + 'current_period_start' => $data['current_period_start'] ?? $today, + 'current_period_end' => $data['current_period_end'] ?? ($plan ? $plan->getNextBillingDate($today) : null), + 'trial_ends_at' => $trialEndsAt, + 'notes' => $data['notes'] ?? null, + ]); + + return redirect()->route('finance.subscriptions.show', $subscription) + ->with('success', 'Subscription created.'); + } + + public function show(Subscription $subscription): Response + { + $this->authorize('view', $subscription); + + $subscription->load(['plan']); + + return Inertia::render('Finance/Subscriptions/Show', [ + 'subscription' => $subscription, + 'breadcrumbs' => [ + ['label' => 'Finance'], + ['label' => 'Subscriptions', 'href' => route('finance.subscriptions.index')], + ['label' => "Subscription #{$subscription->id}"], + ], + ]); + } + + public function destroy(Subscription $subscription): RedirectResponse + { + $this->authorize('delete', $subscription); + + $subscription->delete(); + + return redirect()->route('finance.subscriptions.index') + ->with('success', 'Subscription deleted.'); + } + + public function activate(Subscription $subscription): RedirectResponse + { + $this->authorize('create', Subscription::class); + + $subscription->load('plan'); + $subscription->activate(); + + return back()->with('success', 'Subscription activated.'); + } + + public function cancel(Subscription $subscription): RedirectResponse + { + $this->authorize('create', Subscription::class); + + $subscription->cancel(); + + return back()->with('success', 'Subscription cancelled.'); + } + + public function pause(Subscription $subscription): RedirectResponse + { + $this->authorize('create', Subscription::class); + + $subscription->pause(); + + return back()->with('success', 'Subscription paused.'); + } + + public function generateInvoice(Subscription $subscription): RedirectResponse + { + $this->authorize('create', Subscription::class); + + $subscription->load('plan'); + $invoice = $subscription->generateInvoice(); + + return redirect()->route('finance.invoices.show', $invoice) + ->with('success', 'Invoice generated.'); + } +} diff --git a/erp/app/Modules/Finance/Http/Controllers/SubscriptionPlanController.php b/erp/app/Modules/Finance/Http/Controllers/SubscriptionPlanController.php new file mode 100644 index 00000000000..7659cb0b682 --- /dev/null +++ b/erp/app/Modules/Finance/Http/Controllers/SubscriptionPlanController.php @@ -0,0 +1,103 @@ +authorize('viewAny', SubscriptionPlan::class); + + $plans = SubscriptionPlan::withCount('subscriptions') + ->latest() + ->paginate(15) + ->withQueryString(); + + return Inertia::render('Finance/SubscriptionPlans/Index', [ + 'plans' => $plans, + 'breadcrumbs' => [ + ['label' => 'Finance'], + ['label' => 'Subscription Plans', 'href' => route('finance.subscription-plans.index')], + ], + ]); + } + + public function create(): Response + { + $this->authorize('create', SubscriptionPlan::class); + + return Inertia::render('Finance/SubscriptionPlans/Create', [ + 'breadcrumbs' => [ + ['label' => 'Finance'], + ['label' => 'Subscription Plans', 'href' => route('finance.subscription-plans.index')], + ['label' => 'New Plan'], + ], + ]); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', SubscriptionPlan::class); + + $data = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'billing_cycle' => ['required', Rule::in(['monthly', 'quarterly', 'annual', 'annually'])], + 'price' => ['required', 'numeric', 'min:0'], + 'trial_days' => ['nullable', 'integer', 'min:0'], + 'description' => ['nullable', 'string'], + 'is_active' => ['nullable', 'boolean'], + ]); + + // Normalize 'annually' to 'annual' for DB compatibility + if (($data['billing_cycle'] ?? '') === 'annually') { + $data['billing_cycle'] = 'annual'; + } + + $plan = SubscriptionPlan::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'name' => $data['name'], + 'billing_cycle' => $data['billing_cycle'], + 'price' => $data['price'], + 'trial_days' => $data['trial_days'] ?? 0, + 'description' => $data['description'] ?? null, + 'is_active' => $data['is_active'] ?? true, + ]); + + return redirect()->route('finance.subscription-plans.show', $plan) + ->with('success', 'Subscription plan created.'); + } + + public function show(SubscriptionPlan $subscriptionPlan): Response + { + $this->authorize('view', $subscriptionPlan); + + $subscriptionPlan->loadCount('subscriptions'); + + return Inertia::render('Finance/SubscriptionPlans/Show', [ + 'plan' => $subscriptionPlan, + 'breadcrumbs' => [ + ['label' => 'Finance'], + ['label' => 'Subscription Plans', 'href' => route('finance.subscription-plans.index')], + ['label' => $subscriptionPlan->name], + ], + ]); + } + + public function destroy(SubscriptionPlan $subscriptionPlan): RedirectResponse + { + $this->authorize('delete', $subscriptionPlan); + + $subscriptionPlan->delete(); + + return redirect()->route('finance.subscription-plans.index') + ->with('success', 'Subscription plan deleted.'); + } +} diff --git a/erp/app/Modules/Finance/Http/Controllers/SupportTicketController.php b/erp/app/Modules/Finance/Http/Controllers/SupportTicketController.php new file mode 100644 index 00000000000..ecc55f34ab5 --- /dev/null +++ b/erp/app/Modules/Finance/Http/Controllers/SupportTicketController.php @@ -0,0 +1,153 @@ +authorize('viewAny', SupportTicket::class); + + $query = SupportTicket::with(['assignedTo', 'createdBy']); + + if ($request->filled('status')) { + $query->where('status', $request->status); + } + + if ($request->filled('priority')) { + $query->where('priority', $request->priority); + } + + $tickets = $query->latest()->paginate(20)->withQueryString(); + + return Inertia::render('Finance/SupportTickets/Index', [ + 'tickets' => $tickets, + 'filters' => $request->only('status', 'priority'), + ]); + } + + public function create(): Response + { + $this->authorize('create', SupportTicket::class); + + $contacts = Contact::orderBy('name')->get(['id', 'name']); + + return Inertia::render('Finance/SupportTickets/Create', [ + 'contacts' => $contacts, + ]); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', SupportTicket::class); + + $data = $request->validate([ + 'subject' => ['required', 'string'], + 'description'=> ['required', 'string'], + 'priority' => ['nullable', 'in:low,normal,high,urgent'], + 'category' => ['nullable', 'string'], + 'contact_id' => ['nullable', 'exists:contacts,id'], + ]); + + $tenantId = app('tenant')->id; + + $data['tenant_id'] = $tenantId; + $data['reference'] = SupportTicket::generateReference($tenantId); + $data['created_by'] = auth()->id(); + $data['priority'] = $data['priority'] ?? 'normal'; + + $ticket = SupportTicket::create($data); + + return redirect()->route('finance.support-tickets.show', $ticket); + } + + public function show(SupportTicket $supportTicket): Response + { + $this->authorize('view', $supportTicket); + + $supportTicket->load(['comments.createdBy', 'assignedTo', 'createdBy', 'contact']); + + return Inertia::render('Finance/SupportTickets/Show', [ + 'ticket' => $supportTicket, + ]); + } + + public function destroy(SupportTicket $supportTicket): RedirectResponse + { + $this->authorize('delete', $supportTicket); + + $supportTicket->delete(); + + return redirect()->route('finance.support-tickets.index'); + } + + public function resolve(Request $request, SupportTicket $supportTicket): RedirectResponse + { + $this->authorize('update', $supportTicket); + + $supportTicket->resolve(); + + return back()->with('success', 'Ticket resolved.'); + } + + public function close(Request $request, SupportTicket $supportTicket): RedirectResponse + { + $this->authorize('update', $supportTicket); + + $supportTicket->close(); + + return back()->with('success', 'Ticket closed.'); + } + + public function reopen(Request $request, SupportTicket $supportTicket): RedirectResponse + { + $this->authorize('update', $supportTicket); + + $supportTicket->reopen(); + + return back()->with('success', 'Ticket reopened.'); + } + + public function assign(Request $request, SupportTicket $supportTicket): RedirectResponse + { + $this->authorize('update', $supportTicket); + + $data = $request->validate([ + 'assigned_to' => ['required', 'integer', 'exists:users,id'], + ]); + + $supportTicket->assign($data['assigned_to']); + + return back()->with('success', 'Ticket assigned.'); + } + + public function addComment(Request $request, SupportTicket $supportTicket): RedirectResponse + { + $this->authorize('view', $supportTicket); + + $data = $request->validate([ + 'body' => ['required', 'string'], + 'is_internal' => ['nullable', 'boolean'], + ]); + + TicketComment::create([ + 'tenant_id' => app('tenant')->id, + 'support_ticket_id' => $supportTicket->id, + 'created_by' => auth()->id(), + 'body' => $data['body'], + 'is_internal' => $data['is_internal'] ?? false, + ]); + + return back()->with('success', 'Comment added.'); + } +} diff --git a/erp/app/Modules/Finance/Http/Controllers/TaxGroupController.php b/erp/app/Modules/Finance/Http/Controllers/TaxGroupController.php new file mode 100644 index 00000000000..c3a755e83db --- /dev/null +++ b/erp/app/Modules/Finance/Http/Controllers/TaxGroupController.php @@ -0,0 +1,105 @@ +authorize('viewAny', TaxGroup::class); + + $taxGroups = TaxGroup::withCount('items') + ->latest() + ->paginate(15) + ->withQueryString(); + + return Inertia::render('Finance/TaxGroups/Index', [ + 'taxGroups' => $taxGroups, + ]); + } + + public function create(): Response + { + $taxRates = TaxRate::active()->orderBy('name')->get(); + + return Inertia::render('Finance/TaxGroups/Create', [ + 'taxRates' => $taxRates, + ]); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', TaxGroup::class); + + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'description' => ['nullable', 'string'], + ]); + + $taxGroup = TaxGroup::create([ + ...$validated, + 'tenant_id' => auth()->user()->tenant_id, + ]); + + return redirect()->route('finance.tax-groups.show', $taxGroup) + ->with('success', 'Tax group created successfully.'); + } + + public function show(TaxGroup $taxGroup): Response + { + $taxGroup->load('items.taxRate'); + + return Inertia::render('Finance/TaxGroups/Show', [ + 'taxGroup' => $taxGroup->append('total_rate'), + ]); + } + + public function destroy(TaxGroup $taxGroup): RedirectResponse + { + $this->authorize('delete', $taxGroup); + + $taxGroup->delete(); + + return redirect()->route('finance.tax-groups.index') + ->with('success', 'Tax group deleted successfully.'); + } + + public function addRate(Request $request, TaxGroup $taxGroup): RedirectResponse + { + $this->authorize('create', $taxGroup); + + $validated = $request->validate([ + 'tax_rate_id' => ['required', 'exists:tax_rates,id'], + ]); + + TaxGroupItem::updateOrCreate( + [ + 'tax_group_id' => $taxGroup->id, + 'tax_rate_id' => $validated['tax_rate_id'], + ], + [ + 'tenant_id' => auth()->user()->tenant_id, + ] + ); + + return redirect()->back()->with('success', 'Tax rate added to group.'); + } + + public function removeRate(TaxGroup $taxGroup, TaxGroupItem $item): RedirectResponse + { + $this->authorize('delete', $taxGroup); + + $item->delete(); + + return redirect()->back()->with('success', 'Tax rate removed from group.'); + } +} diff --git a/erp/app/Modules/Finance/Http/Controllers/TaxRateController.php b/erp/app/Modules/Finance/Http/Controllers/TaxRateController.php new file mode 100644 index 00000000000..9c5e01e3766 --- /dev/null +++ b/erp/app/Modules/Finance/Http/Controllers/TaxRateController.php @@ -0,0 +1,71 @@ +authorize('viewAny', TaxRate::class); + + $taxRates = TaxRate::when($request->tax_type, fn ($q) => $q->where('tax_type', $request->tax_type)) + ->latest() + ->paginate(20) + ->withQueryString(); + + return Inertia::render('Finance/TaxRates/Index', [ + 'taxRates' => $taxRates, + 'filters' => $request->only(['tax_type']), + ]); + } + + public function create(): Response + { + return Inertia::render('Finance/TaxRates/Create'); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', TaxRate::class); + + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'rate' => ['required', 'numeric', 'min:0', 'max:100'], + 'tax_type' => ['required', 'in:sales,purchase,both'], + 'is_compound' => ['boolean'], + 'is_active' => ['boolean'], + ]); + + TaxRate::create([ + ...$validated, + 'tenant_id' => auth()->user()->tenant_id, + ]); + + return redirect()->route('finance.tax-rates.index') + ->with('success', 'Tax rate created successfully.'); + } + + public function show(TaxRate $taxRate): Response + { + return Inertia::render('Finance/TaxRates/Show', [ + 'taxRate' => $taxRate->load('taxGroupItems'), + ]); + } + + public function destroy(TaxRate $taxRate): RedirectResponse + { + $this->authorize('delete', $taxRate); + + $taxRate->delete(); + + return redirect()->route('finance.tax-rates.index') + ->with('success', 'Tax rate deleted successfully.'); + } +} diff --git a/erp/app/Modules/Finance/Http/Controllers/VendorBillController.php b/erp/app/Modules/Finance/Http/Controllers/VendorBillController.php new file mode 100644 index 00000000000..aa617566d63 --- /dev/null +++ b/erp/app/Modules/Finance/Http/Controllers/VendorBillController.php @@ -0,0 +1,149 @@ +authorize('viewAny', VendorBill::class); + + $query = VendorBill::with(['supplier']); + + if ($request->filled('status')) { + $query->where('status', $request->status); + } + + $bills = $query->latest()->paginate(20)->withQueryString(); + + return Inertia::render('Finance/VendorBills/Index', [ + 'bills' => $bills, + 'filters' => $request->only('status'), + ]); + } + + public function create(): Response + { + $this->authorize('create', VendorBill::class); + + $suppliers = Supplier::orderBy('name')->get(['id', 'name']); + + return Inertia::render('Finance/VendorBills/Create', [ + 'suppliers' => $suppliers, + ]); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', VendorBill::class); + + $data = $request->validate([ + 'supplier_id' => 'nullable|exists:suppliers,id', + 'bill_date' => 'required|date', + 'due_date' => 'nullable|date', + 'currency' => 'nullable|string|max:3', + 'notes' => 'nullable|string', + 'items' => 'required|array|min:1', + 'items.*.description' => 'required|string|max:255', + 'items.*.quantity' => 'required|numeric|min:0.01', + 'items.*.unit_price' => 'required|numeric|min:0', + ]); + + $tenantId = app('tenant')->id; + + $bill = VendorBill::create([ + 'tenant_id' => $tenantId, + 'bill_number' => VendorBill::generateBillNumber(), + 'supplier_id' => $data['supplier_id'] ?? null, + 'bill_date' => $data['bill_date'], + 'due_date' => $data['due_date'] ?? null, + 'currency' => $data['currency'] ?? 'USD', + 'notes' => $data['notes'] ?? null, + 'status' => 'draft', + 'subtotal' => 0, + 'tax' => 0, + 'total' => 0, + 'created_by' => auth()->id(), + ]); + + foreach ($data['items'] as $item) { + VendorBillItem::create([ + 'tenant_id' => $tenantId, + 'vendor_bill_id' => $bill->id, + 'product_id' => $item['product_id'] ?? null, + 'description' => $item['description'], + 'quantity' => $item['quantity'], + 'unit_price' => $item['unit_price'], + ]); + } + + $bill->recalculateTotals(); + + return redirect()->route('finance.vendor-bills.show', $bill); + } + + public function show(VendorBill $vendorBill): Response + { + $this->authorize('view', $vendorBill); + + $vendorBill->load(['supplier', 'items']); + + return Inertia::render('Finance/VendorBills/Show', [ + 'bill' => $vendorBill, + ]); + } + + public function submit(VendorBill $vendorBill): RedirectResponse + { + $this->authorize('update', $vendorBill); + + $vendorBill->submit(); + + return back()->with('success', 'Bill submitted.'); + } + + public function approve(VendorBill $vendorBill): RedirectResponse + { + $this->authorize('update', $vendorBill); + + $vendorBill->approve(auth()->id()); + + return back()->with('success', 'Bill approved.'); + } + + public function pay(VendorBill $vendorBill): RedirectResponse + { + $this->authorize('update', $vendorBill); + + $vendorBill->pay(); + + return back()->with('success', 'Bill marked as paid.'); + } + + public function cancel(VendorBill $vendorBill): RedirectResponse + { + $this->authorize('update', $vendorBill); + + $vendorBill->cancel(); + + return back()->with('success', 'Bill cancelled.'); + } + + public function destroy(VendorBill $vendorBill): RedirectResponse + { + $this->authorize('delete', $vendorBill); + + $vendorBill->delete(); + + return redirect()->route('finance.vendor-bills.index'); + } +} diff --git a/erp/app/Modules/Finance/Http/Controllers/VendorEvaluationController.php b/erp/app/Modules/Finance/Http/Controllers/VendorEvaluationController.php new file mode 100644 index 00000000000..6f1bd8b72bf --- /dev/null +++ b/erp/app/Modules/Finance/Http/Controllers/VendorEvaluationController.php @@ -0,0 +1,73 @@ +id) + ->with('evaluator') + ->latest('evaluation_date') + ->paginate(25); + + return Inertia::render('Finance/Vendors/Evaluations', [ + 'contact' => $contact, + 'evaluations' => $evaluations, + 'breadcrumbs' => [ + ['label' => 'Finance'], + ['label' => 'Contacts', 'href' => route('finance.contacts.index')], + ['label' => $contact->name], + ['label' => 'Evaluations'], + ], + ]); + } + + public function store(Request $request, Contact $contact): RedirectResponse + { + abort_unless(Gate::allows('finance.create'), 403); + + $data = $request->validate([ + 'evaluation_date' => ['required', 'date'], + 'quality_rating' => ['required', 'integer', 'min:1', 'max:5'], + 'delivery_rating' => ['required', 'integer', 'min:1', 'max:5'], + 'price_rating' => ['required', 'integer', 'min:1', 'max:5'], + 'communication_rating' => ['required', 'integer', 'min:1', 'max:5'], + 'comments' => ['nullable', 'string'], + ]); + + $overall = round( + ($data['quality_rating'] + $data['delivery_rating'] + $data['price_rating'] + $data['communication_rating']) / 4, + 2 + ); + + VendorEvaluation::create(array_merge($data, [ + 'tenant_id' => $contact->tenant_id, + 'contact_id' => $contact->id, + 'evaluated_by' => auth()->id(), + 'overall_rating' => $overall, + ])); + + return redirect()->back()->with('success', 'Evaluation added.'); + } + + public function destroy(Contact $contact, VendorEvaluation $evaluation): RedirectResponse + { + abort_unless(Gate::allows('finance.delete'), 403); + + $evaluation->delete(); + + return redirect()->back()->with('success', 'Evaluation deleted.'); + } +} diff --git a/erp/app/Modules/Finance/Http/Controllers/VendorPaymentController.php b/erp/app/Modules/Finance/Http/Controllers/VendorPaymentController.php new file mode 100644 index 00000000000..1cdc38da270 --- /dev/null +++ b/erp/app/Modules/Finance/Http/Controllers/VendorPaymentController.php @@ -0,0 +1,159 @@ +authorize('viewAny', VendorPayment::class); + + $query = VendorPayment::orderByDesc('payment_date'); + + if ($request->filled('status')) { + $query->where('status', $request->status); + } + + $vendorPayments = $query->paginate(20); + + return Inertia::render('Finance/VendorPayments/Index', compact('vendorPayments')); + } + + public function create(): Response + { + $this->authorize('create', VendorPayment::class); + + return Inertia::render('Finance/VendorPayments/Create'); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', VendorPayment::class); + + $data = $request->validate([ + 'vendor_name' => 'required|string|max:255', + 'vendor_code' => 'nullable|string|max:255', + 'amount' => 'required|numeric|min:0', + 'currency' => 'nullable|string|max:3', + 'payment_method' => 'nullable|in:bank_transfer,cheque,cash,online', + 'reference' => 'nullable|string|max:255', + 'payment_date' => 'required|date', + 'due_date' => 'nullable|date', + 'notes' => 'nullable|string', + ]); + + VendorPayment::create([ + 'tenant_id' => app('tenant')->id, + 'vendor_name' => $data['vendor_name'], + 'vendor_code' => $data['vendor_code'] ?? null, + 'amount' => $data['amount'], + 'currency' => $data['currency'] ?? 'USD', + 'payment_method' => $data['payment_method'] ?? 'bank_transfer', + 'reference' => $data['reference'] ?? null, + 'payment_date' => $data['payment_date'], + 'due_date' => $data['due_date'] ?? null, + 'notes' => $data['notes'] ?? null, + 'created_by' => auth()->id(), + ]); + + return redirect()->route('finance.vendor-payments.index') + ->with('success', 'Vendor payment created.'); + } + + public function show(VendorPayment $vendorPayment): Response + { + $this->authorize('view', $vendorPayment); + + $vendorPayment->load(['approvedBy', 'createdBy']); + + return Inertia::render('Finance/VendorPayments/Show', compact('vendorPayment')); + } + + public function edit(VendorPayment $vendorPayment): Response + { + $this->authorize('update', $vendorPayment); + + return Inertia::render('Finance/VendorPayments/Edit', compact('vendorPayment')); + } + + public function update(Request $request, VendorPayment $vendorPayment): RedirectResponse + { + $this->authorize('update', $vendorPayment); + + $data = $request->validate([ + 'vendor_name' => 'required|string|max:255', + 'vendor_code' => 'nullable|string|max:255', + 'amount' => 'required|numeric|min:0', + 'currency' => 'nullable|string|max:3', + 'payment_method' => 'nullable|in:bank_transfer,cheque,cash,online', + 'reference' => 'nullable|string|max:255', + 'payment_date' => 'required|date', + 'due_date' => 'nullable|date', + 'notes' => 'nullable|string', + ]); + + $vendorPayment->update($data); + + return redirect()->route('finance.vendor-payments.index') + ->with('success', 'Vendor payment updated.'); + } + + public function destroy(VendorPayment $vendorPayment): RedirectResponse + { + $this->authorize('delete', $vendorPayment); + + $vendorPayment->delete(); + + return redirect()->route('finance.vendor-payments.index') + ->with('success', 'Vendor payment deleted.'); + } + + // ── Custom actions ──────────────────────────────────────────────────────── + + public function approve(VendorPayment $vendorPayment): RedirectResponse + { + $this->authorize('approve', $vendorPayment); + + $vendorPayment->approve(auth()->id()); + + return redirect()->route('finance.vendor-payments.index') + ->with('success', 'Vendor payment approved.'); + } + + public function process(VendorPayment $vendorPayment): RedirectResponse + { + $this->authorize('process', $vendorPayment); + + $vendorPayment->process(); + + return redirect()->route('finance.vendor-payments.index') + ->with('success', 'Vendor payment processed.'); + } + + public function reject(VendorPayment $vendorPayment): RedirectResponse + { + $this->authorize('reject', $vendorPayment); + + $vendorPayment->reject(); + + return redirect()->route('finance.vendor-payments.index') + ->with('success', 'Vendor payment rejected.'); + } + + public function cancel(VendorPayment $vendorPayment): RedirectResponse + { + $this->authorize('cancel', $vendorPayment); + + $vendorPayment->cancel(); + + return redirect()->route('finance.vendor-payments.index') + ->with('success', 'Vendor payment cancelled.'); + } +} diff --git a/erp/app/Modules/Finance/Http/Controllers/VendorProfileController.php b/erp/app/Modules/Finance/Http/Controllers/VendorProfileController.php new file mode 100644 index 00000000000..56b0d8a8b9b --- /dev/null +++ b/erp/app/Modules/Finance/Http/Controllers/VendorProfileController.php @@ -0,0 +1,65 @@ + $contact->id], + [ + 'tenant_id' => $contact->tenant_id, + 'payment_terms_days' => 30, + ] + ); + + $profile->load('contact'); + + return Inertia::render('Finance/Vendors/Profile', [ + 'contact' => $contact, + 'profile' => array_merge($profile->toArray(), [ + 'is_over_credit_limit' => $profile->is_over_credit_limit, + ]), + 'breadcrumbs' => [ + ['label' => 'Finance'], + ['label' => 'Contacts', 'href' => route('finance.contacts.index')], + ['label' => $contact->name], + ['label' => 'Vendor Profile'], + ], + ]); + } + + public function update(Request $request, Contact $contact): RedirectResponse + { + abort_unless(Gate::allows('finance.create'), 403); + + $data = $request->validate([ + 'credit_limit' => ['nullable', 'numeric', 'min:0'], + 'payment_terms_days' => ['required', 'integer', 'min:0', 'max:365'], + 'preferred_currency' => ['nullable', 'string', 'size:3'], + 'bank_name' => ['nullable', 'string', 'max:255'], + 'bank_account_number' => ['nullable', 'string', 'max:100'], + 'bank_routing_number' => ['nullable', 'string', 'max:100'], + 'notes' => ['nullable', 'string'], + ]); + + VendorProfile::updateOrCreate( + ['contact_id' => $contact->id], + array_merge($data, ['tenant_id' => $contact->tenant_id]) + ); + + return redirect()->back()->with('success', 'Vendor profile updated.'); + } +} diff --git a/erp/app/Modules/Finance/Http/Controllers/WriteOffController.php b/erp/app/Modules/Finance/Http/Controllers/WriteOffController.php new file mode 100644 index 00000000000..d716866bc79 --- /dev/null +++ b/erp/app/Modules/Finance/Http/Controllers/WriteOffController.php @@ -0,0 +1,67 @@ +authorize('viewAny', WriteOff::class); + $writeOffs = WriteOff::where('tenant_id', app('tenant')->id) + ->latest() + ->paginate(20); + return Inertia::render('Finance/WriteOffs/Index', compact('writeOffs')); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', WriteOff::class); + $validated = $request->validate([ + 'customer_id' => 'nullable|exists:contacts,id', + 'invoice_id' => 'nullable', + 'amount' => 'required|numeric|min:0.01', + 'currency' => 'nullable|string|max:3', + 'write_off_date' => 'required|date', + 'reason' => 'required|string|max:255', + 'notes' => 'nullable|string', + ]); + $validated['tenant_id'] = app('tenant')->id; + $validated['created_by'] = auth()->id(); + WriteOff::create($validated); + return back()->with('success', 'Write-off created.'); + } + + public function show(WriteOff $writeOff): Response + { + $this->authorize('view', $writeOff); + return Inertia::render('Finance/WriteOffs/Show', compact('writeOff')); + } + + public function approve(WriteOff $writeOff): RedirectResponse + { + $this->authorize('update', $writeOff); + $writeOff->approve(auth()->id()); + return back()->with('success', 'Write-off approved.'); + } + + public function reverse(WriteOff $writeOff): RedirectResponse + { + $this->authorize('update', $writeOff); + $writeOff->reverse(); + return back()->with('success', 'Write-off reversed.'); + } + + public function destroy(WriteOff $writeOff): RedirectResponse + { + $this->authorize('delete', $writeOff); + $writeOff->delete(); + return back()->with('success', 'Write-off deleted.'); + } +} diff --git a/erp/app/Modules/Finance/Http/Requests/StoreAccountRequest.php b/erp/app/Modules/Finance/Http/Requests/StoreAccountRequest.php new file mode 100644 index 00000000000..19ad9322b7d --- /dev/null +++ b/erp/app/Modules/Finance/Http/Requests/StoreAccountRequest.php @@ -0,0 +1,26 @@ + ['required', 'string', 'max:20', + Rule::unique('accounts')->where('tenant_id', auth()->user()->tenant_id) + ->ignore($this->route('account')), + ], + 'name' => ['required', 'string', 'max:255'], + 'type' => ['required', Rule::in(['asset', 'liability', 'equity', 'income', 'expense'])], + 'parent_id' => ['nullable', 'integer', 'exists:accounts,id'], + 'description' => ['nullable', 'string'], + 'is_active' => ['boolean'], + ]; + } +} diff --git a/erp/app/Modules/Finance/Http/Requests/StoreBillRequest.php b/erp/app/Modules/Finance/Http/Requests/StoreBillRequest.php new file mode 100644 index 00000000000..b34aba36ca8 --- /dev/null +++ b/erp/app/Modules/Finance/Http/Requests/StoreBillRequest.php @@ -0,0 +1,29 @@ + ['nullable', 'integer', Rule::exists('contacts', 'id') + ->where(fn ($q) => $q->whereIn('type', ['vendor', 'both']))], + 'issue_date' => ['required', 'date'], + 'due_date' => ['nullable', 'date', 'after_or_equal:issue_date'], + 'notes' => ['nullable', 'string'], + 'currency_code' => ['nullable', 'string', 'size:3'], + 'exchange_rate' => ['nullable', 'numeric', 'min:0.000001'], + 'items' => ['required', 'array', 'min:1'], + 'items.*.description' => ['required', 'string', 'max:500'], + 'items.*.quantity' => ['required', 'numeric', 'min:0.01'], + 'items.*.unit_price' => ['required', 'numeric', 'min:0'], + 'items.*.tax_rate' => ['required', 'numeric', 'min:0', 'max:100'], + ]; + } +} diff --git a/erp/app/Modules/Finance/Http/Requests/StoreContactRequest.php b/erp/app/Modules/Finance/Http/Requests/StoreContactRequest.php new file mode 100644 index 00000000000..741076e41d2 --- /dev/null +++ b/erp/app/Modules/Finance/Http/Requests/StoreContactRequest.php @@ -0,0 +1,25 @@ + ['required', 'string', 'max:255'], + 'email' => ['nullable', 'email', 'max:255'], + 'phone' => ['nullable', 'string', 'max:50'], + 'address' => ['nullable', 'string'], + 'type' => ['required', Rule::in(['customer', 'vendor', 'both'])], + 'notes' => ['nullable', 'string'], + 'is_active' => ['boolean'], + 'price_list_id' => ['nullable', 'exists:price_lists,id'], + ]; + } +} diff --git a/erp/app/Modules/Finance/Http/Requests/StoreCreditNoteRequest.php b/erp/app/Modules/Finance/Http/Requests/StoreCreditNoteRequest.php new file mode 100644 index 00000000000..2e3890f766a --- /dev/null +++ b/erp/app/Modules/Finance/Http/Requests/StoreCreditNoteRequest.php @@ -0,0 +1,27 @@ + ['nullable', Rule::exists('contacts', 'id')], + 'invoice_id' => ['nullable', Rule::exists('invoices', 'id')], + 'issue_date' => ['required', 'date'], + 'reason' => ['nullable', 'string'], + 'notes' => ['nullable', 'string'], + 'items' => ['required', 'array', 'min:1'], + 'items.*.description' => ['required', 'string'], + 'items.*.quantity' => ['required', 'numeric', 'min:0.01'], + 'items.*.unit_price' => ['required', 'numeric', 'min:0'], + 'items.*.tax_rate' => ['required', 'numeric', 'min:0', 'max:100'], + ]; + } +} diff --git a/erp/app/Modules/Finance/Http/Requests/StoreFixedAssetRequest.php b/erp/app/Modules/Finance/Http/Requests/StoreFixedAssetRequest.php new file mode 100644 index 00000000000..809d2ebad4b --- /dev/null +++ b/erp/app/Modules/Finance/Http/Requests/StoreFixedAssetRequest.php @@ -0,0 +1,25 @@ + ['required', 'string', 'max:191'], + 'category' => ['required', 'in:equipment,vehicle,building,furniture,intangible,other'], + 'purchase_date' => ['required', 'date'], + 'purchase_cost' => ['required', 'numeric', 'min:0.01'], + 'salvage_value' => ['nullable', 'numeric', 'min:0'], + 'useful_life_years' => ['required', 'integer', 'min:1', 'max:100'], + 'asset_account_id' => ['nullable', 'exists:accounts,id'], + 'depreciation_account_id' => ['nullable', 'exists:accounts,id'], + 'description' => ['nullable', 'string'], + ]; + } +} diff --git a/erp/app/Modules/Finance/Http/Requests/StoreInvoiceRequest.php b/erp/app/Modules/Finance/Http/Requests/StoreInvoiceRequest.php new file mode 100644 index 00000000000..3a423aee4a3 --- /dev/null +++ b/erp/app/Modules/Finance/Http/Requests/StoreInvoiceRequest.php @@ -0,0 +1,27 @@ + ['nullable', 'integer', 'exists:contacts,id'], + 'issue_date' => ['required', 'date'], + 'due_date' => ['nullable', 'date', 'after_or_equal:issue_date'], + 'notes' => ['nullable', 'string'], + 'currency_code' => ['nullable', 'string', 'size:3'], + 'exchange_rate' => ['nullable', 'numeric', 'min:0.000001'], + 'items' => ['required', 'array', 'min:1'], + 'items.*.description' => ['required', 'string', 'max:500'], + 'items.*.quantity' => ['required', 'numeric', 'min:0.01'], + 'items.*.unit_price' => ['required', 'numeric', 'min:0'], + 'items.*.tax_rate' => ['required', 'numeric', 'min:0', 'max:100'], + ]; + } +} diff --git a/erp/app/Modules/Finance/Http/Requests/StoreJournalEntryRequest.php b/erp/app/Modules/Finance/Http/Requests/StoreJournalEntryRequest.php new file mode 100644 index 00000000000..edbd35dffe2 --- /dev/null +++ b/erp/app/Modules/Finance/Http/Requests/StoreJournalEntryRequest.php @@ -0,0 +1,24 @@ + ['required', 'date'], + 'reference' => ['nullable', 'string', 'max:100'], + 'description' => ['required', 'string', 'max:500'], + 'lines' => ['required', 'array', 'min:2'], + 'lines.*.account_id' => ['required', 'integer', 'exists:accounts,id'], + 'lines.*.debit' => ['required', 'numeric', 'min:0'], + 'lines.*.credit' => ['required', 'numeric', 'min:0'], + 'lines.*.description' => ['nullable', 'string', 'max:255'], + ]; + } +} diff --git a/erp/app/Modules/Finance/Http/Requests/StorePaymentRequest.php b/erp/app/Modules/Finance/Http/Requests/StorePaymentRequest.php new file mode 100644 index 00000000000..b6cf0ce74f2 --- /dev/null +++ b/erp/app/Modules/Finance/Http/Requests/StorePaymentRequest.php @@ -0,0 +1,22 @@ + ['required', 'numeric', 'min:0.01'], + 'payment_date' => ['required', 'date'], + 'method' => ['required', Rule::in(['cash', 'bank_transfer', 'cheque', 'card', 'other'])], + 'reference' => ['nullable', 'string', 'max:100'], + 'notes' => ['nullable', 'string'], + ]; + } +} diff --git a/erp/app/Modules/Finance/Http/Requests/StoreQuoteRequest.php b/erp/app/Modules/Finance/Http/Requests/StoreQuoteRequest.php new file mode 100644 index 00000000000..ad210a28d11 --- /dev/null +++ b/erp/app/Modules/Finance/Http/Requests/StoreQuoteRequest.php @@ -0,0 +1,28 @@ + ['nullable', Rule::exists('contacts', 'id')], + 'issue_date' => ['required', 'date'], + 'expiry_date' => ['nullable', 'date', 'after_or_equal:issue_date'], + 'notes' => ['nullable', 'string'], + 'currency_code' => ['nullable', 'string', 'size:3'], + 'exchange_rate' => ['nullable', 'numeric', 'min:0.000001'], + 'items' => ['required', 'array', 'min:1'], + 'items.*.description' => ['required', 'string'], + 'items.*.quantity' => ['required', 'numeric', 'min:0.01'], + 'items.*.unit_price' => ['required', 'numeric', 'min:0'], + 'items.*.tax_rate' => ['required', 'numeric', 'min:0', 'max:100'], + ]; + } +} diff --git a/erp/app/Modules/Finance/Http/Requests/StoreRecurringInvoiceRequest.php b/erp/app/Modules/Finance/Http/Requests/StoreRecurringInvoiceRequest.php new file mode 100644 index 00000000000..6fbf5d89b2c --- /dev/null +++ b/erp/app/Modules/Finance/Http/Requests/StoreRecurringInvoiceRequest.php @@ -0,0 +1,33 @@ + ['nullable', Rule::exists('contacts', 'id')], + 'reference_prefix' => ['nullable', 'string', 'max:50'], + 'frequency' => ['required', Rule::in(['weekly', 'monthly', 'quarterly', 'yearly'])], + 'interval' => ['nullable', 'integer', 'min:1', 'max:12'], + 'start_date' => ['required', 'date'], + 'end_date' => ['nullable', 'date', 'after_or_equal:start_date'], + 'due_days' => ['required', 'integer', 'min:0', 'max:365'], + 'auto_send' => ['boolean'], + 'currency_code' => ['nullable', 'string', 'size:3'], + 'exchange_rate' => ['nullable', 'numeric', 'min:0.000001'], + 'notes' => ['nullable', 'string'], + 'items' => ['required', 'array', 'min:1'], + 'items.*.description' => ['required', 'string'], + 'items.*.quantity' => ['required', 'numeric', 'min:0.01'], + 'items.*.unit_price' => ['required', 'numeric', 'min:0'], + 'items.*.tax_rate' => ['required', 'numeric', 'min:0', 'max:100'], + ]; + } +} diff --git a/erp/app/Modules/Finance/Http/Requests/StoreSalesOrderRequest.php b/erp/app/Modules/Finance/Http/Requests/StoreSalesOrderRequest.php new file mode 100644 index 00000000000..064c38e855d --- /dev/null +++ b/erp/app/Modules/Finance/Http/Requests/StoreSalesOrderRequest.php @@ -0,0 +1,31 @@ + ['nullable', Rule::exists('contacts', 'id')], + 'warehouse_id' => ['nullable', Rule::exists('warehouses', 'id')], + 'reference' => ['nullable', 'string', 'max:100', Rule::unique('sales_orders', 'reference')], + 'order_date' => ['required', 'date'], + 'expected_date' => ['nullable', 'date', 'after_or_equal:order_date'], + 'currency_code' => ['nullable', 'string', 'size:3'], + 'exchange_rate' => ['nullable', 'numeric', 'min:0.000001'], + 'notes' => ['nullable', 'string'], + 'items' => ['required', 'array', 'min:1'], + 'items.*.product_id' => ['nullable', Rule::exists('products', 'id')], + 'items.*.description' => ['required', 'string'], + 'items.*.quantity' => ['required', 'numeric', 'min:0.01'], + 'items.*.unit_price' => ['required', 'numeric', 'min:0'], + 'items.*.tax_rate' => ['required', 'numeric', 'min:0', 'max:100'], + ]; + } +} diff --git a/erp/app/Modules/Finance/Http/Resources/AccountResource.php b/erp/app/Modules/Finance/Http/Resources/AccountResource.php new file mode 100644 index 00000000000..8121a441f62 --- /dev/null +++ b/erp/app/Modules/Finance/Http/Resources/AccountResource.php @@ -0,0 +1,26 @@ + $this->id, + 'code' => $this->code, + 'name' => $this->name, + 'type' => $this->type, + 'description' => $this->description, + 'is_active' => $this->is_active, + 'parent_id' => $this->parent_id, + 'parent' => $this->whenLoaded('parent', fn () => [ + 'id' => $this->parent->id, 'name' => $this->parent->name, 'code' => $this->parent->code, + ]), + 'balance' => $this->when(isset($this->resource->balance), $this->balance), + ]; + } +} diff --git a/erp/app/Modules/Finance/Http/Resources/BillResource.php b/erp/app/Modules/Finance/Http/Resources/BillResource.php new file mode 100644 index 00000000000..768cb7b61c7 --- /dev/null +++ b/erp/app/Modules/Finance/Http/Resources/BillResource.php @@ -0,0 +1,61 @@ + $this->id, + 'number' => $this->number, + 'status' => $this->status, + 'issue_date' => $this->issue_date?->toDateString(), + 'due_date' => $this->due_date?->toDateString(), + 'notes' => $this->notes, + 'is_overdue' => $this->isOverdue(), + 'currency_code' => $this->currency_code ?? 'USD', + 'exchange_rate' => $this->exchange_rate ?? 1.0, + 'contact' => $this->whenLoaded('contact', fn () => $this->contact ? [ + 'id' => $this->contact->id, 'name' => $this->contact->name, + ] : null), + 'items' => $this->whenLoaded('items', fn () => $this->items->map(fn ($i) => [ + 'id' => $i->id, + 'description' => $i->description, + 'quantity' => $i->quantity, + 'unit_price' => $i->unit_price, + 'tax_rate' => $i->tax_rate, + 'subtotal' => $i->subtotal, + 'tax' => $i->tax, + 'line_total' => $i->line_total, + ])), + 'payments' => $this->whenLoaded('payments', fn () => $this->payments->map(fn ($p) => [ + 'id' => $p->id, + 'amount' => $p->amount, + 'payment_date' => $p->payment_date?->toDateString(), + 'method' => $p->method, + 'reference' => $p->reference, + ])), + 'subtotal' => $this->whenLoaded('items', fn () => $this->subtotal), + 'tax_total' => $this->whenLoaded('items', fn () => $this->tax_total), + 'total' => $this->whenLoaded('items', fn () => $this->total), + 'base_total' => $this->whenLoaded('items', fn () => $this->base_total), + 'amount_paid' => $this->whenLoaded('payments', fn () => $this->amount_paid), + 'amount_due' => $this->when( + $this->relationLoaded('items') && $this->relationLoaded('payments'), + fn () => $this->amount_due + ), + 'transitions' => $this->availableTransitions(), + 'attachments' => $this->whenLoaded('attachments', fn () => $this->attachments->map(fn ($a) => [ + 'id' => $a->id, 'filename' => $a->filename, 'disk' => $a->disk, + 'path' => $a->path, 'mime_type' => $a->mime_type, 'size' => $a->size, + 'uploaded_by' => $a->uploaded_by, 'created_at' => $a->created_at?->toIso8601String(), + ])), + 'creator' => $this->whenLoaded('creator', fn () => $this->creator?->name), + 'created_at' => $this->created_at?->toDateString(), + ]; + } +} diff --git a/erp/app/Modules/Finance/Http/Resources/ContactResource.php b/erp/app/Modules/Finance/Http/Resources/ContactResource.php new file mode 100644 index 00000000000..4c8bd56187e --- /dev/null +++ b/erp/app/Modules/Finance/Http/Resources/ContactResource.php @@ -0,0 +1,24 @@ + $this->id, + 'name' => $this->name, + 'email' => $this->email, + 'phone' => $this->phone, + 'address' => $this->address, + 'type' => $this->type, + 'notes' => $this->notes, + 'is_active' => $this->is_active, + 'created_at' => $this->created_at?->toDateString(), + ]; + } +} diff --git a/erp/app/Modules/Finance/Http/Resources/CreditNoteResource.php b/erp/app/Modules/Finance/Http/Resources/CreditNoteResource.php new file mode 100644 index 00000000000..b74898f897a --- /dev/null +++ b/erp/app/Modules/Finance/Http/Resources/CreditNoteResource.php @@ -0,0 +1,41 @@ + $this->id, + 'number' => $this->number, + 'status' => $this->status, + 'issue_date' => $this->issue_date?->toDateString(), + 'reason' => $this->reason, + 'notes' => $this->notes, + 'contact' => $this->whenLoaded('contact', fn () => $this->contact ? [ + 'id' => $this->contact->id, 'name' => $this->contact->name, + ] : null), + 'invoice' => $this->whenLoaded('invoice', fn () => $this->invoice ? [ + 'id' => $this->invoice->id, 'number' => $this->invoice->number, + ] : null), + 'items' => $this->whenLoaded('items', fn () => $this->items->map(fn ($item) => [ + 'id' => $item->id, + 'description' => $item->description, + 'quantity' => $item->quantity, + 'unit_price' => $item->unit_price, + 'tax_rate' => $item->tax_rate, + 'line_total' => $item->line_total, + ])), + 'subtotal' => $this->whenLoaded('items', fn () => $this->subtotal), + 'tax_total' => $this->whenLoaded('items', fn () => $this->tax_total), + 'total' => $this->whenLoaded('items', fn () => $this->total), + 'transitions' => $this->availableTransitions(), + 'created_by' => $this->whenLoaded('creator', fn () => $this->creator?->name), + 'created_at' => $this->created_at, + ]; + } +} diff --git a/erp/app/Modules/Finance/Http/Resources/InvoiceResource.php b/erp/app/Modules/Finance/Http/Resources/InvoiceResource.php new file mode 100644 index 00000000000..81da7a1a51b --- /dev/null +++ b/erp/app/Modules/Finance/Http/Resources/InvoiceResource.php @@ -0,0 +1,61 @@ + $this->id, + 'number' => $this->number, + 'status' => $this->status, + 'issue_date' => $this->issue_date?->toDateString(), + 'due_date' => $this->due_date?->toDateString(), + 'notes' => $this->notes, + 'is_overdue' => $this->isOverdue(), + 'currency_code' => $this->currency_code ?? 'USD', + 'exchange_rate' => $this->exchange_rate ?? 1.0, + 'contact' => $this->whenLoaded('contact', fn () => $this->contact ? [ + 'id' => $this->contact->id, 'name' => $this->contact->name, + ] : null), + 'items' => $this->whenLoaded('items', fn () => $this->items->map(fn ($i) => [ + 'id' => $i->id, + 'description' => $i->description, + 'quantity' => $i->quantity, + 'unit_price' => $i->unit_price, + 'tax_rate' => $i->tax_rate, + 'subtotal' => $i->subtotal, + 'tax' => $i->tax, + 'line_total' => $i->line_total, + ])), + 'payments' => $this->whenLoaded('payments', fn () => $this->payments->map(fn ($p) => [ + 'id' => $p->id, + 'amount' => $p->amount, + 'payment_date' => $p->payment_date?->toDateString(), + 'method' => $p->method, + 'reference' => $p->reference, + ])), + 'subtotal' => $this->whenLoaded('items', fn () => $this->subtotal), + 'tax_total' => $this->whenLoaded('items', fn () => $this->tax_total), + 'total' => $this->whenLoaded('items', fn () => $this->total), + 'base_total' => $this->whenLoaded('items', fn () => $this->base_total), + 'amount_paid' => $this->whenLoaded('payments', fn () => $this->amount_paid), + 'amount_due' => $this->when( + $this->relationLoaded('items') && $this->relationLoaded('payments'), + fn () => $this->amount_due + ), + 'transitions' => $this->availableTransitions(), + 'attachments' => $this->whenLoaded('attachments', fn () => $this->attachments->map(fn ($a) => [ + 'id' => $a->id, 'filename' => $a->filename, 'disk' => $a->disk, + 'path' => $a->path, 'mime_type' => $a->mime_type, 'size' => $a->size, + 'uploaded_by' => $a->uploaded_by, 'created_at' => $a->created_at?->toIso8601String(), + ])), + 'creator' => $this->whenLoaded('creator', fn () => $this->creator?->name), + 'created_at' => $this->created_at?->toDateString(), + ]; + } +} diff --git a/erp/app/Modules/Finance/Http/Resources/JournalEntryResource.php b/erp/app/Modules/Finance/Http/Resources/JournalEntryResource.php new file mode 100644 index 00000000000..d00f1533f99 --- /dev/null +++ b/erp/app/Modules/Finance/Http/Resources/JournalEntryResource.php @@ -0,0 +1,32 @@ + $this->id, + 'date' => $this->date?->toDateString(), + 'reference' => $this->reference, + 'description' => $this->description, + 'status' => $this->status, + 'total_debits' => $this->whenLoaded('lines', fn () => $this->total_debits), + 'total_credits'=> $this->whenLoaded('lines', fn () => $this->total_credits), + 'creator' => $this->whenLoaded('creator', fn () => ['id' => $this->creator->id, 'name' => $this->creator->name]), + 'lines' => $this->whenLoaded('lines', fn () => $this->lines->map(fn ($line) => [ + 'id' => $line->id, + 'account_id' => $line->account_id, + 'account' => $line->relationLoaded('account') ? ['id' => $line->account->id, 'code' => $line->account->code, 'name' => $line->account->name] : null, + 'debit' => $line->debit, + 'credit' => $line->credit, + 'description' => $line->description, + ])), + 'created_at' => $this->created_at?->toDateTimeString(), + ]; + } +} diff --git a/erp/app/Modules/Finance/Http/Resources/QuoteResource.php b/erp/app/Modules/Finance/Http/Resources/QuoteResource.php new file mode 100644 index 00000000000..175403302e6 --- /dev/null +++ b/erp/app/Modules/Finance/Http/Resources/QuoteResource.php @@ -0,0 +1,41 @@ + $this->id, + 'number' => $this->number, + 'status' => $this->status, + 'issue_date' => $this->issue_date?->toDateString(), + 'expiry_date' => $this->expiry_date?->toDateString(), + 'notes' => $this->notes, + 'currency_code' => $this->currency_code ?? 'USD', + 'exchange_rate' => $this->exchange_rate ?? 1.0, + 'contact' => $this->whenLoaded('contact', fn () => $this->contact ? [ + 'id' => $this->contact->id, 'name' => $this->contact->name, + ] : null), + 'items' => $this->whenLoaded('items', fn () => $this->items->map(fn ($item) => [ + 'id' => $item->id, + 'description' => $item->description, + 'quantity' => $item->quantity, + 'unit_price' => $item->unit_price, + 'tax_rate' => $item->tax_rate, + 'line_total' => $item->line_total, + ])), + 'subtotal' => $this->whenLoaded('items', fn () => $this->subtotal), + 'tax_total' => $this->whenLoaded('items', fn () => $this->tax_total), + 'total' => $this->whenLoaded('items', fn () => $this->total), + 'base_total' => $this->whenLoaded('items', fn () => $this->base_total), + 'transitions' => $this->availableTransitions(), + 'created_by' => $this->whenLoaded('creator', fn () => $this->creator?->name), + 'created_at' => $this->created_at, + ]; + } +} diff --git a/erp/app/Modules/Finance/Http/Resources/RecurringInvoiceResource.php b/erp/app/Modules/Finance/Http/Resources/RecurringInvoiceResource.php new file mode 100644 index 00000000000..2a6c9fc6bc5 --- /dev/null +++ b/erp/app/Modules/Finance/Http/Resources/RecurringInvoiceResource.php @@ -0,0 +1,46 @@ + $this->id, + 'contact' => $this->whenLoaded('contact', fn () => $this->contact ? [ + 'id' => $this->contact->id, 'name' => $this->contact->name, + ] : null), + 'reference_prefix' => $this->reference_prefix, + 'frequency' => $this->frequency, + 'interval' => $this->interval, + 'start_date' => $this->start_date?->toDateString(), + 'next_run_date' => $this->next_run_date?->toDateString(), + 'end_date' => $this->end_date?->toDateString(), + 'due_days' => $this->due_days, + 'status' => $this->status, + 'auto_send' => (bool) $this->auto_send, + 'currency_code' => $this->currency_code, + 'exchange_rate' => $this->exchange_rate, + 'notes' => $this->notes, + 'last_generated_at' => $this->last_generated_at, + 'generated_count' => $this->generated_count, + 'items' => $this->whenLoaded('items', fn () => $this->items->map(fn ($item) => [ + 'id' => $item->id, + 'description' => $item->description, + 'quantity' => $item->quantity, + 'unit_price' => $item->unit_price, + 'tax_rate' => $item->tax_rate, + 'line_total' => $item->line_total, + ])), + 'subtotal' => $this->whenLoaded('items', fn () => $this->subtotal), + 'tax_total' => $this->whenLoaded('items', fn () => $this->tax_total), + 'total' => $this->whenLoaded('items', fn () => $this->total), + 'created_by' => $this->whenLoaded('creator', fn () => $this->creator?->name), + 'created_at' => $this->created_at, + ]; + } +} diff --git a/erp/app/Modules/Finance/Http/Resources/SalesOrderResource.php b/erp/app/Modules/Finance/Http/Resources/SalesOrderResource.php new file mode 100644 index 00000000000..0e90aecf57e --- /dev/null +++ b/erp/app/Modules/Finance/Http/Resources/SalesOrderResource.php @@ -0,0 +1,48 @@ + $this->id, + 'number' => $this->number, + 'status' => $this->status, + 'order_date' => $this->order_date?->toDateString(), + 'expected_date' => $this->expected_date?->toDateString(), + 'notes' => $this->notes, + 'contact' => $this->whenLoaded('contact', fn () => $this->contact ? [ + 'id' => $this->contact->id, 'name' => $this->contact->name, + ] : null), + 'warehouse' => $this->whenLoaded('warehouse', fn () => $this->warehouse ? [ + 'id' => $this->warehouse->id, 'name' => $this->warehouse->name, + ] : null), + 'invoice' => $this->whenLoaded('invoice', fn () => $this->invoice ? [ + 'id' => $this->invoice->id, 'number' => $this->invoice->number, + ] : null), + 'items' => $this->whenLoaded('items', fn () => $this->items->map(fn ($item) => [ + 'id' => $item->id, + 'product_id' => $item->product_id, + 'product_name' => $item->product?->name, + 'product_sku' => $item->product?->sku, + 'description' => $item->description, + 'quantity' => $item->quantity, + 'unit_price' => $item->unit_price, + 'tax_rate' => $item->tax_rate, + 'quantity_fulfilled' => $item->quantity_fulfilled, + 'line_total' => $item->line_total, + ])), + 'subtotal' => $this->whenLoaded('items', fn () => $this->subtotal), + 'tax_total' => $this->whenLoaded('items', fn () => $this->tax_total), + 'total' => $this->whenLoaded('items', fn () => $this->total), + 'transitions' => $this->availableTransitions(), + 'created_by' => $this->whenLoaded('creator', fn () => $this->creator?->name), + 'created_at' => $this->created_at, + ]; + } +} diff --git a/erp/app/Modules/Finance/Mail/DocumentMail.php b/erp/app/Modules/Finance/Mail/DocumentMail.php new file mode 100644 index 00000000000..590dc039849 --- /dev/null +++ b/erp/app/Modules/Finance/Mail/DocumentMail.php @@ -0,0 +1,48 @@ +mailSubject); + } + + public function content(): Content + { + return new Content(view: 'emails.document', with: [ + 'subject' => $this->mailSubject, + 'message_body' => $this->messageBody, + 'company' => $this->company, + ]); + } + + public function attachments(): array + { + $pdfData = $this->pdfData; + $filename = $this->filename; + + return [ + Attachment::fromData(fn () => $pdfData, $filename) + ->withMime('application/pdf'), + ]; + } +} diff --git a/erp/app/Modules/Finance/Models/Account.php b/erp/app/Modules/Finance/Models/Account.php new file mode 100644 index 00000000000..47b2b16a93b --- /dev/null +++ b/erp/app/Modules/Finance/Models/Account.php @@ -0,0 +1,66 @@ + 'boolean']; + + /** Types whose normal balance is debit */ + private const DEBIT_NORMAL = ['asset', 'expense']; + + public function parent(): BelongsTo + { + return $this->belongsTo(Account::class, 'parent_id'); + } + + public function children(): HasMany + { + return $this->hasMany(Account::class, 'parent_id'); + } + + public function journalLines(): HasMany + { + return $this->hasMany(JournalLine::class); + } + + public function getBalanceAttribute(): float + { + $debits = (float) $this->journalLines() + ->whereHas('journalEntry', fn ($q) => $q->where('status', 'posted')) + ->sum('debit'); + $credits = (float) $this->journalLines() + ->whereHas('journalEntry', fn ($q) => $q->where('status', 'posted')) + ->sum('credit'); + + return in_array($this->type, self::DEBIT_NORMAL, true) + ? $debits - $credits + : $credits - $debits; + } + + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + public function scopeOfType($query, string $type) + { + return $query->where('type', $type); + } +} diff --git a/erp/app/Modules/Finance/Models/AdvancePayment.php b/erp/app/Modules/Finance/Models/AdvancePayment.php new file mode 100644 index 00000000000..c3f367bf931 --- /dev/null +++ b/erp/app/Modules/Finance/Models/AdvancePayment.php @@ -0,0 +1,61 @@ + 'float', + 'applied_amount' => 'float', + 'payment_date' => 'date', + 'refunded_at' => 'datetime', + ]; + + public function contact(): BelongsTo + { + return $this->belongsTo(Contact::class); + } + + public function createdBy(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function applyAmount(float $amount): void + { + $this->applied_amount = min($this->applied_amount + $amount, $this->amount); + $this->status = $this->applied_amount >= $this->amount ? 'fully_applied' : 'partially_applied'; + $this->save(); + } + + public function refund(): void + { + $this->status = 'refunded'; + $this->refunded_at = now(); + $this->save(); + } + + public function getRemainingAmountAttribute(): float + { + return max(0.0, $this->amount - $this->applied_amount); + } + + public function getIsAvailableAttribute(): bool + { + return $this->status !== 'refunded' && $this->remaining_amount > 0; + } +} diff --git a/erp/app/Modules/Finance/Models/Attachment.php b/erp/app/Modules/Finance/Models/Attachment.php new file mode 100644 index 00000000000..6cf60e994db --- /dev/null +++ b/erp/app/Modules/Finance/Models/Attachment.php @@ -0,0 +1,28 @@ +morphTo(); + } + + public function uploader(): BelongsTo + { + return $this->belongsTo(\App\Models\User::class, 'uploaded_by'); + } +} diff --git a/erp/app/Modules/Finance/Models/BankAccount.php b/erp/app/Modules/Finance/Models/BankAccount.php new file mode 100644 index 00000000000..e939b521df8 --- /dev/null +++ b/erp/app/Modules/Finance/Models/BankAccount.php @@ -0,0 +1,50 @@ + 'float', + 'is_active' => 'boolean', + ]; + + public function transactions(): HasMany + { + return $this->hasMany(BankTransaction::class); + } + + public function reconciliations(): HasMany + { + return $this->hasMany(BankReconciliation::class); + } + + public function getBalanceAttribute(): float + { + return (float) $this->current_balance; + } + + public function getUnreconciledCountAttribute(): int + { + return $this->transactions()->where('reconciled', false)->count(); + } + + public function updateBalance(): void + { + $this->current_balance = $this->transactions()->sum('amount'); + $this->save(); + } +} diff --git a/erp/app/Modules/Finance/Models/BankReconciliation.php b/erp/app/Modules/Finance/Models/BankReconciliation.php new file mode 100644 index 00000000000..8b2f7bf25f6 --- /dev/null +++ b/erp/app/Modules/Finance/Models/BankReconciliation.php @@ -0,0 +1,62 @@ + 'float', + 'reconciled_balance' => 'float', + 'statement_date' => 'date', + 'completed_at' => 'datetime', + ]; + + public function account(): BelongsTo + { + return $this->belongsTo(BankAccount::class, 'bank_account_id'); + } + + public function completedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'completed_by'); + } + + public function transactions(): HasMany + { + return $this->hasMany(BankTransaction::class, 'reconciliation_id'); + } + + public function getDifferenceAttribute(): float + { + return $this->statement_balance - $this->reconciled_balance; + } + + public function getIsBalancedAttribute(): bool + { + return abs($this->difference) < 0.01; + } + + public function complete(User $user): void + { + $this->reconciled_balance = $this->transactions()->sum('amount'); + $this->status = 'completed'; + $this->completed_by = $user->id; + $this->completed_at = now(); + $this->save(); + + $this->transactions()->update(['is_reconciled' => true]); + } +} diff --git a/erp/app/Modules/Finance/Models/BankTransaction.php b/erp/app/Modules/Finance/Models/BankTransaction.php new file mode 100644 index 00000000000..963935eab3f --- /dev/null +++ b/erp/app/Modules/Finance/Models/BankTransaction.php @@ -0,0 +1,50 @@ + 'date', + 'reconciled' => 'boolean', + 'is_reconciled' => 'boolean', + 'amount' => 'float', + ]; + + public function bankAccount(): BelongsTo + { + return $this->belongsTo(BankAccount::class); + } + + public function account(): BelongsTo + { + return $this->belongsTo(BankAccount::class, 'bank_account_id'); + } + + public function reconciliation(): BelongsTo + { + return $this->belongsTo(BankReconciliation::class); + } + + public function payment(): BelongsTo + { + return $this->belongsTo(Payment::class); + } + + public function journalEntry(): BelongsTo + { + return $this->belongsTo(JournalEntry::class); + } +} diff --git a/erp/app/Modules/Finance/Models/BankTransfer.php b/erp/app/Modules/Finance/Models/BankTransfer.php new file mode 100644 index 00000000000..4de7ba264ab --- /dev/null +++ b/erp/app/Modules/Finance/Models/BankTransfer.php @@ -0,0 +1,71 @@ + 'float', + 'transfer_date' => 'date', + 'processed_at' => 'datetime', + ]; + + public function fromAccount(): BelongsTo + { + return $this->belongsTo(BankAccount::class, 'from_account_id'); + } + + public function toAccount(): BelongsTo + { + return $this->belongsTo(BankAccount::class, 'to_account_id'); + } + + public function createdBy(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function complete(): void + { + $this->status = 'completed'; + $this->processed_at = now(); + $this->save(); + } + + public function fail(): void + { + $this->status = 'failed'; + $this->processed_at = now(); + $this->save(); + } + + public function cancel(): void + { + $this->status = 'cancelled'; + $this->save(); + } + + public function getIsPendingAttribute(): bool + { + return $this->status === 'pending'; + } + + public function getIsCompletedAttribute(): bool + { + return $this->status === 'completed'; + } +} diff --git a/erp/app/Modules/Finance/Models/BatchPayment.php b/erp/app/Modules/Finance/Models/BatchPayment.php new file mode 100644 index 00000000000..8cec30e434a --- /dev/null +++ b/erp/app/Modules/Finance/Models/BatchPayment.php @@ -0,0 +1,28 @@ + 'date', + 'total_amount' => 'decimal:2', + ]; + + public function payments(): HasMany + { + return $this->hasMany(Payment::class); + } +} diff --git a/erp/app/Modules/Finance/Models/Bill.php b/erp/app/Modules/Finance/Models/Bill.php new file mode 100644 index 00000000000..3306c2ee406 --- /dev/null +++ b/erp/app/Modules/Finance/Models/Bill.php @@ -0,0 +1,74 @@ + 'date', + 'due_date' => 'date', + 'exchange_rate' => 'float', + ]; + + protected $attributes = ['status' => 'draft']; + + protected function getTransitions(): array + { + return [ + 'draft' => ['received', 'cancelled'], + 'received' => ['partial', 'paid', 'cancelled'], + 'partial' => ['paid', 'cancelled'], + 'paid' => [], + 'cancelled' => [], + ]; + } + + public function getBaseTotalAttribute(): float + { + return round($this->total * (float) $this->exchange_rate, 2); + } + + public function contact(): BelongsTo + { + return $this->belongsTo(Contact::class); + } + + public function items(): HasMany + { + return $this->hasMany(BillItem::class); + } + + public function payments(): HasMany + { + return $this->hasMany(BillPayment::class); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } +} diff --git a/erp/app/Modules/Finance/Models/BillItem.php b/erp/app/Modules/Finance/Models/BillItem.php new file mode 100644 index 00000000000..1e48369bd1c --- /dev/null +++ b/erp/app/Modules/Finance/Models/BillItem.php @@ -0,0 +1,39 @@ + 'decimal:2', + 'unit_price' => 'decimal:2', + 'tax_rate' => 'decimal:2', + ]; + + public function bill(): BelongsTo + { + return $this->belongsTo(Bill::class); + } + + public function getSubtotalAttribute(): float + { + return (float) $this->quantity * (float) $this->unit_price; + } + + public function getTaxAttribute(): float + { + return $this->subtotal * ((float) $this->tax_rate / 100); + } + + public function getLineTotalAttribute(): float + { + return $this->subtotal + $this->tax; + } +} diff --git a/erp/app/Modules/Finance/Models/BillPayment.php b/erp/app/Modules/Finance/Models/BillPayment.php new file mode 100644 index 00000000000..df84811cc39 --- /dev/null +++ b/erp/app/Modules/Finance/Models/BillPayment.php @@ -0,0 +1,24 @@ + 'decimal:2', + 'payment_date' => 'date', + ]; + + public function bill(): BelongsTo + { + return $this->belongsTo(Bill::class); + } +} diff --git a/erp/app/Modules/Finance/Models/Budget.php b/erp/app/Modules/Finance/Models/Budget.php new file mode 100644 index 00000000000..b88f13c4dc2 --- /dev/null +++ b/erp/app/Modules/Finance/Models/Budget.php @@ -0,0 +1,146 @@ + 'integer', + 'year' => 'integer', + 'total_amount' => 'decimal:2', + 'allocated_amount' => 'decimal:2', + 'spent_amount' => 'decimal:2', + 'approved_at' => 'datetime', + 'start_date' => 'date', + 'end_date' => 'date', + ]; + + protected $attributes = [ + 'status' => 'draft', + 'total_amount' => 0, + 'allocated_amount' => 0, + 'spent_amount' => 0, + ]; + + // ─── Relations ──────────────────────────────────────────────────────────── + + public function lines(): HasMany + { + return $this->hasMany(BudgetLine::class); + } + + public function lineItems(): HasMany + { + return $this->hasMany(BudgetLineItem::class); + } + + // ─── Actions ────────────────────────────────────────────────────────────── + + public function activate(int $userId): void + { + $this->status = 'active'; + $this->approved_by = $userId; + $this->approved_at = now(); + + if (is_null($this->budget_number)) { + $this->budget_number = $this->generateBudgetNumber(); + } + + $this->save(); + } + + public function close(): void + { + $this->status = 'closed'; + $this->save(); + } + + public function generateBudgetNumber(): string + { + return 'BDG-' . date('Y') . '-' . str_pad((string) $this->id, 5, '0', STR_PAD_LEFT); + } + + public function recalculate(): void + { + $spent = (float) $this->lineItems()->sum('actual_amount'); + + $this->spent_amount = $spent; + + if ($spent >= (float) $this->total_amount) { + $this->status = 'exceeded'; + } + + $this->save(); + } + + // ─── Accessors ──────────────────────────────────────────────────────────── + + public function getRemainingAmountAttribute(): float + { + return (float) $this->total_amount - (float) $this->spent_amount; + } + + public function getUtilizationPercentAttribute(): float + { + $total = (float) $this->total_amount; + + if ($total <= 0) { + return 0.0; + } + + return round(((float) $this->spent_amount / $total) * 100, 2); + } + + public function getIsActiveAttribute(): bool + { + return $this->status === 'active'; + } + + public function getIsExceededAttribute(): bool + { + return $this->status === 'exceeded'; + } + + // ─── Legacy accessors (Phase 82) ────────────────────────────────────────── + + public function getTotalBudgetedAttribute(): float + { + return (float) $this->lines->sum('budgeted_amount'); + } + + public function getTotalActualAttribute(): float + { + return (float) $this->lines->sum('actual_amount'); + } + + public function getTotalVarianceAttribute(): float + { + return $this->total_actual - $this->total_budgeted; + } + + public function getVariancePercentAttribute(): float + { + $budgeted = $this->total_budgeted; + + if ($budgeted == 0) { + return 0.0; + } + + return round(($this->total_variance / abs($budgeted)) * 100, 1); + } +} diff --git a/erp/app/Modules/Finance/Models/BudgetLine.php b/erp/app/Modules/Finance/Models/BudgetLine.php new file mode 100644 index 00000000000..bb0d2ff56e8 --- /dev/null +++ b/erp/app/Modules/Finance/Models/BudgetLine.php @@ -0,0 +1,51 @@ + 'float', + 'actual_amount' => 'float', + 'period_number' => 'integer', + ]; + + public function budget(): BelongsTo + { + return $this->belongsTo(Budget::class); + } + + public function getVarianceAttribute(): float + { + return $this->actual_amount - $this->budgeted_amount; + } + + public function getVariancePercentAttribute(): float + { + if ($this->budgeted_amount == 0) { + return 0.0; + } + return round(($this->variance / abs($this->budgeted_amount)) * 100, 1); + } + + public function getIsOverBudgetAttribute(): bool + { + if ($this->line_type === 'income') { + // For income, negative variance = under-performing = over budget + return $this->variance < 0; + } + // For expenses, positive variance = over budget + return $this->variance > 0; + } +} diff --git a/erp/app/Modules/Finance/Models/BudgetLineItem.php b/erp/app/Modules/Finance/Models/BudgetLineItem.php new file mode 100644 index 00000000000..46d0d8569d5 --- /dev/null +++ b/erp/app/Modules/Finance/Models/BudgetLineItem.php @@ -0,0 +1,33 @@ + 'decimal:2', + 'actual_amount' => 'decimal:2', + ]; + + protected $attributes = [ + 'planned_amount' => 0, + 'actual_amount' => 0, + ]; + + public function budget(): BelongsTo + { + return $this->belongsTo(Budget::class); + } + + public function getVarianceAttribute(): float + { + return (float) $this->planned_amount - (float) $this->actual_amount; + } +} diff --git a/erp/app/Modules/Finance/Models/CashFlowForecast.php b/erp/app/Modules/Finance/Models/CashFlowForecast.php new file mode 100644 index 00000000000..0f26a5ff218 --- /dev/null +++ b/erp/app/Modules/Finance/Models/CashFlowForecast.php @@ -0,0 +1,115 @@ + 'decimal:2', + 'projected_inflows' => 'decimal:2', + 'projected_outflows' => 'decimal:2', + 'actual_inflows' => 'decimal:2', + 'actual_outflows' => 'decimal:2', + 'period_start' => 'date', + 'period_end' => 'date', + 'approved_at' => 'datetime', + ]; + + protected $attributes = [ + 'status' => 'draft', + 'opening_balance' => 0, + 'projected_inflows' => 0, + 'projected_outflows' => 0, + 'actual_inflows' => 0, + 'actual_outflows' => 0, + ]; + + // ─── Actions ────────────────────────────────────────────────────────────── + + public function publish(int $userId): void + { + $this->status = 'published'; + $this->approved_by = $userId; + $this->approved_at = now(); + + if (is_null($this->forecast_number)) { + $this->forecast_number = $this->generateForecastNumber(); + } + + $this->save(); + } + + public function archive(): void + { + $this->status = 'archived'; + $this->save(); + } + + public function generateForecastNumber(): string + { + return 'CF-' . date('Y') . '-' . str_pad((string) $this->id, 5, '0', STR_PAD_LEFT); + } + + // ─── Accessors ──────────────────────────────────────────────────────────── + + public function getProjectedNetAttribute(): float + { + return (float) $this->projected_inflows - (float) $this->projected_outflows; + } + + public function getActualNetAttribute(): float + { + return (float) $this->actual_inflows - (float) $this->actual_outflows; + } + + public function getProjectedClosingBalanceAttribute(): float + { + return (float) $this->opening_balance + $this->projected_net; + } + + public function getActualClosingBalanceAttribute(): float + { + return (float) $this->opening_balance + $this->actual_net; + } + + public function getVarianceAttribute(): float + { + return $this->actual_net - $this->projected_net; + } + + public function getIsDraftAttribute(): bool + { + return $this->status === 'draft'; + } + + public function getIsPublishedAttribute(): bool + { + return $this->status === 'published'; + } +} diff --git a/erp/app/Modules/Finance/Models/Commission.php b/erp/app/Modules/Finance/Models/Commission.php new file mode 100644 index 00000000000..29482bf74e6 --- /dev/null +++ b/erp/app/Modules/Finance/Models/Commission.php @@ -0,0 +1,57 @@ + 'decimal:2', + 'commission_amount' => 'decimal:2', + 'approved_at' => 'datetime', + 'paid_at' => 'datetime', + ]; + + public function rule(): BelongsTo + { + return $this->belongsTo(CommissionRule::class, 'commission_rule_id'); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function invoice(): BelongsTo + { + return $this->belongsTo(Invoice::class); + } + + public function approve(): void + { + $this->status = 'approved'; + $this->approved_at = now(); + $this->save(); + } + + public function markPaid(): void + { + $this->status = 'paid'; + $this->paid_at = now(); + $this->save(); + } +} diff --git a/erp/app/Modules/Finance/Models/CommissionRule.php b/erp/app/Modules/Finance/Models/CommissionRule.php new file mode 100644 index 00000000000..fee61abc132 --- /dev/null +++ b/erp/app/Modules/Finance/Models/CommissionRule.php @@ -0,0 +1,47 @@ + 'decimal:4', + 'fixed_amount' => 'decimal:2', + 'is_active' => 'boolean', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function commissions(): HasMany + { + return $this->hasMany(Commission::class); + } + + public function calculateCommission(float $invoiceAmount): float + { + if ($this->type === 'percentage') { + return round($invoiceAmount * (float) $this->rate, 2); + } + + if ($this->type === 'fixed') { + return (float) $this->fixed_amount; + } + + return 0.0; + } +} diff --git a/erp/app/Modules/Finance/Models/Contact.php b/erp/app/Modules/Finance/Models/Contact.php new file mode 100644 index 00000000000..a15f3681d6f --- /dev/null +++ b/erp/app/Modules/Finance/Models/Contact.php @@ -0,0 +1,81 @@ + 'boolean', + 'credit_hold' => 'boolean', + 'credit_limit' => 'decimal:2', + 'credit_terms_days' => 'integer', + ]; + + public function invoices(): HasMany + { + return $this->hasMany(Invoice::class); + } + + public function bills(): HasMany + { + return $this->hasMany(Bill::class); + } + + public function priceList(): BelongsTo + { + return $this->belongsTo(PriceList::class); + } + + public function vendorProfile(): HasOne + { + return $this->hasOne(VendorProfile::class); + } + + public function vendorEvaluations(): HasMany + { + return $this->hasMany(VendorEvaluation::class)->latest('evaluation_date'); + } + + public function scopeCustomers($query) + { + return $query->whereIn('type', ['customer', 'both']); + } + + public function scopeVendors($query) + { + return $query->whereIn('type', ['vendor', 'both']); + } + + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + public function scopeSearch($query, string $term) + { + return $query->where(function ($q) use ($term) { + $q->where('name', 'like', "%{$term}%") + ->orWhere('email', 'like', "%{$term}%"); + }); + } +} diff --git a/erp/app/Modules/Finance/Models/Contract.php b/erp/app/Modules/Finance/Models/Contract.php new file mode 100644 index 00000000000..82afd7b620d --- /dev/null +++ b/erp/app/Modules/Finance/Models/Contract.php @@ -0,0 +1,122 @@ + 'float', + 'start_date' => 'date', + 'end_date' => 'date', + 'signed_at' => 'datetime', + 'terminated_at' => 'datetime', + 'auto_renew' => 'boolean', + ]; + + public function contact(): BelongsTo + { + return $this->belongsTo(Contact::class); + } + + public function createdBy(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function renewals(): HasMany + { + return $this->hasMany(ContractRenewal::class); + } + + public function activate(): void + { + $this->status = 'active'; + $this->signed_at = now(); + $this->save(); + } + + public function terminate(string $notes = ''): void + { + $this->status = 'terminated'; + $this->terminated_at = now(); + if ($notes !== '') { + $this->notes = $notes; + } + $this->save(); + } + + public function renew(string $newEndDate, ?float $newValue, string $notes, int $userId): void + { + $this->end_date = $newEndDate; + if ($newValue !== null) { + $this->value = $newValue; + } + ContractRenewal::create([ + 'tenant_id' => $this->tenant_id, + 'contract_id' => $this->id, + 'new_end_date'=> $newEndDate, + 'new_value' => $newValue, + 'notes' => $notes, + 'renewed_by' => $userId, + ]); + $this->save(); + } + + public static function generateContractNumber(): string + { + return 'CNT-' . strtoupper(uniqid()); + } + + public function getIsExpiredAttribute(): bool + { + return $this->end_date !== null + && $this->end_date->isPast() + && $this->status !== 'terminated'; + } + + public function getIsExpiringAttribute(): bool + { + if ($this->end_date === null || $this->status !== 'active') { + return false; + } + $noticeDays = $this->renewal_notice_days ?? 30; + return $this->end_date->isFuture() + && $this->end_date->diffInDays(now(), false) >= -$noticeDays; + } + + public function getDaysRemainingAttribute(): int + { + if ($this->end_date === null) { + return 0; + } + return max(0, (int) today()->diffInDays($this->end_date, false)); + } + + public function scopeExpiringSoon($query) + { + return $query->where('status', 'active') + ->whereBetween('end_date', [now(), now()->addDays(30)]); + } + + public function scopeActive($query) + { + return $query->where('status', 'active'); + } +} diff --git a/erp/app/Modules/Finance/Models/ContractRenewal.php b/erp/app/Modules/Finance/Models/ContractRenewal.php new file mode 100644 index 00000000000..2836d8ca572 --- /dev/null +++ b/erp/app/Modules/Finance/Models/ContractRenewal.php @@ -0,0 +1,32 @@ + 'date', + 'new_value' => 'float', + ]; + + public function contract(): BelongsTo + { + return $this->belongsTo(Contract::class); + } + + public function renewedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'renewed_by'); + } +} diff --git a/erp/app/Modules/Finance/Models/CreditNote.php b/erp/app/Modules/Finance/Models/CreditNote.php new file mode 100644 index 00000000000..c355f248c10 --- /dev/null +++ b/erp/app/Modules/Finance/Models/CreditNote.php @@ -0,0 +1,123 @@ + 'date', + 'subtotal' => 'float', + 'tax' => 'float', + 'total' => 'float', + // Legacy casts + 'exchange_rate' => 'float', + 'tax_total' => 'float', + 'amount_applied' => 'float', + ]; + + // Relations + public function items(): HasMany + { + return $this->hasMany(CreditNoteItem::class); + } + + public function contact(): BelongsTo + { + return $this->belongsTo(Contact::class); + } + + public function invoice(): BelongsTo + { + return $this->belongsTo(Invoice::class, 'original_invoice_id'); + } + + public function bill(): BelongsTo + { + return $this->belongsTo(Bill::class, 'original_bill_id'); + } + + public function createdBy(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + // Phase 103 methods + public static function generateCreditNoteNumber(): string + { + return 'CN-' . strtoupper(uniqid()); + } + + public function recalculateTotals(): void + { + $subtotal = $this->items()->get()->sum(fn ($i) => $i->quantity * $i->unit_price); + $this->subtotal = $subtotal; + $this->total = $subtotal + ($this->tax ?? 0); + $this->save(); + } + + public function issue(): void + { + $this->status = 'issued'; + $this->save(); + } + + public function apply(): void + { + $this->status = 'applied'; + $this->save(); + } + + public function void(): void + { + $this->status = 'void'; + $this->save(); + } + + // Accessors + public function getIsAvailableAttribute(): bool + { + return $this->status === 'issued'; + } + + public function getIsOpenAttribute(): bool + { + return in_array($this->status, ['draft', 'issued']); + } + + public function getAmountRemainingAttribute(): float + { + return max(0, (float) ($this->total ?? 0) - (float) ($this->amount_applied ?? 0)); + } + + protected static function booted(): void + { + static::saving(function (self $cn) { + // Legacy total calculation (when using old fields) + if ($cn->relationLoaded('items') && $cn->items->count() > 0 + && $cn->getAttribute('subtotal') === null) { + $cn->subtotal = $cn->items->sum('line_total'); + $cn->tax_total = $cn->items->sum(fn ($i) => $i->line_total * $i->tax_rate / 100); + $cn->total = $cn->subtotal + $cn->tax_total; + } + }); + } +} diff --git a/erp/app/Modules/Finance/Models/CreditNoteItem.php b/erp/app/Modules/Finance/Models/CreditNoteItem.php new file mode 100644 index 00000000000..307869a59bc --- /dev/null +++ b/erp/app/Modules/Finance/Models/CreditNoteItem.php @@ -0,0 +1,42 @@ + 'float', + 'unit_price' => 'float', + 'tax_rate' => 'float', + 'line_total' => 'float', + ]; + + public function getLineTotalAttribute(): float + { + return round((float) $this->quantity * (float) $this->unit_price, 2); + } + + public function creditNote(): BelongsTo + { + return $this->belongsTo(CreditNote::class); + } + + protected static function booted(): void + { + static::saving(function (self $item) { + // Keep line_total in sync if column exists + $item->line_total = round((float) $item->quantity * (float) $item->unit_price, 2); + }); + } +} diff --git a/erp/app/Modules/Finance/Models/Currency.php b/erp/app/Modules/Finance/Models/Currency.php new file mode 100644 index 00000000000..1eab6364393 --- /dev/null +++ b/erp/app/Modules/Finance/Models/Currency.php @@ -0,0 +1,48 @@ + 'boolean', + 'is_active' => 'boolean', + ]; + + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + public function scopeBase($query) + { + return $query->where('is_base', true); + } + + public function setAsBase(): void + { + static::where('tenant_id', $this->tenant_id)->update(['is_base' => false]); + $this->is_base = true; + $this->save(); + } + + public static function getBase(int $tenantId): ?self + { + return static::where('tenant_id', $tenantId)->where('is_base', true)->first(); + } +} diff --git a/erp/app/Modules/Finance/Models/CustomerCredit.php b/erp/app/Modules/Finance/Models/CustomerCredit.php new file mode 100644 index 00000000000..0cb1ef7cfb6 --- /dev/null +++ b/erp/app/Modules/Finance/Models/CustomerCredit.php @@ -0,0 +1,128 @@ + 'active', + 'currency' => 'USD', + 'used_amount' => 0, + ]; + + protected $casts = [ + 'credit_amount' => 'decimal:2', + 'used_amount' => 'decimal:2', + 'expiry_date' => 'date', + 'issued_at' => 'datetime', + ]; + + // Relations + + public function issuedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'issued_by'); + } + + public function createdBy(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + // Accessors + + protected function remainingAmount(): Attribute + { + return Attribute::make( + get: fn () => max(0, (float) $this->credit_amount - (float) $this->used_amount), + ); + } + + protected function isActive(): Attribute + { + return Attribute::make( + get: fn () => $this->status === 'active', + ); + } + + protected function isExhausted(): Attribute + { + return Attribute::make( + get: fn () => $this->status === 'exhausted', + ); + } + + protected function isExpired(): Attribute + { + return Attribute::make( + get: fn () => $this->status === 'expired' + || ($this->status === 'active' && $this->expiry_date && $this->expiry_date < now()->startOfDay()), + ); + } + + // Methods + + public function generateCreditNumber(): string + { + return 'CC-' . date('Y') . '-' . str_pad((string) $this->id, 5, '0', STR_PAD_LEFT); + } + + public function issue(int $userId): void + { + $this->issued_by = $userId; + $this->issued_at = now(); + if (is_null($this->credit_number)) { + $this->credit_number = $this->generateCreditNumber(); + } + $this->save(); + } + + public function apply(float $amount): void + { + $this->used_amount = (float) $this->used_amount + $amount; + if ((float) $this->used_amount >= (float) $this->credit_amount) { + $this->status = 'exhausted'; + } + $this->save(); + } + + public function expire(): void + { + $this->status = 'expired'; + $this->save(); + } + + public function cancel(): void + { + $this->status = 'cancelled'; + $this->save(); + } +} diff --git a/erp/app/Modules/Finance/Models/CustomerGroup.php b/erp/app/Modules/Finance/Models/CustomerGroup.php new file mode 100644 index 00000000000..d131023f961 --- /dev/null +++ b/erp/app/Modules/Finance/Models/CustomerGroup.php @@ -0,0 +1,55 @@ + 'float', + 'credit_limit' => 'float', + 'is_active' => 'boolean', + ]; + + public function members(): BelongsToMany + { + return $this->belongsToMany( + Contact::class, + 'customer_group_members', + 'customer_group_id', + 'contact_id' + )->withTimestamps(); + } + + public function paymentTerm(): BelongsTo + { + return $this->belongsTo(PaymentTerm::class); + } + + public function getMemberCountAttribute(): int + { + return $this->members()->count(); + } + + public function getHasDiscountAttribute(): bool + { + return $this->discount_percent > 0; + } + + public function calculateDiscount(float $amount): float + { + return $amount * ($this->discount_percent / 100); + } +} diff --git a/erp/app/Modules/Finance/Models/CustomerPortalToken.php b/erp/app/Modules/Finance/Models/CustomerPortalToken.php new file mode 100644 index 00000000000..678ea9461c3 --- /dev/null +++ b/erp/app/Modules/Finance/Models/CustomerPortalToken.php @@ -0,0 +1,54 @@ + 'datetime', + 'last_accessed_at' => 'datetime', + ]; + + public function contact(): BelongsTo + { + return $this->belongsTo(Contact::class); + } + + public function getIsExpiredAttribute(): bool + { + if ($this->expires_at === null) { + return false; + } + + return $this->expires_at->isPast(); + } + + public static function generate(int $tenantId, int $contactId, string $email, int $days = 30): self + { + $token = bin2hex(random_bytes(32)); // 64-char hex string + + return static::create([ + 'tenant_id' => $tenantId, + 'contact_id' => $contactId, + 'token' => $token, + 'email' => $email, + 'expires_at' => now()->addDays($days), + ]); + } +} diff --git a/erp/app/Modules/Finance/Models/DebitNote.php b/erp/app/Modules/Finance/Models/DebitNote.php new file mode 100644 index 00000000000..cc5774220bd --- /dev/null +++ b/erp/app/Modules/Finance/Models/DebitNote.php @@ -0,0 +1,94 @@ + 'draft', + ]; + + protected $casts = [ + 'issue_date' => 'date', + 'subtotal' => 'float', + 'tax' => 'float', + 'total' => 'float', + ]; + + // Relations + + public function items(): HasMany + { + return $this->hasMany(DebitNoteItem::class); + } + + public function vendor(): BelongsTo + { + return $this->belongsTo(Contact::class, 'vendor_id'); + } + + // Methods + + public function generateDebitNoteNumber(): string + { + return 'DN-' . date('Y') . '-' . str_pad((string) $this->id, 5, '0', STR_PAD_LEFT); + } + + public function recalculateTotals(): void + { + $items = $this->items()->get(); + $subtotal = $items->sum(fn ($item) => $item->quantity * $item->unit_price); + $tax = $items->sum(fn ($item) => $item->quantity * $item->unit_price * $item->tax_rate / 100); + $this->subtotal = $subtotal; + $this->tax = $tax; + $this->total = $subtotal + $tax; + $this->save(); + } + + public function issue(): void + { + $this->status = 'issued'; + if (is_null($this->debit_note_number)) { + $this->debit_note_number = $this->generateDebitNoteNumber(); + } + $this->save(); + } + + public function apply(): void + { + $this->status = 'applied'; + $this->save(); + } + + public function void(): void + { + $this->status = 'void'; + $this->save(); + } + + // Accessors + + public function getIsOpenAttribute(): bool + { + return in_array($this->status, ['draft', 'issued']); + } + + public function getIsAvailableAttribute(): bool + { + return $this->status === 'issued'; + } +} diff --git a/erp/app/Modules/Finance/Models/DebitNoteItem.php b/erp/app/Modules/Finance/Models/DebitNoteItem.php new file mode 100644 index 00000000000..0df34c4cfb8 --- /dev/null +++ b/erp/app/Modules/Finance/Models/DebitNoteItem.php @@ -0,0 +1,34 @@ + 'float', + 'unit_price' => 'float', + 'tax_rate' => 'float', + 'line_total' => 'float', + ]; + + public function debitNote(): BelongsTo + { + return $this->belongsTo(DebitNote::class); + } + + public function getLineTotalAttribute(): float + { + return (float) $this->quantity * (float) $this->unit_price; + } +} diff --git a/erp/app/Modules/Finance/Models/DeliveryNote.php b/erp/app/Modules/Finance/Models/DeliveryNote.php new file mode 100644 index 00000000000..691de109549 --- /dev/null +++ b/erp/app/Modules/Finance/Models/DeliveryNote.php @@ -0,0 +1,44 @@ + 'date', + 'delivery_date' => 'date', + ]; + + public function salesOrder(): BelongsTo + { + return $this->belongsTo(SalesOrder::class); + } + + public function invoice(): BelongsTo + { + return $this->belongsTo(Invoice::class); + } + + public function contact(): BelongsTo + { + return $this->belongsTo(Contact::class); + } + + public function items(): HasMany + { + return $this->hasMany(DeliveryNoteItem::class); + } +} diff --git a/erp/app/Modules/Finance/Models/DeliveryNoteItem.php b/erp/app/Modules/Finance/Models/DeliveryNoteItem.php new file mode 100644 index 00000000000..b03be8483ff --- /dev/null +++ b/erp/app/Modules/Finance/Models/DeliveryNoteItem.php @@ -0,0 +1,22 @@ +belongsTo(DeliveryNote::class); + } + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } +} diff --git a/erp/app/Modules/Finance/Models/DepreciationEntry.php b/erp/app/Modules/Finance/Models/DepreciationEntry.php new file mode 100644 index 00000000000..45e522f67ee --- /dev/null +++ b/erp/app/Modules/Finance/Models/DepreciationEntry.php @@ -0,0 +1,35 @@ + 'date', + 'amount' => 'float', + ]; + + public function fixedAsset(): BelongsTo + { + return $this->belongsTo(FixedAsset::class); + } + + public function journalEntry(): BelongsTo + { + return $this->belongsTo(JournalEntry::class); + } +} diff --git a/erp/app/Modules/Finance/Models/DocumentTemplate.php b/erp/app/Modules/Finance/Models/DocumentTemplate.php new file mode 100644 index 00000000000..465319a2514 --- /dev/null +++ b/erp/app/Modules/Finance/Models/DocumentTemplate.php @@ -0,0 +1,42 @@ + 'array', + 'is_default' => 'boolean', + 'is_active' => 'boolean', + ]; + + public function render(array $data): string + { + $rendered = $this->body; + foreach ($data as $key => $value) { + $rendered = str_replace('{{' . $key . '}}', $value, $rendered); + } + return $rendered; + } + + public function scopeForType(Builder $query, string $type): Builder + { + return $query->where('type', $type); + } + + public function scopeActive(Builder $query): Builder + { + return $query->where('is_active', true); + } +} diff --git a/erp/app/Modules/Finance/Models/ExchangeRate.php b/erp/app/Modules/Finance/Models/ExchangeRate.php new file mode 100644 index 00000000000..07dcde31459 --- /dev/null +++ b/erp/app/Modules/Finance/Models/ExchangeRate.php @@ -0,0 +1,199 @@ + 'date', + 'rate' => 'float', + 'is_active' => 'boolean', + ]; + + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + /** + * When setting from_currency, also populate base_currency for backward compatibility. + */ + public function setFromCurrencyAttribute(string $value): void + { + $this->attributes['from_currency'] = $value; + $this->attributes['base_currency'] = $value; + } + + /** + * When setting to_currency, also populate quote_currency for backward compatibility. + */ + public function setToCurrencyAttribute(string $value): void + { + $this->attributes['to_currency'] = $value; + $this->attributes['quote_currency'] = $value; + } + + /** + * When setting base_currency, also populate from_currency. + */ + public function setBaseCurrencyAttribute(string $value): void + { + $this->attributes['base_currency'] = $value; + $this->attributes['from_currency'] = $value; + } + + /** + * When setting quote_currency, also populate to_currency. + */ + public function setQuoteCurrencyAttribute(string $value): void + { + $this->attributes['quote_currency'] = $value; + $this->attributes['to_currency'] = $value; + } + + /** + * Get the most recent rate on or before $date where from=from AND to=to. + * Supports both from_currency/to_currency and base_currency/quote_currency columns. + * Returns the rate as float, or null if not found. + * + * @param int $tenantId + * @param string $from + * @param string $to + * @param string|Carbon|null $date + */ + public static function getRate(int $tenantId, string $from, string $to, $date = null): ?float + { + if ($from === $to) { + return 1.0; + } + + $dateStr = $date instanceof Carbon + ? $date->toDateString() + : ($date ?? now()->toDateString()); + + $value = static::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where(function ($q) use ($from, $to) { + // Support new from_currency/to_currency columns + $q->where(function ($q2) use ($from, $to) { + $q2->where('from_currency', $from)->where('to_currency', $to); + }) + // Support old base_currency/quote_currency columns + ->orWhere(function ($q2) use ($from, $to) { + $q2->whereNull('from_currency') + ->where('base_currency', $from) + ->where('quote_currency', $to); + }); + }) + ->whereDate('effective_date', '<=', $dateStr) + ->orderByDesc('effective_date') + ->value('rate'); + + return $value !== null ? (float) $value : null; + } + + /** + * Convert an amount from one currency to another. + * Supports two call signatures: + * New: convert(int $tenantId, float $amount, string $from, string $to, ?Carbon $date = null) + * Old: convert(float $amount, string $from, string $to, int $tenantId, ?string $date = null) + * + * Returns null if no rate found. + */ + public static function convert($tenantIdOrAmount, $amountOrFrom, string $from, $toOrTenantId, $dateOrNull = null): ?float + { + // Detect call signature: + // New: first arg is int (tenantId), second arg is numeric (amount) + // Old: first arg is float/int (amount), second arg is string (from currency) + if (is_string($amountOrFrom)) { + // Old signature: convert(amount, from, to, tenantId, date) + $amount = (float) $tenantIdOrAmount; + $fromCurr = (string) $amountOrFrom; + $toCurr = (string) $from; + $tenantId = (int) $toOrTenantId; + $date = $dateOrNull; + } else { + // New signature: convert(tenantId, amount, from, to, date) + $tenantId = (int) $tenantIdOrAmount; + $amount = (float) $amountOrFrom; + $fromCurr = (string) $from; + $toCurr = (string) $toOrTenantId; + $date = $dateOrNull; + } + + if ($fromCurr === $toCurr) { + return $amount; + } + + $rate = static::getRate($tenantId, $fromCurr, $toCurr, $date); + + if ($rate === null) { + return null; + } + + return round($amount * $rate, 4); + } + + /** + * Legacy helper: get rate for a currency code vs USD. + * Returns 1.0 if currency is USD or no rate found. + */ + public static function rateFor(string $currencyCode, string $tenantIdOrDate, ?string $date = null): float + { + if ($currencyCode === 'USD') { + return 1.0; + } + + if ($date === null) { + $date = $tenantIdOrDate; + $tenantId = app('tenant')->id; + } else { + $tenantId = (int) $tenantIdOrDate; + } + + $record = static::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where(function ($q) use ($currencyCode) { + $q->where(function ($q2) use ($currencyCode) { + $q2->where('base_currency', 'USD') + ->where('quote_currency', $currencyCode); + })->orWhere(function ($q2) use ($currencyCode) { + $q2->where('base_currency', $currencyCode) + ->where('quote_currency', 'USD'); + }); + }) + ->whereDate('effective_date', '<=', $date) + ->orderByDesc('effective_date') + ->first(); + + if (!$record) { + return 1.0; + } + + // If base is USD, quote is the currency: rate = quote per USD + if ($record->base_currency === 'USD') { + return (float) $record->rate; + } + + // If base is currency and quote is USD: inverse + return $record->rate != 0 ? round(1 / (float) $record->rate, 6) : 1.0; + } +} diff --git a/erp/app/Modules/Finance/Models/ExpenseBudget.php b/erp/app/Modules/Finance/Models/ExpenseBudget.php new file mode 100644 index 00000000000..7de4c21762a --- /dev/null +++ b/erp/app/Modules/Finance/Models/ExpenseBudget.php @@ -0,0 +1,93 @@ + 'decimal:2', + 'spent_amount' => 'decimal:2', + ]; + + protected $attributes = [ + 'status' => 'active', + 'currency' => 'USD', + 'allocated_amount' => 0, + 'spent_amount' => 0, + ]; + + // ─── Actions ────────────────────────────────────────────────────────────── + + public function freeze(): void + { + $this->status = 'frozen'; + $this->save(); + } + + public function close(): void + { + $this->status = 'closed'; + $this->save(); + } + + public function recordSpend(float $amount): void + { + $this->spent_amount = (float) $this->spent_amount + $amount; + $this->save(); + } + + public function generateBudgetCode(): string + { + return 'EB-' . date('Y') . '-' . str_pad((string) $this->id, 5, '0', STR_PAD_LEFT); + } + + // ─── Accessors ──────────────────────────────────────────────────────────── + + public function getRemainingAmountAttribute(): float + { + return max(0, (float) $this->allocated_amount - (float) $this->spent_amount); + } + + public function getUtilizationPercentAttribute(): float + { + $allocated = (float) $this->allocated_amount; + + if ($allocated <= 0) { + return 0.0; + } + + return round(((float) $this->spent_amount / $allocated) * 100, 2); + } + + public function getIsOverBudgetAttribute(): bool + { + return (float) $this->spent_amount > (float) $this->allocated_amount; + } + + public function getIsActiveAttribute(): bool + { + return $this->status === 'active'; + } +} diff --git a/erp/app/Modules/Finance/Models/ExpenseClaim.php b/erp/app/Modules/Finance/Models/ExpenseClaim.php new file mode 100644 index 00000000000..f2cf07c3bf4 --- /dev/null +++ b/erp/app/Modules/Finance/Models/ExpenseClaim.php @@ -0,0 +1,100 @@ + 'date', + 'submitted_at' => 'datetime', + 'approved_at' => 'datetime', + 'paid_at' => 'datetime', + 'total_amount' => 'float', + ]; + + public function submittedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'submitted_by'); + } + + public function approvedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'approved_by'); + } + + public function items(): HasMany + { + return $this->hasMany(ExpenseItem::class); + } + + public static function generateReference(): string + { + return 'EXP-' . strtoupper(uniqid()); + } + + public function submit(): void + { + $this->status = 'submitted'; + $this->submitted_at = now(); + $this->save(); + } + + public function approve(int $userId): void + { + $this->status = 'approved'; + $this->approved_by = $userId; + $this->approved_at = now(); + $this->save(); + } + + public function reject(): void + { + $this->status = 'rejected'; + $this->save(); + } + + public function markPaid(): void + { + $this->status = 'paid'; + $this->paid_at = now(); + $this->save(); + } + + public function recalculate(): void + { + $this->total_amount = $this->items()->sum('amount'); + $this->save(); + } + + public function getIsEditableAttribute(): bool + { + return $this->status === 'draft'; + } +} diff --git a/erp/app/Modules/Finance/Models/ExpenseItem.php b/erp/app/Modules/Finance/Models/ExpenseItem.php new file mode 100644 index 00000000000..091e6296fc7 --- /dev/null +++ b/erp/app/Modules/Finance/Models/ExpenseItem.php @@ -0,0 +1,34 @@ + 'date', + 'amount' => 'float', + ]; + + public function claim(): BelongsTo + { + return $this->belongsTo(ExpenseClaim::class); + } +} diff --git a/erp/app/Modules/Finance/Models/FixedAsset.php b/erp/app/Modules/Finance/Models/FixedAsset.php new file mode 100644 index 00000000000..15d2806890d --- /dev/null +++ b/erp/app/Modules/Finance/Models/FixedAsset.php @@ -0,0 +1,154 @@ + 'date', + 'disposal_date' => 'date', + 'purchase_cost' => 'float', + 'salvage_value' => 'float', + 'accumulated_depreciation' => 'float', + 'disposal_proceeds' => 'float', + ]; + + // Relations + + public function depreciationEntries(): HasMany + { + return $this->hasMany(DepreciationEntry::class); + } + + public function assetAccount(): BelongsTo + { + return $this->belongsTo(Account::class, 'asset_account_id'); + } + + public function depreciationAccount(): BelongsTo + { + return $this->belongsTo(Account::class, 'depreciation_account_id'); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + // Computed attributes + + public function getNetBookValueAttribute(): float + { + return $this->purchase_cost - $this->accumulated_depreciation; + } + + public function getAnnualDepreciationAttribute(): float + { + return ($this->purchase_cost - $this->salvage_value) / $this->useful_life_years; + } + + public function getDepreciableAmountAttribute(): float + { + return $this->purchase_cost - $this->salvage_value; + } + + // Business logic + + public function runDepreciation(string $periodDate, string $method = 'straight_line'): DepreciationEntry + { + if ($this->status !== 'active') { + throw new DomainException('Cannot depreciate asset with status: ' . $this->status); + } + + $annualDepreciation = $this->annual_depreciation; + $remaining = $this->depreciable_amount - $this->accumulated_depreciation; + + if ($remaining <= 0) { + throw new DomainException('Asset is fully depreciated'); + } + + $periodAmount = min($annualDepreciation, $remaining); + + return DB::transaction(function () use ($periodDate, $periodAmount) { + $journalEntryId = null; + + if ($this->depreciation_account_id && $this->asset_account_id) { + $journalEntry = JournalEntry::create([ + 'tenant_id' => $this->tenant_id, + 'date' => $periodDate, + 'reference' => 'DEP-' . $this->code, + 'description' => 'Depreciation for ' . $this->name, + 'status' => 'posted', + 'created_by' => $this->created_by, + ]); + + // Debit depreciation expense account + $journalEntry->lines()->create([ + 'account_id' => $this->depreciation_account_id, + 'debit' => $periodAmount, + 'credit' => 0, + 'description' => 'Depreciation expense: ' . $this->name, + ]); + + // Credit asset account + $journalEntry->lines()->create([ + 'account_id' => $this->asset_account_id, + 'debit' => 0, + 'credit' => $periodAmount, + 'description' => 'Accumulated depreciation: ' . $this->name, + ]); + + $journalEntryId = $journalEntry->id; + } + + $entry = DepreciationEntry::create([ + 'tenant_id' => $this->tenant_id, + 'fixed_asset_id' => $this->id, + 'journal_entry_id' => $journalEntryId, + 'period_date' => $periodDate, + 'amount' => $periodAmount, + ]); + + $this->accumulated_depreciation += $periodAmount; + + if ($this->accumulated_depreciation >= $this->depreciable_amount) { + $this->status = 'fully_depreciated'; + } + + $this->save(); + + return $entry; + }); + } +} diff --git a/erp/app/Modules/Finance/Models/IntercompanyTransaction.php b/erp/app/Modules/Finance/Models/IntercompanyTransaction.php new file mode 100644 index 00000000000..39793f3e990 --- /dev/null +++ b/erp/app/Modules/Finance/Models/IntercompanyTransaction.php @@ -0,0 +1,64 @@ + 'draft', + 'currency' => 'USD', + ]; + + protected $fillable = [ + 'tenant_id', 'transaction_number', 'from_entity', 'to_entity', + 'amount', 'currency', 'transaction_date', 'transaction_type', + 'description', 'status', 'created_by', + ]; + + protected $casts = [ + 'amount' => 'float', + 'transaction_date' => 'date', + ]; + + public function generateTransactionNumber(): string + { + return 'IC-' . date('Y') . '-' . str_pad((string) $this->id, 5, '0', STR_PAD_LEFT); + } + + public function post(): void + { + $this->status = 'posted'; + if (is_null($this->transaction_number)) { + $this->transaction_number = $this->generateTransactionNumber(); + } + $this->save(); + } + + public function reconcile(): void + { + $this->status = 'reconciled'; + $this->save(); + } + + public function reverse(): void + { + $this->status = 'reversed'; + $this->save(); + } + + public function getIsPostedAttribute(): bool + { + return $this->status === 'posted'; + } + + public function getIsDraftAttribute(): bool + { + return $this->status === 'draft'; + } +} diff --git a/erp/app/Modules/Finance/Models/Invoice.php b/erp/app/Modules/Finance/Models/Invoice.php new file mode 100644 index 00000000000..598dcf7ccdc --- /dev/null +++ b/erp/app/Modules/Finance/Models/Invoice.php @@ -0,0 +1,91 @@ + 'date', + 'due_date' => 'date', + 'exchange_rate' => 'float', + ]; + + protected $attributes = ['status' => 'draft']; + + protected function getTransitions(): array + { + return [ + 'draft' => ['sent', 'cancelled'], + 'sent' => ['partial', 'paid', 'cancelled'], + 'partial' => ['paid', 'cancelled'], + 'paid' => [], + 'cancelled' => [], + ]; + } + + public function getBaseTotalAttribute(): float + { + return round($this->total * (float) $this->exchange_rate, 2); + } + + public function contact(): BelongsTo + { + return $this->belongsTo(Contact::class); + } + + public function items(): HasMany + { + return $this->hasMany(InvoiceItem::class); + } + + public function payments(): HasMany + { + return $this->hasMany(Payment::class); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function recurringInvoice(): BelongsTo + { + return $this->belongsTo(RecurringInvoice::class); + } + + public function salesOrder(): BelongsTo + { + return $this->belongsTo(SalesOrder::class); + } + + public function assignedTo(): BelongsTo + { + return $this->belongsTo(User::class, 'assigned_to_user_id'); + } +} \ No newline at end of file diff --git a/erp/app/Modules/Finance/Models/InvoiceItem.php b/erp/app/Modules/Finance/Models/InvoiceItem.php new file mode 100644 index 00000000000..395bbb63e66 --- /dev/null +++ b/erp/app/Modules/Finance/Models/InvoiceItem.php @@ -0,0 +1,39 @@ + 'decimal:2', + 'unit_price' => 'decimal:2', + 'tax_rate' => 'decimal:2', + ]; + + public function invoice(): BelongsTo + { + return $this->belongsTo(Invoice::class); + } + + public function getSubtotalAttribute(): float + { + return (float) $this->quantity * (float) $this->unit_price; + } + + public function getTaxAttribute(): float + { + return $this->subtotal * ((float) $this->tax_rate / 100); + } + + public function getLineTotalAttribute(): float + { + return $this->subtotal + $this->tax; + } +} diff --git a/erp/app/Modules/Finance/Models/JournalEntry.php b/erp/app/Modules/Finance/Models/JournalEntry.php new file mode 100644 index 00000000000..a96564993b2 --- /dev/null +++ b/erp/app/Modules/Finance/Models/JournalEntry.php @@ -0,0 +1,73 @@ + 'date', + 'status' => 'string', + ]; + + protected $attributes = ['status' => 'draft']; + + public function lines(): HasMany + { + return $this->hasMany(JournalLine::class); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function getTotalDebitsAttribute(): float + { + return (float) $this->lines->sum('debit'); + } + + public function getTotalCreditsAttribute(): float + { + return (float) $this->lines->sum('credit'); + } + + public function isBalanced(): bool + { + return abs($this->total_debits - $this->total_credits) < 0.001; + } + + public function post(): void + { + if ($this->status === 'posted') { + throw new \DomainException('Journal entry is already posted.'); + } + + if (! $this->isBalanced()) { + throw new \DomainException( + sprintf( + 'Journal entry is not balanced (debits %.2f ≠ credits %.2f).', + $this->total_debits, + $this->total_credits + ) + ); + } + + $this->update(['status' => 'posted']); + } +} diff --git a/erp/app/Modules/Finance/Models/JournalLine.php b/erp/app/Modules/Finance/Models/JournalLine.php new file mode 100644 index 00000000000..0b2abdc8e73 --- /dev/null +++ b/erp/app/Modules/Finance/Models/JournalLine.php @@ -0,0 +1,28 @@ + 'decimal:2', + 'credit' => 'decimal:2', + ]; + + public function journalEntry(): BelongsTo + { + return $this->belongsTo(JournalEntry::class); + } + + public function account(): BelongsTo + { + return $this->belongsTo(Account::class); + } +} diff --git a/erp/app/Modules/Finance/Models/Lead.php b/erp/app/Modules/Finance/Models/Lead.php new file mode 100644 index 00000000000..49ec37fade8 --- /dev/null +++ b/erp/app/Modules/Finance/Models/Lead.php @@ -0,0 +1,87 @@ + 'decimal:2', + 'probability' => 'integer', + 'won_at' => 'date', + 'lost_at' => 'date', + 'expected_close_date' => 'date', + ]; + + public function assignedTo(): BelongsTo + { + return $this->belongsTo(User::class, 'assigned_to'); + } + + public function activities(): HasMany + { + return $this->hasMany(LeadActivity::class); + } + + public function markWon(): void + { + $this->stage = 'won'; + $this->won_at = Carbon::today(); + $this->probability = 100; + $this->save(); + } + + public function markLost(string $reason): void + { + $this->stage = 'lost'; + $this->lost_at = Carbon::today(); + $this->lost_reason = $reason; + $this->probability = 0; + $this->save(); + } + + public function moveStage(string $stage): void + { + $this->stage = $stage; + $this->save(); + } + + public function getWeightedValueAttribute(): float + { + if ($this->estimated_value === null) { + return 0.0; + } + + return (float) $this->estimated_value * $this->probability / 100; + } +} diff --git a/erp/app/Modules/Finance/Models/LeadActivity.php b/erp/app/Modules/Finance/Models/LeadActivity.php new file mode 100644 index 00000000000..7b5180fd24e --- /dev/null +++ b/erp/app/Modules/Finance/Models/LeadActivity.php @@ -0,0 +1,41 @@ + 'date', + 'duration_minutes' => 'integer', + ]; + + public function lead(): BelongsTo + { + return $this->belongsTo(Lead::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/erp/app/Modules/Finance/Models/LoyaltyEnrollment.php b/erp/app/Modules/Finance/Models/LoyaltyEnrollment.php new file mode 100644 index 00000000000..ce8f411f55b --- /dev/null +++ b/erp/app/Modules/Finance/Models/LoyaltyEnrollment.php @@ -0,0 +1,92 @@ + 'integer', + 'total_points_earned' => 'integer', + 'total_points_redeemed' => 'integer', + 'enrolled_at' => 'datetime', + ]; + + public function program(): BelongsTo + { + return $this->belongsTo(LoyaltyProgram::class, 'loyalty_program_id'); + } + + public function contact(): BelongsTo + { + return $this->belongsTo(Contact::class); + } + + public function transactions(): HasMany + { + return $this->hasMany(LoyaltyTransaction::class); + } + + public function earnPoints(int $points, string $description = '', ?int $referenceId = null): LoyaltyTransaction + { + $this->increment('points_balance', $points); + $this->increment('total_points_earned', $points); + $this->refresh(); + + return LoyaltyTransaction::create([ + 'tenant_id' => $this->tenant_id, + 'loyalty_enrollment_id' => $this->id, + 'type' => 'earn', + 'points' => $points, + 'description' => $description, + 'reference_id' => $referenceId, + 'balance_after' => $this->points_balance, + ]); + } + + public function redeemPoints(int $points, string $description = ''): LoyaltyTransaction + { + if ($points > $this->points_balance) { + throw new \Exception('Insufficient points balance.'); + } + + $this->decrement('points_balance', $points); + $this->increment('total_points_redeemed', $points); + $this->refresh(); + + return LoyaltyTransaction::create([ + 'tenant_id' => $this->tenant_id, + 'loyalty_enrollment_id' => $this->id, + 'type' => 'redeem', + 'points' => $points, + 'description' => $description, + 'balance_after' => $this->points_balance, + ]); + } + + public function updateTier(): void + { + $tier = $this->program->getTierForPoints($this->total_points_earned); + $this->tier_name = $tier['name'] ?? null; + $this->save(); + } +} diff --git a/erp/app/Modules/Finance/Models/LoyaltyProgram.php b/erp/app/Modules/Finance/Models/LoyaltyProgram.php new file mode 100644 index 00000000000..ad3b996221c --- /dev/null +++ b/erp/app/Modules/Finance/Models/LoyaltyProgram.php @@ -0,0 +1,69 @@ + 'decimal:4', + 'points_to_currency_rate' => 'decimal:6', + 'minimum_redemption_points' => 'integer', + 'is_active' => 'boolean', + 'tier_config' => 'array', + ]; + + public function enrollments(): HasMany + { + return $this->hasMany(LoyaltyEnrollment::class); + } + + public function calculatePointsEarned(float $amount): int + { + return (int) floor($amount * $this->points_per_currency_unit); + } + + public function calculateRedemptionValue(int $points): float + { + return round($points * $this->points_to_currency_rate, 2); + } + + public function getTierForPoints(int $points): ?array + { + $tiers = $this->tier_config; + + if (empty($tiers)) { + return null; + } + + usort($tiers, fn ($a, $b) => $b['min_points'] <=> $a['min_points']); + + foreach ($tiers as $tier) { + if ($points >= $tier['min_points']) { + return $tier; + } + } + + return null; + } +} diff --git a/erp/app/Modules/Finance/Models/LoyaltyTransaction.php b/erp/app/Modules/Finance/Models/LoyaltyTransaction.php new file mode 100644 index 00000000000..9887c71b2fc --- /dev/null +++ b/erp/app/Modules/Finance/Models/LoyaltyTransaction.php @@ -0,0 +1,34 @@ + 'integer', + 'balance_after' => 'integer', + ]; + + public function enrollment(): BelongsTo + { + return $this->belongsTo(LoyaltyEnrollment::class, 'loyalty_enrollment_id'); + } +} diff --git a/erp/app/Modules/Finance/Models/MaintenanceLog.php b/erp/app/Modules/Finance/Models/MaintenanceLog.php new file mode 100644 index 00000000000..8ff9cff1d03 --- /dev/null +++ b/erp/app/Modules/Finance/Models/MaintenanceLog.php @@ -0,0 +1,43 @@ + 'date', + 'next_service_date' => 'date', + 'hours_spent' => 'decimal:2', + ]; + + public function agreement(): BelongsTo + { + return $this->belongsTo(ServiceAgreement::class, 'service_agreement_id'); + } + + public function technician(): BelongsTo + { + return $this->belongsTo(User::class, 'technician_id'); + } + + public function complete(string $resolution): void + { + $this->status = 'completed'; + $this->resolution = $resolution; + $this->save(); + } +} diff --git a/erp/app/Modules/Finance/Models/Payment.php b/erp/app/Modules/Finance/Models/Payment.php new file mode 100644 index 00000000000..b65446b2c29 --- /dev/null +++ b/erp/app/Modules/Finance/Models/Payment.php @@ -0,0 +1,34 @@ + 'decimal:2', + 'payment_date' => 'date', + ]; + + public function invoice(): BelongsTo + { + return $this->belongsTo(Invoice::class); + } + + public function batchPayment(): BelongsTo + { + return $this->belongsTo(BatchPayment::class); + } +} diff --git a/erp/app/Modules/Finance/Models/PaymentSchedule.php b/erp/app/Modules/Finance/Models/PaymentSchedule.php new file mode 100644 index 00000000000..71a1974b79a --- /dev/null +++ b/erp/app/Modules/Finance/Models/PaymentSchedule.php @@ -0,0 +1,116 @@ + 'decimal:2', + 'paid_amount' => 'decimal:2', + 'start_date' => 'date', + 'end_date' => 'date', + 'installments' => 'integer', + ]; + + protected $attributes = [ + 'status' => 'active', + 'currency' => 'USD', + 'frequency' => 'monthly', + 'paid_amount' => 0, + 'installments' => 1, + ]; + + // ─── Relations ──────────────────────────────────────────────────────────── + + public function items(): HasMany + { + return $this->hasMany(PaymentScheduleItem::class); + } + + // ─── Actions ────────────────────────────────────────────────────────────── + + public function pause(): void + { + $this->status = 'paused'; + $this->save(); + } + + public function resume(): void + { + $this->status = 'active'; + $this->save(); + } + + public function cancel(): void + { + $this->status = 'cancelled'; + $this->save(); + } + + public function generateScheduleNumber(): string + { + return 'PS-' . date('Y') . '-' . str_pad((string) $this->id, 5, '0', STR_PAD_LEFT); + } + + public function recalculatePaidAmount(): void + { + $this->paid_amount = $this->items()->where('status', 'paid')->sum('amount'); + + if ((float) $this->paid_amount >= (float) $this->total_amount) { + $this->status = 'completed'; + } + + $this->save(); + } + + // ─── Accessors ──────────────────────────────────────────────────────────── + + public function getRemainingAmountAttribute(): float + { + return (float) $this->total_amount - (float) $this->paid_amount; + } + + public function getCompletionPercentAttribute(): float + { + if ((float) $this->total_amount > 0) { + return round(((float) $this->paid_amount / (float) $this->total_amount) * 100, 2); + } + + return 0; + } + + public function getIsActiveAttribute(): bool + { + return $this->status === 'active'; + } + + public function getIsCompletedAttribute(): bool + { + return $this->status === 'completed'; + } +} diff --git a/erp/app/Modules/Finance/Models/PaymentScheduleItem.php b/erp/app/Modules/Finance/Models/PaymentScheduleItem.php new file mode 100644 index 00000000000..853bcb11258 --- /dev/null +++ b/erp/app/Modules/Finance/Models/PaymentScheduleItem.php @@ -0,0 +1,58 @@ + 'decimal:2', + 'due_date' => 'date', + 'paid_date' => 'date', + ]; + + protected $attributes = [ + 'status' => 'pending', + ]; + + // ─── Relations ──────────────────────────────────────────────────────────── + + public function schedule(): BelongsTo + { + return $this->belongsTo(PaymentSchedule::class); + } + + // ─── Actions ────────────────────────────────────────────────────────────── + + public function markPaid(string $date = null): void + { + $this->status = 'paid'; + $this->paid_date = $date ?? Carbon::today()->toDateString(); + $this->save(); + } + + public function waive(): void + { + $this->status = 'waived'; + $this->save(); + } + + // ─── Accessors ──────────────────────────────────────────────────────────── + + public function getIsOverdueAttribute(): bool + { + return $this->status === 'pending' && $this->due_date->lt(Carbon::today()); + } +} diff --git a/erp/app/Modules/Finance/Models/PaymentTerm.php b/erp/app/Modules/Finance/Models/PaymentTerm.php new file mode 100644 index 00000000000..cab9c8b87fc --- /dev/null +++ b/erp/app/Modules/Finance/Models/PaymentTerm.php @@ -0,0 +1,48 @@ + 'integer', + 'discount_days' => 'integer', + 'discount_percent' => 'float', + 'is_active' => 'boolean', + ]; + + public function getDueDate(Carbon $fromDate): Carbon + { + return $fromDate->copy()->addDays($this->days); + } + + public function getDiscountDueDate(Carbon $fromDate): Carbon + { + return $fromDate->copy()->addDays($this->discount_days); + } + + public function getHasEarlyDiscountAttribute(): bool + { + return $this->discount_days > 0 && $this->discount_percent > 0; + } + + public function getDisplayLabelAttribute(): string + { + if ($this->has_early_discount) { + return "{$this->discount_percent}/{$this->discount_days} Net {$this->days}"; + } + return "Net {$this->days}"; + } +} diff --git a/erp/app/Modules/Finance/Models/PettyCashFund.php b/erp/app/Modules/Finance/Models/PettyCashFund.php new file mode 100644 index 00000000000..beecbeb31be --- /dev/null +++ b/erp/app/Modules/Finance/Models/PettyCashFund.php @@ -0,0 +1,97 @@ + 'float', + 'current_balance' => 'float', + 'is_active' => 'boolean', + 'last_replenished_at' => 'datetime', + ]; + + public function custodian(): BelongsTo + { + return $this->belongsTo(User::class, 'custodian_id'); + } + + public function transactions(): HasMany + { + return $this->hasMany(PettyCashTransaction::class, 'fund_id'); + } + + public function replenish(float $amount, int $userId): void + { + $this->current_balance = $this->current_balance + $amount; + $this->last_replenished_at = now(); + $this->save(); + + PettyCashTransaction::create([ + 'tenant_id' => $this->tenant_id, + 'fund_id' => $this->id, + 'type' => 'replenishment', + 'amount' => $amount, + 'description' => 'Fund replenishment', + 'transaction_date' => now()->toDateString(), + 'created_by' => $userId, + ]); + } + + public function addExpense(float $amount, string $description, string $date, int $userId, ?string $category = null): void + { + $this->current_balance = $this->current_balance - $amount; + $this->save(); + + PettyCashTransaction::create([ + 'tenant_id' => $this->tenant_id, + 'fund_id' => $this->id, + 'type' => 'expense', + 'amount' => $amount, + 'description' => $description, + 'transaction_date' => $date, + 'category' => $category, + 'created_by' => $userId, + ]); + } + + public function getIsLowBalanceAttribute(): bool + { + if ($this->authorized_amount <= 0) { + return false; + } + + return ($this->current_balance / $this->authorized_amount) < 0.2; + } + + public function getUtilizationPercentAttribute(): float + { + if ($this->authorized_amount <= 0) { + return 0.0; + } + + $spent = $this->authorized_amount - $this->current_balance; + + return round(($spent / $this->authorized_amount) * 100, 1); + } +} diff --git a/erp/app/Modules/Finance/Models/PettyCashTransaction.php b/erp/app/Modules/Finance/Models/PettyCashTransaction.php new file mode 100644 index 00000000000..7912f687a95 --- /dev/null +++ b/erp/app/Modules/Finance/Models/PettyCashTransaction.php @@ -0,0 +1,42 @@ + 'float', + 'transaction_date' => 'date', + ]; + + public function fund(): BelongsTo + { + return $this->belongsTo(PettyCashFund::class, 'fund_id'); + } + + public function createdBy(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function getIsDebitAttribute(): bool + { + return $this->type === 'expense' || $this->type === 'advance'; + } +} diff --git a/erp/app/Modules/Finance/Models/PriceList.php b/erp/app/Modules/Finance/Models/PriceList.php new file mode 100644 index 00000000000..3a2bd1df644 --- /dev/null +++ b/erp/app/Modules/Finance/Models/PriceList.php @@ -0,0 +1,73 @@ + 'float', + 'is_active' => 'boolean', + 'is_default' => 'boolean', + 'valid_from' => 'date', + 'valid_to' => 'date', + ]; + + public function items(): HasMany + { + return $this->hasMany(PriceListItem::class); + } + + public function contacts(): HasMany + { + return $this->hasMany(Contact::class, 'price_list_id'); + } + + public static function getDefault(int $tenantId): ?self + { + return static::where('is_default', true) + ->where('tenant_id', $tenantId) + ->first(); + } + + public function getPriceForProduct(int $productId, int $quantity = 1): ?float + { + $item = $this->items() + ->where('product_id', $productId) + ->where('min_quantity', '<=', $quantity) + ->orderBy('min_quantity', 'desc') + ->first(); + + return $item ? (float) $item->unit_price : null; + } + + public static function priceFor(int $priceListId, int $productId, float $defaultPrice): float + { + // First check for a product-specific override + $item = PriceListItem::where('price_list_id', $priceListId) + ->where('product_id', $productId) + ->first(); + if ($item) return (float) $item->unit_price; + + // Fall back to global discount + $list = static::find($priceListId); + if ($list && $list->discount_percent > 0) { + return round($defaultPrice * (1 - $list->discount_percent / 100), 4); + } + + return $defaultPrice; + } +} diff --git a/erp/app/Modules/Finance/Models/PriceListItem.php b/erp/app/Modules/Finance/Models/PriceListItem.php new file mode 100644 index 00000000000..a437d8cf276 --- /dev/null +++ b/erp/app/Modules/Finance/Models/PriceListItem.php @@ -0,0 +1,31 @@ + 'decimal:4', + 'min_quantity' => 'integer', + ]; + + public function priceList(): BelongsTo + { + return $this->belongsTo(PriceList::class); + } + + public function product(): BelongsTo + { + return $this->belongsTo(\App\Modules\Inventory\Models\Product::class); + } +} diff --git a/erp/app/Modules/Finance/Models/ProfitCenter.php b/erp/app/Modules/Finance/Models/ProfitCenter.php new file mode 100644 index 00000000000..42848f99454 --- /dev/null +++ b/erp/app/Modules/Finance/Models/ProfitCenter.php @@ -0,0 +1,79 @@ + 'float', + ]; + + protected $attributes = [ + 'type' => 'profit', + 'status' => 'active', + ]; + + public function parent(): BelongsTo + { + return $this->belongsTo(ProfitCenter::class, 'parent_id'); + } + + public function children(): HasMany + { + return $this->hasMany(ProfitCenter::class, 'parent_id'); + } + + public function manager(): BelongsTo + { + return $this->belongsTo(User::class, 'manager_id'); + } + + public function activate(): void + { + $this->status = 'active'; + $this->save(); + } + + public function deactivate(): void + { + $this->status = 'inactive'; + $this->save(); + } + + protected function isActive(): Attribute + { + return Attribute::make(get: fn () => $this->status === 'active'); + } + + protected function isCostCenter(): Attribute + { + return Attribute::make(get: fn () => $this->type === 'cost'); + } + + protected function isProfitCenter(): Attribute + { + return Attribute::make(get: fn () => $this->type === 'profit'); + } +} diff --git a/erp/app/Modules/Finance/Models/Project.php b/erp/app/Modules/Finance/Models/Project.php new file mode 100644 index 00000000000..1c7c750b43e --- /dev/null +++ b/erp/app/Modules/Finance/Models/Project.php @@ -0,0 +1,112 @@ + 'date', + 'end_date' => 'date', + 'budget' => 'decimal:2', + 'hourly_rate' => 'decimal:2', + 'status' => 'string', + 'billing_type' => 'string', + ]; + + // Relations + + public function contact(): BelongsTo + { + return $this->belongsTo(Contact::class); + } + + public function tasks(): HasMany + { + return $this->hasMany(ProjectTask::class); + } + + public function timeEntries(): HasMany + { + return $this->hasMany(ProjectTimeEntry::class); + } + + // Actions + + public function activate(): void + { + $this->status = 'active'; + $this->save(); + } + + public function complete(): void + { + $this->status = 'completed'; + $this->save(); + } + + // Accessors + + public function getTotalHoursAttribute(): float + { + if ($this->relationLoaded('timeEntries')) { + return (float) $this->timeEntries->sum('hours'); + } + return (float) $this->timeEntries()->sum('hours'); + } + + public function getTotalBilledAttribute(): float + { + if ($this->billing_type === 'hourly') { + return $this->total_hours * (float) ($this->hourly_rate ?? 0); + } + if ($this->billing_type === 'fixed') { + return (float) ($this->budget ?? 0); + } + return 0.0; + } + + public function getCompletionPercentAttribute(): float + { + if ($this->relationLoaded('tasks')) { + $total = $this->tasks->count(); + } else { + $total = $this->tasks()->count(); + } + + if ($total === 0) { + return 0.0; + } + + if ($this->relationLoaded('tasks')) { + $completed = $this->tasks->where('status', 'done')->count(); + } else { + $completed = $this->tasks()->where('status', 'done')->count(); + } + + return round(($completed / $total) * 100, 1); + } +} diff --git a/erp/app/Modules/Finance/Models/ProjectTask.php b/erp/app/Modules/Finance/Models/ProjectTask.php new file mode 100644 index 00000000000..091689a723b --- /dev/null +++ b/erp/app/Modules/Finance/Models/ProjectTask.php @@ -0,0 +1,63 @@ + 'date', + 'estimated_hours' => 'decimal:2', + 'actual_hours' => 'decimal:2', + ]; + + // Relations + + public function project(): BelongsTo + { + return $this->belongsTo(Project::class); + } + + public function assignedTo(): BelongsTo + { + return $this->belongsTo(User::class, 'assigned_to'); + } + + // Actions + + public function complete(): void + { + $this->status = 'done'; + $this->save(); + } + + // Accessors + + public function getIsOverdueAttribute(): bool + { + return $this->due_date !== null + && $this->due_date->lt(now()->startOfDay()) + && $this->status !== 'done'; + } +} diff --git a/erp/app/Modules/Finance/Models/ProjectTimeEntry.php b/erp/app/Modules/Finance/Models/ProjectTimeEntry.php new file mode 100644 index 00000000000..f65f834c329 --- /dev/null +++ b/erp/app/Modules/Finance/Models/ProjectTimeEntry.php @@ -0,0 +1,49 @@ + 'decimal:2', + 'entry_date' => 'date', + 'is_billable' => 'boolean', + ]; + + // Relations + + public function project(): BelongsTo + { + return $this->belongsTo(Project::class); + } + + public function task(): BelongsTo + { + return $this->belongsTo(ProjectTask::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/erp/app/Modules/Finance/Models/Quote.php b/erp/app/Modules/Finance/Models/Quote.php new file mode 100644 index 00000000000..5f217429ff0 --- /dev/null +++ b/erp/app/Modules/Finance/Models/Quote.php @@ -0,0 +1,67 @@ + 'date', + 'expiry_date' => 'date', + 'exchange_rate' => 'float', + ]; + + protected $attributes = ['status' => 'draft']; + + protected function getTransitions(): array + { + return [ + 'draft' => ['sent', 'cancelled'], + 'sent' => ['accepted', 'declined', 'cancelled'], + 'accepted' => [], + 'declined' => [], + 'cancelled' => [], + ]; + } + + public function getBaseTotalAttribute(): float + { + return round($this->total * (float) $this->exchange_rate, 2); + } + + public function contact(): BelongsTo + { + return $this->belongsTo(Contact::class); + } + + public function items(): HasMany + { + return $this->hasMany(QuoteItem::class); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } +} diff --git a/erp/app/Modules/Finance/Models/QuoteItem.php b/erp/app/Modules/Finance/Models/QuoteItem.php new file mode 100644 index 00000000000..90425b6539a --- /dev/null +++ b/erp/app/Modules/Finance/Models/QuoteItem.php @@ -0,0 +1,31 @@ + 'decimal:2', + 'unit_price' => 'decimal:2', + 'tax_rate' => 'decimal:2', + ]; + + public function quote(): BelongsTo + { + return $this->belongsTo(Quote::class); + } + + public function getLineTotalAttribute(): float + { + return (float) $this->quantity * (float) $this->unit_price * (1 + (float) $this->tax_rate / 100); + } +} diff --git a/erp/app/Modules/Finance/Models/RecurringExpense.php b/erp/app/Modules/Finance/Models/RecurringExpense.php new file mode 100644 index 00000000000..25cc18a778a --- /dev/null +++ b/erp/app/Modules/Finance/Models/RecurringExpense.php @@ -0,0 +1,102 @@ + 'decimal:2', + 'start_date' => 'date', + 'end_date' => 'date', + 'next_due_date' => 'date', + 'last_processed_date' => 'date', + ]; + + protected $attributes = [ + 'status' => 'active', + 'currency' => 'USD', + 'frequency' => 'monthly', + ]; + + // ─── Actions ────────────────────────────────────────────────────────────── + + public function pause(): void + { + $this->status = 'paused'; + $this->save(); + } + + public function resume(): void + { + $this->status = 'active'; + $this->save(); + } + + public function cancel(): void + { + $this->status = 'cancelled'; + $this->save(); + } + + public function generateExpenseNumber(): string + { + return 'RE-' . date('Y') . '-' . str_pad((string) $this->id, 5, '0', STR_PAD_LEFT); + } + + public function calculateNextDueDate(): void + { + $base = $this->last_processed_date + ? Carbon::parse($this->last_processed_date) + : Carbon::parse($this->start_date); + + $next = match ($this->frequency) { + 'monthly' => $base->copy()->addMonth(), + 'quarterly' => $base->copy()->addMonths(3), + 'annual' => $base->copy()->addYear(), + 'weekly' => $base->copy()->addWeek(), + default => $base->copy()->addMonth(), + }; + + $this->next_due_date = $next; + $this->save(); + } + + // ─── Accessors ──────────────────────────────────────────────────────────── + + public function getIsActiveAttribute(): bool + { + return $this->status === 'active'; + } + + public function getIsDueAttribute(): bool + { + return $this->is_active + && $this->next_due_date !== null + && $this->next_due_date->lte(Carbon::today()); + } +} diff --git a/erp/app/Modules/Finance/Models/RecurringInvoice.php b/erp/app/Modules/Finance/Models/RecurringInvoice.php new file mode 100644 index 00000000000..f927683c3a5 --- /dev/null +++ b/erp/app/Modules/Finance/Models/RecurringInvoice.php @@ -0,0 +1,153 @@ + 'date', + 'next_run_date' => 'date', + 'end_date' => 'date', + 'auto_send' => 'boolean', + 'last_generated_at' => 'datetime', + ]; + + protected $attributes = [ + 'status' => 'active', + 'frequency' => 'monthly', + 'interval' => 1, + 'reference_prefix' => 'REC-INV', + 'currency_code' => 'USD', + 'exchange_rate' => 1, + 'due_days' => 30, + 'generated_count' => 0, + 'auto_send' => false, + ]; + + public function contact(): BelongsTo + { + return $this->belongsTo(Contact::class); + } + + public function items(): HasMany + { + return $this->hasMany(RecurringInvoiceItem::class); + } + + public function invoices(): HasMany + { + return $this->hasMany(Invoice::class, 'recurring_invoice_id'); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + /** + * Compute the next run date relative to a given Carbon date, + * respecting both the frequency and the interval multiplier. + */ + public function computeNextRunDate(\Carbon\Carbon $from): \Carbon\Carbon + { + $n = (int) ($this->interval ?? 1); + + return match ($this->frequency) { + 'weekly' => $from->copy()->addWeeks($n), + 'quarterly' => $from->copy()->addMonthsNoOverflow($n * 3), + 'yearly' => $from->copy()->addYears($n), + default => $from->copy()->addMonthsNoOverflow($n), + }; + } + + public function scopeDue($query) + { + return $query->where('status', 'active') + ->whereDate('next_run_date', '<=', now()->toDateString()); + } + + protected function intervalAdvance(\Carbon\Carbon $date): \Carbon\Carbon + { + return $this->computeNextRunDate($date); + } + + public function advanceSchedule(): void + { + $next = $this->computeNextRunDate(\Carbon\Carbon::parse($this->next_run_date)); + + if ($this->end_date && $next->gt(\Carbon\Carbon::parse($this->end_date))) { + $this->status = 'ended'; + } else { + $this->next_run_date = $next->toDateString(); + } + + $this->save(); + } + + public function generateInvoice(): Invoice + { + return DB::transaction(function () { + if (! $this->relationLoaded('items')) { + $this->load('items'); + } + + $nextCount = $this->generated_count + 1; + $prefix = $this->reference_prefix ?: 'REC-INV'; + $refNumber = "{$prefix}-{$nextCount}"; + + $invoice = Invoice::create([ + 'tenant_id' => $this->tenant_id, + 'recurring_invoice_id' => $this->id, + 'contact_id' => $this->contact_id, + 'number' => $refNumber, + 'issue_date' => now()->toDateString(), + 'due_date' => now()->addDays($this->due_days)->toDateString(), + 'status' => $this->auto_send ? 'sent' : 'draft', + 'currency_code' => $this->currency_code ?? 'USD', + 'exchange_rate' => $this->exchange_rate ?? 1, + 'notes' => $this->notes, + 'created_by' => $this->created_by, + ]); + + foreach ($this->items as $item) { + InvoiceItem::create([ + 'invoice_id' => $invoice->id, + 'description' => $item->description, + 'quantity' => $item->quantity, + 'unit_price' => $item->unit_price, + 'tax_rate' => $item->tax_rate, + ]); + } + + $this->generated_count = $nextCount; + $this->last_generated_at = now(); + $this->save(); + + $this->advanceSchedule(); + + return $invoice; + }); + } +} diff --git a/erp/app/Modules/Finance/Models/RecurringInvoiceItem.php b/erp/app/Modules/Finance/Models/RecurringInvoiceItem.php new file mode 100644 index 00000000000..adcbb5a27d5 --- /dev/null +++ b/erp/app/Modules/Finance/Models/RecurringInvoiceItem.php @@ -0,0 +1,31 @@ + 'decimal:2', + 'unit_price' => 'decimal:2', + 'tax_rate' => 'decimal:2', + ]; + + public function recurringInvoice(): BelongsTo + { + return $this->belongsTo(RecurringInvoice::class); + } + + public function getLineTotalAttribute(): float + { + return (float) $this->quantity * (float) $this->unit_price * (1 + (float) $this->tax_rate / 100); + } +} diff --git a/erp/app/Modules/Finance/Models/ReturnRequest.php b/erp/app/Modules/Finance/Models/ReturnRequest.php new file mode 100644 index 00000000000..a754e992b13 --- /dev/null +++ b/erp/app/Modules/Finance/Models/ReturnRequest.php @@ -0,0 +1,78 @@ + 'datetime', + 'refunded_at' => 'datetime', + 'refund_amount' => 'decimal:2', + ]; + + public function invoice(): BelongsTo + { + return $this->belongsTo(Invoice::class); + } + + public function contact(): BelongsTo + { + return $this->belongsTo(Contact::class); + } + + public function approvedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'approved_by'); + } + + public function items(): HasMany + { + return $this->hasMany(ReturnRequestItem::class); + } + + public function approve(User $user): void + { + $this->status = 'approved'; + $this->approved_by = $user->id; + $this->approved_at = now(); + $this->save(); + } + + public function reject(): void + { + $this->status = 'rejected'; + $this->save(); + } + + public function markRefunded(): void + { + $this->status = 'refunded'; + $this->refunded_at = now(); + $this->save(); + } + + public function getTotalRequestedAttribute(): float + { + if ($this->relationLoaded('items')) { + return (float) $this->items->sum(fn ($item) => $item->quantity * $item->unit_price); + } + return 0.0; + } +} diff --git a/erp/app/Modules/Finance/Models/ReturnRequestItem.php b/erp/app/Modules/Finance/Models/ReturnRequestItem.php new file mode 100644 index 00000000000..ec845eaf1ca --- /dev/null +++ b/erp/app/Modules/Finance/Models/ReturnRequestItem.php @@ -0,0 +1,34 @@ + 'integer', + 'unit_price' => 'decimal:2', + ]; + + public function returnRequest(): BelongsTo + { + return $this->belongsTo(ReturnRequest::class); + } + + public function invoiceItem(): BelongsTo + { + return $this->belongsTo(InvoiceItem::class); + } +} diff --git a/erp/app/Modules/Finance/Models/SalesOrder.php b/erp/app/Modules/Finance/Models/SalesOrder.php new file mode 100644 index 00000000000..78d21eb3a15 --- /dev/null +++ b/erp/app/Modules/Finance/Models/SalesOrder.php @@ -0,0 +1,154 @@ + 'date', + 'expected_date' => 'date', + ]; + + protected $attributes = ['status' => 'draft']; + + protected function getTransitions(): array + { + return [ + 'draft' => ['confirmed', 'cancelled'], + 'confirmed' => ['fulfilled', 'invoiced', 'cancelled'], + 'fulfilled' => ['invoiced'], + 'invoiced' => [], + 'cancelled' => [], + ]; + } + + public function contact(): BelongsTo + { + return $this->belongsTo(Contact::class); + } + + public function warehouse(): BelongsTo + { + return $this->belongsTo(\App\Modules\Inventory\Models\Warehouse::class); + } + + public function invoice(): BelongsTo + { + return $this->belongsTo(Invoice::class); + } + + public function generatedInvoice(): HasOne + { + return $this->hasOne(Invoice::class, 'sales_order_id'); + } + + public function items(): HasMany + { + return $this->hasMany(SalesOrderItem::class); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + /** Convert this confirmed SO to a new Invoice and mark SO as invoiced. */ + public function convertToInvoice(): Invoice + { + abort_unless($this->status === 'confirmed', 422, 'Only confirmed orders can be invoiced.'); + $this->load('items'); + + $ref = $this->reference ?? $this->number; + $invoiceRef = $ref ? 'INV-' . preg_replace('/^SO-/', '', $ref) : null; + + $invoice = Invoice::create([ + 'tenant_id' => $this->tenant_id, + 'sales_order_id' => $this->id, + 'contact_id' => $this->contact_id, + 'issue_date' => now()->toDateString(), + 'due_date' => now()->addDays(30)->toDateString(), + 'status' => 'draft', + 'notes' => $this->notes, + 'created_by' => auth()->id(), + 'currency_code' => $this->currency_code ?? 'USD', + 'exchange_rate' => $this->exchange_rate ?? 1, + ]); + + if ($invoiceRef) { + $invoice->update(['number' => $invoiceRef]); + } else { + $invoice->update([ + 'number' => 'INV-' . now()->format('Y') . '-' . str_pad((string) $invoice->id, 5, '0', STR_PAD_LEFT), + ]); + } + + foreach ($this->items as $item) { + $invoice->items()->create([ + 'description' => $item->description, + 'quantity' => $item->quantity, + 'unit_price' => $item->unit_price, + 'tax_rate' => $item->tax_rate, + ]); + } + + $this->update(['status' => 'invoiced', 'invoice_id' => $invoice->id]); + + return $invoice; + } + + public function fulfill(): void + { + if (! $this->canTransitionTo('fulfilled')) { + throw new \DomainException("Sales order cannot be fulfilled in status '{$this->status}'."); + } + + if (! $this->warehouse_id) { + throw new \DomainException('A warehouse is required to fulfill this order.'); + } + + \Illuminate\Support\Facades\DB::transaction(function () { + foreach ($this->items as $item) { + if (! $item->product_id) { + $item->update(['quantity_fulfilled' => $item->quantity]); + continue; + } + + \App\Modules\Inventory\Models\StockMovement::record([ + 'product_id' => $item->product_id, + 'warehouse_id' => $this->warehouse_id, + 'type' => 'out', + 'quantity' => (float) $item->quantity, + 'reference' => $this->number, + 'notes' => "Fulfilled SO #{$this->id}", + ]); + + $item->update(['quantity_fulfilled' => $item->quantity]); + } + + $this->update(['status' => 'fulfilled']); + }); + } +} diff --git a/erp/app/Modules/Finance/Models/SalesOrderItem.php b/erp/app/Modules/Finance/Models/SalesOrderItem.php new file mode 100644 index 00000000000..bea01e65ee5 --- /dev/null +++ b/erp/app/Modules/Finance/Models/SalesOrderItem.php @@ -0,0 +1,38 @@ + 'decimal:2', + 'unit_price' => 'decimal:2', + 'tax_rate' => 'decimal:2', + 'quantity_fulfilled' => 'decimal:2', + ]; + + public function salesOrder(): BelongsTo + { + return $this->belongsTo(SalesOrder::class); + } + + public function product(): BelongsTo + { + return $this->belongsTo(\App\Modules\Inventory\Models\Product::class); + } + + public function getLineTotalAttribute(): float + { + return (float) $this->quantity * (float) $this->unit_price * (1 + (float) $this->tax_rate / 100); + } +} diff --git a/erp/app/Modules/Finance/Models/ServiceAgreement.php b/erp/app/Modules/Finance/Models/ServiceAgreement.php new file mode 100644 index 00000000000..12f155613ce --- /dev/null +++ b/erp/app/Modules/Finance/Models/ServiceAgreement.php @@ -0,0 +1,84 @@ + 'date', + 'end_date' => 'date', + 'signed_at' => 'date', + 'value' => 'decimal:2', + 'auto_renew' => 'boolean', + ]; + + public function contact(): BelongsTo + { + return $this->belongsTo(Contact::class); + } + + public function serviceItems(): HasMany + { + return $this->hasMany(ServiceAgreementItem::class); + } + + public function maintenanceLogs(): HasMany + { + return $this->hasMany(MaintenanceLog::class); + } + + public function activate(): void + { + $this->status = 'active'; + $this->save(); + } + + public function terminate(): void + { + $this->status = 'terminated'; + $this->save(); + } + + public function getIsExpiredAttribute(): bool + { + return $this->end_date !== null + && $this->end_date->isPast() + && $this->status !== 'terminated'; + } + + public function getIsExpiringAttribute(): bool + { + return $this->end_date !== null + && $this->end_date->diffInDays(now()) <= 30 + && $this->end_date->isFuture() + && $this->status === 'active'; + } + + public function getDaysRemainingAttribute(): ?int + { + if ($this->end_date === null) { + return null; + } + if ($this->end_date->isPast()) { + return 0; + } + return (int) now()->diffInDays($this->end_date); + } +} diff --git a/erp/app/Modules/Finance/Models/ServiceAgreementItem.php b/erp/app/Modules/Finance/Models/ServiceAgreementItem.php new file mode 100644 index 00000000000..fd8afe13209 --- /dev/null +++ b/erp/app/Modules/Finance/Models/ServiceAgreementItem.php @@ -0,0 +1,36 @@ + 'integer', + 'unit_price' => 'decimal:2', + 'total_price' => 'decimal:2', + ]; + + public function agreement(): BelongsTo + { + return $this->belongsTo(ServiceAgreement::class, 'service_agreement_id'); + } + + public function calculateTotal(): void + { + $this->total_price = $this->quantity * $this->unit_price; + $this->save(); + } +} diff --git a/erp/app/Modules/Finance/Models/Subscription.php b/erp/app/Modules/Finance/Models/Subscription.php new file mode 100644 index 00000000000..8b6771bd21c --- /dev/null +++ b/erp/app/Modules/Finance/Models/Subscription.php @@ -0,0 +1,92 @@ + 'date', + 'current_period_start' => 'date', + 'current_period_end' => 'date', + 'cancelled_at' => 'datetime', + ]; + + public function plan(): BelongsTo + { + return $this->belongsTo(SubscriptionPlan::class, 'plan_id'); + } + + public function activate(): void + { + $today = Carbon::today()->toDateString(); + $this->status = 'active'; + $this->current_period_start = $today; + $this->current_period_end = $this->plan->getNextBillingDate($today); + $this->save(); + } + + public function cancel(): void + { + $this->status = 'cancelled'; + $this->cancelled_at = Carbon::now(); + $this->save(); + } + + public function pause(): void + { + $this->status = 'paused'; + $this->save(); + } + + public function generateInvoice(): Invoice + { + $today = Carbon::today()->toDateString(); + $plan = $this->plan; + + $periodStart = $this->current_period_start + ? $this->current_period_start->toDateString() + : $today; + $periodEnd = $this->current_period_end + ? $this->current_period_end->toDateString() + : $plan->getNextBillingDate($today); + + $period = "{$periodStart} - {$periodEnd}"; + + $invoice = DB::transaction(function () use ($today, $plan, $period) { + $inv = Invoice::create([ + 'tenant_id' => $this->tenant_id, + 'status' => 'draft', + 'issue_date' => $today, + 'due_date' => Carbon::today()->addDays(30)->toDateString(), + ]); + + InvoiceItem::create([ + 'invoice_id' => $inv->id, + 'description' => "Subscription: {$plan->name} ({$period})", + 'quantity' => 1, + 'unit_price' => $plan->price, + 'tax_rate' => 0, + ]); + + return $inv; + }); + + return $invoice; + } +} diff --git a/erp/app/Modules/Finance/Models/SubscriptionPlan.php b/erp/app/Modules/Finance/Models/SubscriptionPlan.php new file mode 100644 index 00000000000..332356ac936 --- /dev/null +++ b/erp/app/Modules/Finance/Models/SubscriptionPlan.php @@ -0,0 +1,41 @@ + 'decimal:2', + 'is_active' => 'boolean', + ]; + + public function subscriptions(): HasMany + { + return $this->hasMany(Subscription::class); + } + + public function getNextBillingDate(string $from): string + { + return match ($this->billing_cycle) { + 'monthly' => Carbon::parse($from)->addMonth()->toDateString(), + 'quarterly' => Carbon::parse($from)->addMonths(3)->toDateString(), + 'annual' => Carbon::parse($from)->addYear()->toDateString(), + 'annually' => Carbon::parse($from)->addYear()->toDateString(), + default => Carbon::parse($from)->addMonth()->toDateString(), + }; + } +} diff --git a/erp/app/Modules/Finance/Models/SupportTicket.php b/erp/app/Modules/Finance/Models/SupportTicket.php new file mode 100644 index 00000000000..1705d6c0cde --- /dev/null +++ b/erp/app/Modules/Finance/Models/SupportTicket.php @@ -0,0 +1,111 @@ + 'datetime', + 'closed_at' => 'datetime', + ]; + + public function comments(): HasMany + { + return $this->hasMany(TicketComment::class); + } + + public function assignedTo(): BelongsTo + { + return $this->belongsTo(User::class, 'assigned_to'); + } + + public function createdBy(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function contact(): BelongsTo + { + return $this->belongsTo(Contact::class, 'contact_id'); + } + + public static function generateReference(int $tenantId): string + { + return 'TKT-' . str_pad( + SupportTicket::where('tenant_id', $tenantId)->count() + 1, + 4, + '0', + STR_PAD_LEFT + ); + } + + public function resolve(): void + { + $this->status = 'resolved'; + $this->resolved_at = now(); + $this->save(); + } + + public function close(): void + { + $this->status = 'closed'; + $this->closed_at = now(); + $this->save(); + } + + public function reopen(): void + { + $this->status = 'open'; + $this->resolved_at = null; + $this->closed_at = null; + $this->save(); + } + + public function assign(int $userId): void + { + $this->assigned_to = $userId; + if ($this->status === 'open') { + $this->status = 'in_progress'; + } + $this->save(); + } + + public function getIsOpenAttribute(): bool + { + return $this->status === 'open' || $this->status === 'in_progress'; + } + + public function getResponseTimeHoursAttribute(): ?float + { + if ($this->resolved_at === null) { + return null; + } + + return round($this->created_at->diffInMinutes($this->resolved_at) / 60, 1); + } +} diff --git a/erp/app/Modules/Finance/Models/TaxGroup.php b/erp/app/Modules/Finance/Models/TaxGroup.php new file mode 100644 index 00000000000..1bada9b481c --- /dev/null +++ b/erp/app/Modules/Finance/Models/TaxGroup.php @@ -0,0 +1,59 @@ + 'boolean', + ]; + + public function items(): HasMany + { + return $this->hasMany(TaxGroupItem::class); + } + + public function calculateTotalTax(float $amount): float + { + if (!$this->relationLoaded('items')) { + $this->load('items.taxRate'); + } + + $sum = 0.0; + foreach ($this->items as $item) { + if ($item->taxRate) { + $sum += $item->taxRate->calculateTax($amount); + } + } + return (float) $sum; + } + + public function getTotalRateAttribute(): float + { + if (!$this->relationLoaded('items')) { + $this->load('items.taxRate'); + } + + $sum = 0.0; + foreach ($this->items as $item) { + if ($item->taxRate) { + $sum += (float) $item->taxRate->rate; + } + } + return $sum; + } +} diff --git a/erp/app/Modules/Finance/Models/TaxGroupItem.php b/erp/app/Modules/Finance/Models/TaxGroupItem.php new file mode 100644 index 00000000000..3911c3089bf --- /dev/null +++ b/erp/app/Modules/Finance/Models/TaxGroupItem.php @@ -0,0 +1,28 @@ +belongsTo(TaxGroup::class); + } + + public function taxRate(): BelongsTo + { + return $this->belongsTo(TaxRate::class); + } +} diff --git a/erp/app/Modules/Finance/Models/TaxRate.php b/erp/app/Modules/Finance/Models/TaxRate.php new file mode 100644 index 00000000000..e586f54ea56 --- /dev/null +++ b/erp/app/Modules/Finance/Models/TaxRate.php @@ -0,0 +1,58 @@ + 'decimal:4', + 'is_compound' => 'boolean', + 'is_active' => 'boolean', + ]; + + public function account(): BelongsTo + { + return $this->belongsTo(Account::class); + } + + public function taxGroupItems(): HasMany + { + return $this->hasMany(TaxGroupItem::class); + } + + public function calculateTax(float $amount): float + { + return round($amount * $this->rate / 100, 4); + } + + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + public function scopeForSales($query) + { + return $query->whereIn('tax_type', ['sales', 'both']); + } + + public function scopeForPurchase($query) + { + return $query->whereIn('tax_type', ['purchase', 'both']); + } +} diff --git a/erp/app/Modules/Finance/Models/TicketComment.php b/erp/app/Modules/Finance/Models/TicketComment.php new file mode 100644 index 00000000000..114646f14aa --- /dev/null +++ b/erp/app/Modules/Finance/Models/TicketComment.php @@ -0,0 +1,35 @@ + 'boolean', + ]; + + public function ticket(): BelongsTo + { + return $this->belongsTo(SupportTicket::class); + } + + public function createdBy(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } +} diff --git a/erp/app/Modules/Finance/Models/VendorBill.php b/erp/app/Modules/Finance/Models/VendorBill.php new file mode 100644 index 00000000000..4cb4fee19b6 --- /dev/null +++ b/erp/app/Modules/Finance/Models/VendorBill.php @@ -0,0 +1,112 @@ + 'date', + 'due_date' => 'date', + 'approved_at' => 'datetime', + 'paid_at' => 'datetime', + 'subtotal' => 'float', + 'tax' => 'float', + 'total' => 'float', + ]; + + public function items(): HasMany + { + return $this->hasMany(VendorBillItem::class); + } + + public function supplier(): BelongsTo + { + return $this->belongsTo(Supplier::class); + } + + public function createdBy(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + public static function generateBillNumber(): string + { + return 'BILL-' . strtoupper(uniqid()); + } + + public function recalculateTotals(): void + { + $this->subtotal = $this->items()->get()->sum(fn ($item) => $item->quantity * $item->unit_price); + $this->total = $this->subtotal + $this->tax; + $this->save(); + } + + public function submit(): void + { + $this->status = 'pending'; + $this->save(); + } + + public function approve(int $userId): void + { + $this->status = 'approved'; + $this->approved_by = $userId; + $this->approved_at = now(); + $this->save(); + } + + public function pay(): void + { + $this->status = 'paid'; + $this->paid_at = now(); + $this->save(); + } + + public function cancel(): void + { + $this->status = 'cancelled'; + $this->save(); + } + + public function getIsOverdueAttribute(): bool + { + return !in_array($this->status, ['paid', 'cancelled']) + && $this->due_date !== null + && $this->due_date->lt(now()->startOfDay()); + } + + public function getIsOpenAttribute(): bool + { + return in_array($this->status, ['draft', 'pending', 'approved']); + } +} diff --git a/erp/app/Modules/Finance/Models/VendorBillItem.php b/erp/app/Modules/Finance/Models/VendorBillItem.php new file mode 100644 index 00000000000..495a1da0497 --- /dev/null +++ b/erp/app/Modules/Finance/Models/VendorBillItem.php @@ -0,0 +1,39 @@ + 'float', + 'unit_price' => 'float', + ]; + + public function vendorBill(): BelongsTo + { + return $this->belongsTo(VendorBill::class); + } + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + public function getLineTotalAttribute(): float + { + return $this->quantity * $this->unit_price; + } +} diff --git a/erp/app/Modules/Finance/Models/VendorEvaluation.php b/erp/app/Modules/Finance/Models/VendorEvaluation.php new file mode 100644 index 00000000000..d6d5a11b523 --- /dev/null +++ b/erp/app/Modules/Finance/Models/VendorEvaluation.php @@ -0,0 +1,43 @@ + 'date', + 'overall_rating' => 'decimal:2', + ]; + + public function contact(): BelongsTo + { + return $this->belongsTo(Contact::class); + } + + public function evaluator(): BelongsTo + { + return $this->belongsTo(User::class, 'evaluated_by'); + } +} diff --git a/erp/app/Modules/Finance/Models/VendorPayment.php b/erp/app/Modules/Finance/Models/VendorPayment.php new file mode 100644 index 00000000000..0ef21adc2fa --- /dev/null +++ b/erp/app/Modules/Finance/Models/VendorPayment.php @@ -0,0 +1,124 @@ + 'pending', + 'currency' => 'USD', + 'payment_method' => 'bank_transfer', + ]; + + protected $casts = [ + 'amount' => 'decimal:2', + 'payment_date' => 'date', + 'due_date' => 'date', + 'approved_at' => 'datetime', + 'processed_at' => 'datetime', + ]; + + // ── Relationships ───────────────────────────────────────────────────────── + + public function approvedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'approved_by'); + } + + public function createdBy(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + // ── Status transitions ──────────────────────────────────────────────────── + + public function approve(int $userId): void + { + $this->status = 'approved'; + $this->approved_by = $userId; + $this->approved_at = now(); + + if (is_null($this->payment_number)) { + $this->payment_number = $this->generatePaymentNumber(); + } + + $this->save(); + } + + public function process(): void + { + $this->status = 'processed'; + $this->processed_at = now(); + $this->save(); + } + + public function reject(): void + { + $this->status = 'rejected'; + $this->save(); + } + + public function cancel(): void + { + $this->status = 'cancelled'; + $this->save(); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + public function generatePaymentNumber(): string + { + return 'VP-' . date('Y') . '-' . str_pad((string) $this->id, 5, '0', STR_PAD_LEFT); + } + + // ── Accessors ───────────────────────────────────────────────────────────── + + public function getIsPendingAttribute(): bool + { + return $this->status === 'pending'; + } + + public function getIsApprovedAttribute(): bool + { + return $this->status === 'approved'; + } + + public function getIsProcessedAttribute(): bool + { + return $this->status === 'processed'; + } + + public function getIsOverdueAttribute(): bool + { + return $this->is_pending + && $this->due_date !== null + && $this->due_date->lt(now()->startOfDay()); + } +} diff --git a/erp/app/Modules/Finance/Models/VendorProfile.php b/erp/app/Modules/Finance/Models/VendorProfile.php new file mode 100644 index 00000000000..3a3489f3331 --- /dev/null +++ b/erp/app/Modules/Finance/Models/VendorProfile.php @@ -0,0 +1,57 @@ + 'decimal:2', + ]; + + public function contact(): BelongsTo + { + return $this->belongsTo(Contact::class); + } + + public function getIsOverCreditLimitAttribute(): bool + { + if (is_null($this->credit_limit)) { + return false; + } + + if (! $this->relationLoaded('contact')) { + $this->load('contact'); + } + + if (! $this->contact) { + return false; + } + + $bills = $this->contact->bills() + ->whereIn('status', ['received', 'partial']) + ->with(['items', 'payments']) + ->get(); + + $outstanding = $bills->sum(fn ($bill) => $bill->amount_due); + + return $outstanding > (float) $this->credit_limit; + } +} diff --git a/erp/app/Modules/Finance/Models/WriteOff.php b/erp/app/Modules/Finance/Models/WriteOff.php new file mode 100644 index 00000000000..65420100667 --- /dev/null +++ b/erp/app/Modules/Finance/Models/WriteOff.php @@ -0,0 +1,88 @@ + 'pending', + 'currency' => 'USD', + ]; + + protected $casts = [ + 'amount' => 'float', + 'write_off_date' => 'date', + 'approved_at' => 'datetime', + ]; + + // Methods + + public function generateWriteOffNumber(): string + { + return 'WO-' . date('Y') . '-' . str_pad((string) $this->id, 5, '0', STR_PAD_LEFT); + } + + public function approve(int $userId): void + { + $this->status = 'approved'; + $this->approved_by = $userId; + $this->approved_at = now(); + if (is_null($this->write_off_number)) { + $this->write_off_number = $this->generateWriteOffNumber(); + } + $this->save(); + } + + public function reverse(): void + { + $this->status = 'reversed'; + $this->save(); + } + + // Accessors + + public function getIsPendingAttribute(): bool + { + return $this->status === 'pending'; + } + + public function getIsApprovedAttribute(): bool + { + return $this->status === 'approved'; + } + + // Relations + + public function customer(): BelongsTo + { + return $this->belongsTo(Contact::class, 'customer_id'); + } + + public function invoice(): BelongsTo + { + return $this->belongsTo(Invoice::class, 'invoice_id'); + } +} diff --git a/erp/app/Modules/Finance/Policies/AccountPolicy.php b/erp/app/Modules/Finance/Policies/AccountPolicy.php new file mode 100644 index 00000000000..97f461d6e57 --- /dev/null +++ b/erp/app/Modules/Finance/Policies/AccountPolicy.php @@ -0,0 +1,15 @@ +can('finance.view'); } + public function view(User $user, Account $account): bool { return $user->can('finance.view'); } + public function create(User $user): bool { return $user->can('finance.create'); } + public function update(User $user, Account $account): bool { return $user->can('finance.update'); } + public function delete(User $user, Account $account): bool { return $user->can('finance.delete'); } +} diff --git a/erp/app/Modules/Finance/Policies/AdvancePaymentPolicy.php b/erp/app/Modules/Finance/Policies/AdvancePaymentPolicy.php new file mode 100644 index 00000000000..c299f30895a --- /dev/null +++ b/erp/app/Modules/Finance/Policies/AdvancePaymentPolicy.php @@ -0,0 +1,34 @@ +can('finance.view'); + } + + public function view(User $user, AdvancePayment $advancePayment): bool + { + return $user->can('finance.view'); + } + + public function create(User $user): bool + { + return $user->can('finance.create'); + } + + public function update(User $user, AdvancePayment $advancePayment): bool + { + return $user->can('finance.create'); + } + + public function delete(User $user, AdvancePayment $advancePayment): bool + { + return $user->can('finance.delete'); + } +} diff --git a/erp/app/Modules/Finance/Policies/AttachmentPolicy.php b/erp/app/Modules/Finance/Policies/AttachmentPolicy.php new file mode 100644 index 00000000000..7c2a53d47d1 --- /dev/null +++ b/erp/app/Modules/Finance/Policies/AttachmentPolicy.php @@ -0,0 +1,14 @@ +can('finance.view'); } + public function view(User $user, Attachment $attachment): bool { return $user->can('finance.view'); } + public function create(User $user): bool { return $user->can('finance.create'); } + public function delete(User $user, Attachment $attachment): bool { return $user->can('finance.delete'); } +} diff --git a/erp/app/Modules/Finance/Policies/BankAccountPolicy.php b/erp/app/Modules/Finance/Policies/BankAccountPolicy.php new file mode 100644 index 00000000000..571d10d49ed --- /dev/null +++ b/erp/app/Modules/Finance/Policies/BankAccountPolicy.php @@ -0,0 +1,15 @@ +can('finance.view'); } + public function view(User $user, BankAccount $bankAccount): bool { return $user->can('finance.view'); } + public function create(User $user): bool { return $user->can('finance.create'); } + public function update(User $user, BankAccount $bankAccount): bool { return $user->can('finance.create'); } + public function delete(User $user, BankAccount $bankAccount): bool { return $user->can('finance.delete'); } +} diff --git a/erp/app/Modules/Finance/Policies/BankPolicy.php b/erp/app/Modules/Finance/Policies/BankPolicy.php new file mode 100644 index 00000000000..77793246f12 --- /dev/null +++ b/erp/app/Modules/Finance/Policies/BankPolicy.php @@ -0,0 +1,14 @@ +hasPermissionTo('finance.view'); } + public function view(User $user, $model): bool { return $user->hasPermissionTo('finance.view'); } + public function create(User $user): bool { return $user->hasPermissionTo('finance.create'); } + public function update(User $user, $model): bool { return $user->hasPermissionTo('finance.create'); } + public function delete(User $user, $model): bool { return $user->hasPermissionTo('finance.delete'); } +} diff --git a/erp/app/Modules/Finance/Policies/BankTransactionPolicy.php b/erp/app/Modules/Finance/Policies/BankTransactionPolicy.php new file mode 100644 index 00000000000..29250b8562f --- /dev/null +++ b/erp/app/Modules/Finance/Policies/BankTransactionPolicy.php @@ -0,0 +1,15 @@ +can('finance.view'); } + public function view(User $user, BankTransaction $bankTransaction): bool { return $user->can('finance.view'); } + public function create(User $user): bool { return $user->can('finance.create'); } + public function update(User $user, BankTransaction $bankTransaction): bool { return $user->can('finance.create'); } + public function delete(User $user, BankTransaction $bankTransaction): bool { return $user->can('finance.delete'); } +} diff --git a/erp/app/Modules/Finance/Policies/BankTransferPolicy.php b/erp/app/Modules/Finance/Policies/BankTransferPolicy.php new file mode 100644 index 00000000000..88a5058e53b --- /dev/null +++ b/erp/app/Modules/Finance/Policies/BankTransferPolicy.php @@ -0,0 +1,14 @@ +can('finance.view'); } + public function view(User $user, BankTransfer $bankTransfer): bool { return $user->can('finance.view'); } + public function create(User $user): bool { return $user->can('finance.create'); } + public function delete(User $user, BankTransfer $bankTransfer): bool { return $user->can('finance.delete'); } +} diff --git a/erp/app/Modules/Finance/Policies/BatchPaymentPolicy.php b/erp/app/Modules/Finance/Policies/BatchPaymentPolicy.php new file mode 100644 index 00000000000..a66e295e99f --- /dev/null +++ b/erp/app/Modules/Finance/Policies/BatchPaymentPolicy.php @@ -0,0 +1,29 @@ +can('finance.view'); + } + + public function view(User $user, BatchPayment $batchPayment): bool + { + return $user->can('finance.view'); + } + + public function create(User $user): bool + { + return $user->can('finance.create'); + } + + public function delete(User $user, BatchPayment $batchPayment): bool + { + return $user->can('finance.delete'); + } +} diff --git a/erp/app/Modules/Finance/Policies/BillPolicy.php b/erp/app/Modules/Finance/Policies/BillPolicy.php new file mode 100644 index 00000000000..02a46866efb --- /dev/null +++ b/erp/app/Modules/Finance/Policies/BillPolicy.php @@ -0,0 +1,17 @@ +can('finance.view'); } + public function view(User $user, Bill $bill): bool { return $user->can('finance.view'); } + public function create(User $user): bool { return $user->can('finance.create'); } + public function update(User $user, Bill $bill): bool { return $user->can('finance.update'); } + public function delete(User $user, Bill $bill): bool { + return $user->can('finance.delete') && $bill->status === 'draft'; + } +} diff --git a/erp/app/Modules/Finance/Policies/BudgetPolicy.php b/erp/app/Modules/Finance/Policies/BudgetPolicy.php new file mode 100644 index 00000000000..e49970a1109 --- /dev/null +++ b/erp/app/Modules/Finance/Policies/BudgetPolicy.php @@ -0,0 +1,43 @@ +hasPermissionTo('finance.view'); + } + + public function view(User $user, $model): bool + { + return $user->hasPermissionTo('finance.view'); + } + + public function create(User $user): bool + { + return $user->hasPermissionTo('finance.create'); + } + + public function update(User $user, $model): bool + { + return $user->hasPermissionTo('finance.create'); + } + + public function delete(User $user, $model): bool + { + return $user->hasPermissionTo('finance.delete'); + } + + public function activate(User $user, $model): bool + { + return $user->hasPermissionTo('finance.create'); + } + + public function close(User $user, $model): bool + { + return $user->hasPermissionTo('finance.create'); + } +} diff --git a/erp/app/Modules/Finance/Policies/CashFlowForecastPolicy.php b/erp/app/Modules/Finance/Policies/CashFlowForecastPolicy.php new file mode 100644 index 00000000000..735b3f15678 --- /dev/null +++ b/erp/app/Modules/Finance/Policies/CashFlowForecastPolicy.php @@ -0,0 +1,43 @@ +hasPermissionTo('finance.view'); + } + + public function view(User $user, $model): bool + { + return $user->hasPermissionTo('finance.view'); + } + + public function create(User $user): bool + { + return $user->hasPermissionTo('finance.create'); + } + + public function update(User $user, $model): bool + { + return $user->hasPermissionTo('finance.create'); + } + + public function publish(User $user, $model): bool + { + return $user->hasPermissionTo('finance.create'); + } + + public function archive(User $user, $model): bool + { + return $user->hasPermissionTo('finance.create'); + } + + public function delete(User $user, $model): bool + { + return $user->hasPermissionTo('finance.delete'); + } +} diff --git a/erp/app/Modules/Finance/Policies/CommissionPolicy.php b/erp/app/Modules/Finance/Policies/CommissionPolicy.php new file mode 100644 index 00000000000..0aba8c04df8 --- /dev/null +++ b/erp/app/Modules/Finance/Policies/CommissionPolicy.php @@ -0,0 +1,15 @@ +can('finance.view'); } + public function view(User $user, Commission $commission): bool { return $user->can('finance.view'); } + public function create(User $user): bool { return $user->can('finance.create'); } + public function update(User $user, Commission $commission): bool { return $user->can('finance.create'); } + public function delete(User $user, Commission $commission): bool { return $user->can('finance.delete'); } +} diff --git a/erp/app/Modules/Finance/Policies/CommissionRulePolicy.php b/erp/app/Modules/Finance/Policies/CommissionRulePolicy.php new file mode 100644 index 00000000000..d92d38e5afb --- /dev/null +++ b/erp/app/Modules/Finance/Policies/CommissionRulePolicy.php @@ -0,0 +1,15 @@ +can('finance.view'); } + public function view(User $user, CommissionRule $rule): bool { return $user->can('finance.view'); } + public function create(User $user): bool { return $user->can('finance.create'); } + public function update(User $user, CommissionRule $rule): bool { return $user->can('finance.create'); } + public function delete(User $user, CommissionRule $rule): bool { return $user->can('finance.delete'); } +} diff --git a/erp/app/Modules/Finance/Policies/ContactPolicy.php b/erp/app/Modules/Finance/Policies/ContactPolicy.php new file mode 100644 index 00000000000..6bc040e0758 --- /dev/null +++ b/erp/app/Modules/Finance/Policies/ContactPolicy.php @@ -0,0 +1,15 @@ +can('finance.view'); } + public function view(User $user, Contact $contact): bool { return $user->can('finance.view'); } + public function create(User $user): bool { return $user->can('finance.create'); } + public function update(User $user, Contact $contact): bool { return $user->can('finance.update'); } + public function delete(User $user, Contact $contact): bool { return $user->can('finance.delete'); } +} diff --git a/erp/app/Modules/Finance/Policies/ContractPolicy.php b/erp/app/Modules/Finance/Policies/ContractPolicy.php new file mode 100644 index 00000000000..25995512560 --- /dev/null +++ b/erp/app/Modules/Finance/Policies/ContractPolicy.php @@ -0,0 +1,15 @@ +can('finance.view'); } + public function view(User $user, Contract $contract): bool { return $user->can('finance.view'); } + public function create(User $user): bool { return $user->can('finance.create'); } + public function update(User $user, Contract $contract): bool { return $user->can('finance.create'); } + public function delete(User $user, Contract $contract): bool { return $user->can('finance.delete'); } +} diff --git a/erp/app/Modules/Finance/Policies/CreditNotePolicy.php b/erp/app/Modules/Finance/Policies/CreditNotePolicy.php new file mode 100644 index 00000000000..7483f2a4d4c --- /dev/null +++ b/erp/app/Modules/Finance/Policies/CreditNotePolicy.php @@ -0,0 +1,34 @@ +can('finance.view'); + } + + public function view(User $user, CreditNote $creditNote): bool + { + return $user->can('finance.view'); + } + + public function create(User $user): bool + { + return $user->can('finance.create'); + } + + public function update(User $user, CreditNote $creditNote): bool + { + return $user->can('finance.create'); + } + + public function delete(User $user, CreditNote $creditNote): bool + { + return $user->can('finance.delete'); + } +} diff --git a/erp/app/Modules/Finance/Policies/CurrencyPolicy.php b/erp/app/Modules/Finance/Policies/CurrencyPolicy.php new file mode 100644 index 00000000000..47edd2d6811 --- /dev/null +++ b/erp/app/Modules/Finance/Policies/CurrencyPolicy.php @@ -0,0 +1,33 @@ +hasPermissionTo('finance.view'); + } + + public function view(User $user, $model): bool + { + return $user->hasPermissionTo('finance.view'); + } + + public function create(User $user): bool + { + return $user->hasPermissionTo('finance.create'); + } + + public function update(User $user, $model): bool + { + return $user->hasPermissionTo('finance.create'); + } + + public function delete(User $user, $model): bool + { + return $user->hasPermissionTo('finance.delete'); + } +} diff --git a/erp/app/Modules/Finance/Policies/CustomerCreditPolicy.php b/erp/app/Modules/Finance/Policies/CustomerCreditPolicy.php new file mode 100644 index 00000000000..c2ba08e5a99 --- /dev/null +++ b/erp/app/Modules/Finance/Policies/CustomerCreditPolicy.php @@ -0,0 +1,54 @@ +can('finance.view'); + } + + public function view(User $user, CustomerCredit $customerCredit): bool + { + return $user->can('finance.view'); + } + + public function create(User $user): bool + { + return $user->can('finance.create'); + } + + public function update(User $user, CustomerCredit $customerCredit): bool + { + return $user->can('finance.create'); + } + + public function issue(User $user, CustomerCredit $customerCredit): bool + { + return $user->can('finance.create'); + } + + public function apply(User $user, CustomerCredit $customerCredit): bool + { + return $user->can('finance.create'); + } + + public function expire(User $user, CustomerCredit $customerCredit): bool + { + return $user->can('finance.delete'); + } + + public function cancel(User $user, CustomerCredit $customerCredit): bool + { + return $user->can('finance.delete'); + } + + public function delete(User $user, CustomerCredit $customerCredit): bool + { + return $user->can('finance.delete'); + } +} diff --git a/erp/app/Modules/Finance/Policies/CustomerGroupPolicy.php b/erp/app/Modules/Finance/Policies/CustomerGroupPolicy.php new file mode 100644 index 00000000000..e94af4af9df --- /dev/null +++ b/erp/app/Modules/Finance/Policies/CustomerGroupPolicy.php @@ -0,0 +1,34 @@ +can('finance.view'); + } + + public function view(User $user, CustomerGroup $customerGroup): bool + { + return $user->can('finance.view'); + } + + public function create(User $user): bool + { + return $user->can('finance.create'); + } + + public function update(User $user, CustomerGroup $customerGroup): bool + { + return $user->can('finance.create'); + } + + public function delete(User $user, CustomerGroup $customerGroup): bool + { + return $user->can('finance.delete'); + } +} diff --git a/erp/app/Modules/Finance/Policies/DebitNotePolicy.php b/erp/app/Modules/Finance/Policies/DebitNotePolicy.php new file mode 100644 index 00000000000..83412fcae26 --- /dev/null +++ b/erp/app/Modules/Finance/Policies/DebitNotePolicy.php @@ -0,0 +1,34 @@ +hasPermissionTo('finance.view'); + } + + public function view(User $user, DebitNote $debitNote): bool + { + return $user->hasPermissionTo('finance.view'); + } + + public function create(User $user): bool + { + return $user->hasPermissionTo('finance.create'); + } + + public function update(User $user, DebitNote $debitNote): bool + { + return $user->hasPermissionTo('finance.create'); + } + + public function delete(User $user, DebitNote $debitNote): bool + { + return $user->hasPermissionTo('finance.delete'); + } +} diff --git a/erp/app/Modules/Finance/Policies/DeliveryNotePolicy.php b/erp/app/Modules/Finance/Policies/DeliveryNotePolicy.php new file mode 100644 index 00000000000..256b4e58eb0 --- /dev/null +++ b/erp/app/Modules/Finance/Policies/DeliveryNotePolicy.php @@ -0,0 +1,34 @@ +can('finance.view'); + } + + public function view(User $user, DeliveryNote $dn): bool + { + return $user->can('finance.view'); + } + + public function create(User $user): bool + { + return $user->can('finance.create'); + } + + public function update(User $user, DeliveryNote $dn): bool + { + return $user->can('finance.create'); + } + + public function delete(User $user, DeliveryNote $dn): bool + { + return $user->can('finance.delete'); + } +} diff --git a/erp/app/Modules/Finance/Policies/DocumentTemplatePolicy.php b/erp/app/Modules/Finance/Policies/DocumentTemplatePolicy.php new file mode 100644 index 00000000000..9efefca8b62 --- /dev/null +++ b/erp/app/Modules/Finance/Policies/DocumentTemplatePolicy.php @@ -0,0 +1,34 @@ +hasPermissionTo('finance.view'); + } + + public function view(User $user, DocumentTemplate $model): bool + { + return $user->hasPermissionTo('finance.view'); + } + + public function create(User $user): bool + { + return $user->hasPermissionTo('finance.create'); + } + + public function update(User $user, DocumentTemplate $model): bool + { + return $user->hasPermissionTo('finance.create'); + } + + public function delete(User $user, DocumentTemplate $model): bool + { + return $user->hasPermissionTo('finance.delete'); + } +} diff --git a/erp/app/Modules/Finance/Policies/ExchangeRatePolicy.php b/erp/app/Modules/Finance/Policies/ExchangeRatePolicy.php new file mode 100644 index 00000000000..f293e2ca312 --- /dev/null +++ b/erp/app/Modules/Finance/Policies/ExchangeRatePolicy.php @@ -0,0 +1,29 @@ +hasPermissionTo('finance.view'); + } + + public function view(User $user, ExchangeRate $model): bool + { + return $user->hasPermissionTo('finance.view'); + } + + public function create(User $user): bool + { + return $user->hasPermissionTo('finance.create'); + } + + public function delete(User $user, ExchangeRate $model): bool + { + return $user->hasPermissionTo('finance.delete'); + } +} diff --git a/erp/app/Modules/Finance/Policies/ExpenseBudgetPolicy.php b/erp/app/Modules/Finance/Policies/ExpenseBudgetPolicy.php new file mode 100644 index 00000000000..bb36aa5a8d5 --- /dev/null +++ b/erp/app/Modules/Finance/Policies/ExpenseBudgetPolicy.php @@ -0,0 +1,44 @@ +hasPermissionTo('finance.view'); + } + + public function view(User $user, ExpenseBudget $expenseBudget): bool + { + return $user->hasPermissionTo('finance.view'); + } + + public function create(User $user): bool + { + return $user->hasPermissionTo('finance.create'); + } + + public function update(User $user, ExpenseBudget $expenseBudget): bool + { + return $user->hasPermissionTo('finance.create'); + } + + public function freeze(User $user, ExpenseBudget $expenseBudget): bool + { + return $user->hasPermissionTo('finance.create'); + } + + public function close(User $user, ExpenseBudget $expenseBudget): bool + { + return $user->hasPermissionTo('finance.delete'); + } + + public function delete(User $user, ExpenseBudget $expenseBudget): bool + { + return $user->hasPermissionTo('finance.delete'); + } +} diff --git a/erp/app/Modules/Finance/Policies/ExpenseClaimPolicy.php b/erp/app/Modules/Finance/Policies/ExpenseClaimPolicy.php new file mode 100644 index 00000000000..ecc0a5d49bc --- /dev/null +++ b/erp/app/Modules/Finance/Policies/ExpenseClaimPolicy.php @@ -0,0 +1,14 @@ +hasPermissionTo('finance.view'); } + public function view(User $user, $model): bool { return $user->hasPermissionTo('finance.view'); } + public function create(User $user): bool { return $user->hasPermissionTo('finance.create'); } + public function update(User $user, $model): bool { return $user->hasPermissionTo('finance.create'); } + public function delete(User $user, $model): bool { return $user->hasPermissionTo('finance.delete'); } +} diff --git a/erp/app/Modules/Finance/Policies/FixedAssetPolicy.php b/erp/app/Modules/Finance/Policies/FixedAssetPolicy.php new file mode 100644 index 00000000000..105821036f9 --- /dev/null +++ b/erp/app/Modules/Finance/Policies/FixedAssetPolicy.php @@ -0,0 +1,34 @@ +can('finance.view'); + } + + public function view(User $user, FixedAsset $fixedAsset): bool + { + return $user->can('finance.view'); + } + + public function create(User $user): bool + { + return $user->can('finance.create'); + } + + public function update(User $user, FixedAsset $fixedAsset): bool + { + return $user->can('finance.create'); + } + + public function delete(User $user, FixedAsset $fixedAsset): bool + { + return $user->can('finance.delete') && $fixedAsset->status === 'active'; + } +} diff --git a/erp/app/Modules/Finance/Policies/IntercompanyPolicy.php b/erp/app/Modules/Finance/Policies/IntercompanyPolicy.php new file mode 100644 index 00000000000..7250ed9869c --- /dev/null +++ b/erp/app/Modules/Finance/Policies/IntercompanyPolicy.php @@ -0,0 +1,34 @@ +hasPermissionTo('finance.view'); + } + + public function view(User $user, IntercompanyTransaction $transaction): bool + { + return $user->hasPermissionTo('finance.view'); + } + + public function create(User $user): bool + { + return $user->hasPermissionTo('finance.create'); + } + + public function update(User $user, IntercompanyTransaction $transaction): bool + { + return $user->hasPermissionTo('finance.create'); + } + + public function delete(User $user, IntercompanyTransaction $transaction): bool + { + return $user->hasPermissionTo('finance.delete'); + } +} diff --git a/erp/app/Modules/Finance/Policies/InvoicePolicy.php b/erp/app/Modules/Finance/Policies/InvoicePolicy.php new file mode 100644 index 00000000000..af34d4bb258 --- /dev/null +++ b/erp/app/Modules/Finance/Policies/InvoicePolicy.php @@ -0,0 +1,15 @@ +can('finance.view'); } + public function view(User $user, Invoice $invoice): bool { return $user->can('finance.view'); } + public function create(User $user): bool { return $user->can('finance.create'); } + public function update(User $user, Invoice $invoice): bool { return $user->can('finance.update'); } + public function delete(User $user, Invoice $invoice): bool { return $user->can('finance.delete') && $invoice->status === 'draft'; } +} diff --git a/erp/app/Modules/Finance/Policies/JournalEntryPolicy.php b/erp/app/Modules/Finance/Policies/JournalEntryPolicy.php new file mode 100644 index 00000000000..7838029e749 --- /dev/null +++ b/erp/app/Modules/Finance/Policies/JournalEntryPolicy.php @@ -0,0 +1,15 @@ +can('finance.view'); } + public function view(User $user, JournalEntry $entry): bool { return $user->can('finance.view'); } + public function create(User $user): bool { return $user->can('finance.create'); } + public function update(User $user, JournalEntry $entry): bool { return $user->can('finance.update') && $entry->status === 'draft'; } + public function delete(User $user, JournalEntry $entry): bool { return $user->can('finance.delete') && $entry->status === 'draft'; } +} diff --git a/erp/app/Modules/Finance/Policies/LeadPolicy.php b/erp/app/Modules/Finance/Policies/LeadPolicy.php new file mode 100644 index 00000000000..f2ca52cb1bd --- /dev/null +++ b/erp/app/Modules/Finance/Policies/LeadPolicy.php @@ -0,0 +1,15 @@ +can('finance.view'); } + public function view(User $user, Lead $lead): bool { return $user->can('finance.view'); } + public function create(User $user): bool { return $user->can('finance.create'); } + public function update(User $user, Lead $lead): bool { return $user->can('finance.create'); } + public function delete(User $user, Lead $lead): bool { return $user->can('finance.delete'); } +} diff --git a/erp/app/Modules/Finance/Policies/LoyaltyPolicy.php b/erp/app/Modules/Finance/Policies/LoyaltyPolicy.php new file mode 100644 index 00000000000..25d7584bcdc --- /dev/null +++ b/erp/app/Modules/Finance/Policies/LoyaltyPolicy.php @@ -0,0 +1,15 @@ +can('finance.view'); } + public function view(User $user, LoyaltyProgram $loyaltyProgram): bool { return $user->can('finance.view'); } + public function create(User $user): bool { return $user->can('finance.create'); } + public function update(User $user, LoyaltyProgram $loyaltyProgram): bool { return $user->can('finance.create'); } + public function delete(User $user, LoyaltyProgram $loyaltyProgram): bool { return $user->can('finance.delete'); } +} diff --git a/erp/app/Modules/Finance/Policies/PaymentSchedulePolicy.php b/erp/app/Modules/Finance/Policies/PaymentSchedulePolicy.php new file mode 100644 index 00000000000..f92170e1e94 --- /dev/null +++ b/erp/app/Modules/Finance/Policies/PaymentSchedulePolicy.php @@ -0,0 +1,49 @@ +hasPermissionTo('finance.view'); + } + + public function view(User $user, PaymentSchedule $paymentSchedule): bool + { + return $user->hasPermissionTo('finance.view'); + } + + public function create(User $user): bool + { + return $user->hasPermissionTo('finance.create'); + } + + public function update(User $user, PaymentSchedule $paymentSchedule): bool + { + return $user->hasPermissionTo('finance.create'); + } + + public function pause(User $user, PaymentSchedule $paymentSchedule): bool + { + return $user->hasPermissionTo('finance.create'); + } + + public function resume(User $user, PaymentSchedule $paymentSchedule): bool + { + return $user->hasPermissionTo('finance.create'); + } + + public function cancel(User $user, PaymentSchedule $paymentSchedule): bool + { + return $user->hasPermissionTo('finance.create'); + } + + public function delete(User $user, PaymentSchedule $paymentSchedule): bool + { + return $user->hasPermissionTo('finance.delete'); + } +} diff --git a/erp/app/Modules/Finance/Policies/PaymentTermPolicy.php b/erp/app/Modules/Finance/Policies/PaymentTermPolicy.php new file mode 100644 index 00000000000..4c2b6438eab --- /dev/null +++ b/erp/app/Modules/Finance/Policies/PaymentTermPolicy.php @@ -0,0 +1,15 @@ +can('finance.view'); } + public function view(User $user, PaymentTerm $paymentTerm): bool { return $user->can('finance.view'); } + public function create(User $user): bool { return $user->can('finance.create'); } + public function update(User $user, PaymentTerm $paymentTerm): bool { return $user->can('finance.create'); } + public function delete(User $user, PaymentTerm $paymentTerm): bool { return $user->can('finance.delete'); } +} diff --git a/erp/app/Modules/Finance/Policies/PettyCashPolicy.php b/erp/app/Modules/Finance/Policies/PettyCashPolicy.php new file mode 100644 index 00000000000..84d24ec9319 --- /dev/null +++ b/erp/app/Modules/Finance/Policies/PettyCashPolicy.php @@ -0,0 +1,33 @@ +hasPermissionTo('finance.view'); + } + + public function view(User $user, $model): bool + { + return $user->hasPermissionTo('finance.view'); + } + + public function create(User $user): bool + { + return $user->hasPermissionTo('finance.create'); + } + + public function update(User $user, $model): bool + { + return $user->hasPermissionTo('finance.create'); + } + + public function delete(User $user, $model): bool + { + return $user->hasPermissionTo('finance.delete'); + } +} diff --git a/erp/app/Modules/Finance/Policies/PriceListPolicy.php b/erp/app/Modules/Finance/Policies/PriceListPolicy.php new file mode 100644 index 00000000000..fbe91eae725 --- /dev/null +++ b/erp/app/Modules/Finance/Policies/PriceListPolicy.php @@ -0,0 +1,15 @@ +can('finance.view'); } + public function view(User $user, PriceList $priceList): bool { return $user->can('finance.view'); } + public function create(User $user): bool { return $user->can('finance.create'); } + public function update(User $user, PriceList $priceList): bool { return $user->can('finance.create'); } + public function delete(User $user, PriceList $priceList): bool { return $user->can('finance.delete'); } +} diff --git a/erp/app/Modules/Finance/Policies/ProfitCenterPolicy.php b/erp/app/Modules/Finance/Policies/ProfitCenterPolicy.php new file mode 100644 index 00000000000..4f60896ee11 --- /dev/null +++ b/erp/app/Modules/Finance/Policies/ProfitCenterPolicy.php @@ -0,0 +1,44 @@ +can('finance.view'); + } + + public function view(User $user, ProfitCenter $profitCenter): bool + { + return $user->can('finance.view'); + } + + public function create(User $user): bool + { + return $user->can('finance.create'); + } + + public function update(User $user, ProfitCenter $profitCenter): bool + { + return $user->can('finance.create'); + } + + public function activate(User $user, ProfitCenter $profitCenter): bool + { + return $user->can('finance.create'); + } + + public function deactivate(User $user, ProfitCenter $profitCenter): bool + { + return $user->can('finance.create'); + } + + public function delete(User $user, ProfitCenter $profitCenter): bool + { + return $user->can('finance.delete'); + } +} diff --git a/erp/app/Modules/Finance/Policies/ProjectPolicy.php b/erp/app/Modules/Finance/Policies/ProjectPolicy.php new file mode 100644 index 00000000000..c15c2f8f007 --- /dev/null +++ b/erp/app/Modules/Finance/Policies/ProjectPolicy.php @@ -0,0 +1,34 @@ +can('finance.view'); + } + + public function view(User $user, Project $project): bool + { + return $user->can('finance.view'); + } + + public function create(User $user): bool + { + return $user->can('finance.create'); + } + + public function update(User $user, Project $project): bool + { + return $user->can('finance.create'); + } + + public function delete(User $user, Project $project): bool + { + return $user->can('finance.delete'); + } +} diff --git a/erp/app/Modules/Finance/Policies/QuotePolicy.php b/erp/app/Modules/Finance/Policies/QuotePolicy.php new file mode 100644 index 00000000000..03c271665ca --- /dev/null +++ b/erp/app/Modules/Finance/Policies/QuotePolicy.php @@ -0,0 +1,34 @@ +can('finance.view'); + } + + public function view(User $user, Quote $quote): bool + { + return $user->can('finance.view'); + } + + public function create(User $user): bool + { + return $user->can('finance.create'); + } + + public function update(User $user, Quote $quote): bool + { + return $user->can('finance.update'); + } + + public function delete(User $user, Quote $quote): bool + { + return $user->can('finance.delete') && $quote->status === 'draft'; + } +} diff --git a/erp/app/Modules/Finance/Policies/RecurringExpensePolicy.php b/erp/app/Modules/Finance/Policies/RecurringExpensePolicy.php new file mode 100644 index 00000000000..966b1ba6c6a --- /dev/null +++ b/erp/app/Modules/Finance/Policies/RecurringExpensePolicy.php @@ -0,0 +1,48 @@ +hasPermissionTo('finance.view'); + } + + public function view(User $user, $model): bool + { + return $user->hasPermissionTo('finance.view'); + } + + public function create(User $user): bool + { + return $user->hasPermissionTo('finance.create'); + } + + public function update(User $user, $model): bool + { + return $user->hasPermissionTo('finance.create'); + } + + public function pause(User $user, $model): bool + { + return $user->hasPermissionTo('finance.create'); + } + + public function resume(User $user, $model): bool + { + return $user->hasPermissionTo('finance.create'); + } + + public function cancel(User $user, $model): bool + { + return $user->hasPermissionTo('finance.create'); + } + + public function delete(User $user, $model): bool + { + return $user->hasPermissionTo('finance.delete'); + } +} diff --git a/erp/app/Modules/Finance/Policies/RecurringInvoicePolicy.php b/erp/app/Modules/Finance/Policies/RecurringInvoicePolicy.php new file mode 100644 index 00000000000..240562ca85c --- /dev/null +++ b/erp/app/Modules/Finance/Policies/RecurringInvoicePolicy.php @@ -0,0 +1,34 @@ +can('finance.view'); + } + + public function view(User $user, RecurringInvoice $recurringInvoice): bool + { + return $user->can('finance.view'); + } + + public function create(User $user): bool + { + return $user->can('finance.create'); + } + + public function update(User $user, RecurringInvoice $recurringInvoice): bool + { + return $user->can('finance.update'); + } + + public function delete(User $user, RecurringInvoice $recurringInvoice): bool + { + return $user->can('finance.delete'); + } +} diff --git a/erp/app/Modules/Finance/Policies/ReturnRequestPolicy.php b/erp/app/Modules/Finance/Policies/ReturnRequestPolicy.php new file mode 100644 index 00000000000..56466ce1772 --- /dev/null +++ b/erp/app/Modules/Finance/Policies/ReturnRequestPolicy.php @@ -0,0 +1,15 @@ +can('finance.view'); } + public function view(User $user, ReturnRequest $returnRequest): bool { return $user->can('finance.view'); } + public function create(User $user): bool { return $user->can('finance.create'); } + public function update(User $user, ReturnRequest $returnRequest): bool { return $user->can('finance.create'); } + public function delete(User $user, ReturnRequest $returnRequest): bool { return $user->can('finance.delete'); } +} diff --git a/erp/app/Modules/Finance/Policies/SalesOrderPolicy.php b/erp/app/Modules/Finance/Policies/SalesOrderPolicy.php new file mode 100644 index 00000000000..04105afacb2 --- /dev/null +++ b/erp/app/Modules/Finance/Policies/SalesOrderPolicy.php @@ -0,0 +1,34 @@ +can('finance.view'); + } + + public function view(User $user, SalesOrder $salesOrder): bool + { + return $user->can('finance.view'); + } + + public function create(User $user): bool + { + return $user->can('finance.create'); + } + + public function update(User $user, SalesOrder $salesOrder): bool + { + return $user->can('finance.update'); + } + + public function delete(User $user, SalesOrder $salesOrder): bool + { + return $user->can('finance.delete') && $salesOrder->status === 'draft'; + } +} diff --git a/erp/app/Modules/Finance/Policies/ServiceAgreementPolicy.php b/erp/app/Modules/Finance/Policies/ServiceAgreementPolicy.php new file mode 100644 index 00000000000..6e7e0ab43a7 --- /dev/null +++ b/erp/app/Modules/Finance/Policies/ServiceAgreementPolicy.php @@ -0,0 +1,15 @@ +can('finance.view'); } + public function view(User $user, ServiceAgreement $serviceAgreement): bool { return $user->can('finance.view'); } + public function create(User $user): bool { return $user->can('finance.create'); } + public function update(User $user, ServiceAgreement $serviceAgreement): bool { return $user->can('finance.create'); } + public function delete(User $user, ServiceAgreement $serviceAgreement): bool { return $user->can('finance.delete'); } +} diff --git a/erp/app/Modules/Finance/Policies/SubscriptionPolicy.php b/erp/app/Modules/Finance/Policies/SubscriptionPolicy.php new file mode 100644 index 00000000000..38c9ca0d381 --- /dev/null +++ b/erp/app/Modules/Finance/Policies/SubscriptionPolicy.php @@ -0,0 +1,14 @@ +can('finance.view'); } + public function view(User $user): bool { return $user->can('finance.view'); } + public function create(User $user): bool { return $user->can('finance.create'); } + public function update(User $user): bool { return $user->can('finance.create'); } + public function delete(User $user): bool { return $user->can('finance.delete'); } +} diff --git a/erp/app/Modules/Finance/Policies/SupportTicketPolicy.php b/erp/app/Modules/Finance/Policies/SupportTicketPolicy.php new file mode 100644 index 00000000000..f24bb240799 --- /dev/null +++ b/erp/app/Modules/Finance/Policies/SupportTicketPolicy.php @@ -0,0 +1,14 @@ +hasPermissionTo('finance.view'); } + public function view(User $user, $model): bool { return $user->hasPermissionTo('finance.view'); } + public function create(User $user): bool { return $user->hasPermissionTo('finance.create'); } + public function update(User $user, $model): bool { return $user->hasPermissionTo('finance.create'); } + public function delete(User $user, $model): bool { return $user->hasPermissionTo('finance.delete'); } +} diff --git a/erp/app/Modules/Finance/Policies/TaxPolicy.php b/erp/app/Modules/Finance/Policies/TaxPolicy.php new file mode 100644 index 00000000000..f644cf47b14 --- /dev/null +++ b/erp/app/Modules/Finance/Policies/TaxPolicy.php @@ -0,0 +1,17 @@ +can('finance.view'); } + public function view(User $user, mixed $model): bool { return $user->can('finance.view'); } + public function create(User $user, mixed $model = null): bool { return $user->can('finance.create'); } + public function update(User $user, mixed $model): bool { return $user->can('finance.create'); } + public function delete(User $user, mixed $model): bool { return $user->can('finance.delete'); } +} diff --git a/erp/app/Modules/Finance/Policies/VendorBillPolicy.php b/erp/app/Modules/Finance/Policies/VendorBillPolicy.php new file mode 100644 index 00000000000..f47bbf991e9 --- /dev/null +++ b/erp/app/Modules/Finance/Policies/VendorBillPolicy.php @@ -0,0 +1,14 @@ +hasPermissionTo('finance.view'); } + public function view(User $user, $model): bool { return $user->hasPermissionTo('finance.view'); } + public function create(User $user): bool { return $user->hasPermissionTo('finance.create'); } + public function update(User $user, $model): bool { return $user->hasPermissionTo('finance.create'); } + public function delete(User $user, $model): bool { return $user->hasPermissionTo('finance.delete'); } +} diff --git a/erp/app/Modules/Finance/Policies/VendorPaymentPolicy.php b/erp/app/Modules/Finance/Policies/VendorPaymentPolicy.php new file mode 100644 index 00000000000..142fc90509d --- /dev/null +++ b/erp/app/Modules/Finance/Policies/VendorPaymentPolicy.php @@ -0,0 +1,54 @@ +can('finance.view'); + } + + public function view(User $user, VendorPayment $vendorPayment): bool + { + return $user->can('finance.view'); + } + + public function create(User $user): bool + { + return $user->can('finance.create'); + } + + public function update(User $user, VendorPayment $vendorPayment): bool + { + return $user->can('finance.create'); + } + + public function approve(User $user, VendorPayment $vendorPayment): bool + { + return $user->can('finance.create'); + } + + public function process(User $user, VendorPayment $vendorPayment): bool + { + return $user->can('finance.create'); + } + + public function reject(User $user, VendorPayment $vendorPayment): bool + { + return $user->can('finance.delete'); + } + + public function cancel(User $user, VendorPayment $vendorPayment): bool + { + return $user->can('finance.delete'); + } + + public function delete(User $user, VendorPayment $vendorPayment): bool + { + return $user->can('finance.delete'); + } +} diff --git a/erp/app/Modules/Finance/Policies/WriteOffPolicy.php b/erp/app/Modules/Finance/Policies/WriteOffPolicy.php new file mode 100644 index 00000000000..6e212023cab --- /dev/null +++ b/erp/app/Modules/Finance/Policies/WriteOffPolicy.php @@ -0,0 +1,34 @@ +hasPermissionTo('finance.view'); + } + + public function view(User $user, WriteOff $writeOff): bool + { + return $user->hasPermissionTo('finance.view'); + } + + public function create(User $user): bool + { + return $user->hasPermissionTo('finance.create'); + } + + public function update(User $user, WriteOff $writeOff): bool + { + return $user->hasPermissionTo('finance.create'); + } + + public function delete(User $user, WriteOff $writeOff): bool + { + return $user->hasPermissionTo('finance.delete'); + } +} diff --git a/erp/app/Modules/Finance/Providers/FinanceServiceProvider.php b/erp/app/Modules/Finance/Providers/FinanceServiceProvider.php new file mode 100644 index 00000000000..bc32762f891 --- /dev/null +++ b/erp/app/Modules/Finance/Providers/FinanceServiceProvider.php @@ -0,0 +1,217 @@ +loadRoutesFrom(__DIR__ . '/../routes/finance.php'); + + Gate::policy(Account::class, AccountPolicy::class); + Gate::policy(Budget::class, BudgetPolicy::class); + Gate::policy(BudgetLine::class, BudgetPolicy::class); + Gate::policy(Contact::class, ContactPolicy::class); + Gate::policy(DeliveryNote::class, DeliveryNotePolicy::class); + Gate::policy(JournalEntry::class, JournalEntryPolicy::class); + Gate::policy(Invoice::class, InvoicePolicy::class); + Gate::policy(Bill::class, BillPolicy::class); + Gate::policy(Quote::class, QuotePolicy::class); + Gate::policy(CreditNote::class, CreditNotePolicy::class); + Gate::policy(RecurringInvoice::class, RecurringInvoicePolicy::class); + Gate::policy(SalesOrder::class, SalesOrderPolicy::class); + Gate::policy(Currency::class, CurrencyPolicy::class); + Gate::policy(ExchangeRate::class, ExchangeRatePolicy::class); + Gate::policy(BankAccount::class, BankAccountPolicy::class); + Gate::policy(BankTransaction::class, BankTransactionPolicy::class); + Gate::policy(BankReconciliation::class, BankPolicy::class); + Gate::policy(FixedAsset::class, FixedAssetPolicy::class); + Gate::policy(DepreciationEntry::class, FixedAssetPolicy::class); + Gate::policy(PriceList::class, PriceListPolicy::class); + Gate::policy(PriceListItem::class, PriceListPolicy::class); + Gate::policy(Project::class, ProjectPolicy::class); + Gate::policy(ProjectTask::class, ProjectPolicy::class); + Gate::policy(ProjectTimeEntry::class, ProjectPolicy::class); + Gate::policy(Attachment::class, AttachmentPolicy::class); + Gate::policy(BatchPayment::class, BatchPaymentPolicy::class); + Gate::policy(DocumentTemplate::class, DocumentTemplatePolicy::class); + Gate::policy(SubscriptionPlan::class, SubscriptionPolicy::class); + Gate::policy(Subscription::class, SubscriptionPolicy::class); + Gate::policy(Commission::class, CommissionPolicy::class); + Gate::policy(CommissionRule::class, CommissionRulePolicy::class); + Gate::policy(Contract::class, ContractPolicy::class); + Gate::policy(ContractRenewal::class, ContractPolicy::class); + + Gate::policy(ReturnRequest::class, ReturnRequestPolicy::class); + Gate::policy(ReturnRequestItem::class, ReturnRequestPolicy::class); + + Gate::policy(TaxRate::class, TaxPolicy::class); + Gate::policy(TaxGroup::class, TaxPolicy::class); + Gate::policy(TaxGroupItem::class, TaxPolicy::class); + + Gate::policy(ServiceAgreement::class, ServiceAgreementPolicy::class); + Gate::policy(ServiceAgreementItem::class, ServiceAgreementPolicy::class); + Gate::policy(MaintenanceLog::class, ServiceAgreementPolicy::class); + + Gate::policy(LoyaltyProgram::class, LoyaltyPolicy::class); + + Gate::policy(Lead::class, LeadPolicy::class); + Gate::policy(LeadActivity::class, LeadPolicy::class); + Gate::policy(LoyaltyEnrollment::class, LoyaltyPolicy::class); + Gate::policy(LoyaltyTransaction::class, LoyaltyPolicy::class); + + Gate::policy(SupportTicket::class, SupportTicketPolicy::class); + Gate::policy(TicketComment::class, SupportTicketPolicy::class); + + + Gate::policy(ExpenseClaim::class, ExpenseClaimPolicy::class); + Gate::policy(VendorBill::class, VendorBillPolicy::class); + Gate::policy(VendorBillItem::class, VendorBillPolicy::class); + Gate::policy(ExpenseItem::class, ExpenseClaimPolicy::class); + Gate::policy(PaymentTerm::class, PaymentTermPolicy::class); + Gate::policy(PettyCashFund::class, PettyCashPolicy::class); + Gate::policy(PettyCashTransaction::class, PettyCashPolicy::class); + Gate::policy(BankTransfer::class, BankTransferPolicy::class); + Gate::policy(CustomerGroup::class, CustomerGroupPolicy::class); + Gate::policy(AdvancePayment::class, AdvancePaymentPolicy::class); + Gate::policy(DebitNote::class, DebitNotePolicy::class); + Gate::policy(DebitNoteItem::class, DebitNotePolicy::class); + Gate::policy(WriteOff::class, WriteOffPolicy::class); + Gate::policy(IntercompanyTransaction::class, IntercompanyPolicy::class); + Gate::policy(CashFlowForecast::class, CashFlowForecastPolicy::class); + Gate::policy(RecurringExpense::class, RecurringExpensePolicy::class); + Gate::policy(VendorPayment::class, VendorPaymentPolicy::class); + Gate::policy(PaymentSchedule::class, PaymentSchedulePolicy::class); + Gate::policy(PaymentScheduleItem::class, PaymentSchedulePolicy::class); + Gate::policy(CustomerCredit::class, CustomerCreditPolicy::class); + Gate::policy(ExpenseBudget::class, ExpenseBudgetPolicy::class); + Gate::policy(ProfitCenter::class, ProfitCenterPolicy::class); + if ($this->app->runningInConsole()) { + $this->commands([\App\Modules\Finance\Console\Commands\GenerateRecurringInvoices::class]); + } + } +} diff --git a/erp/app/Modules/Finance/Services/CurrencyConversionService.php b/erp/app/Modules/Finance/Services/CurrencyConversionService.php new file mode 100644 index 00000000000..4bf4f3230ab --- /dev/null +++ b/erp/app/Modules/Finance/Services/CurrencyConversionService.php @@ -0,0 +1,36 @@ +tenantId); + } + + public function convert(float $amount, string $from, string $to, ?\Carbon\Carbon $date = null): ?float { + return ExchangeRate::convert($this->tenantId, $amount, $from, $to, $date); + } + + public function convertToBase(float $amount, string $from, ?\Carbon\Carbon $date = null): ?float { + $base = $this->getBaseCurrency(); + if (!$base || $base->code === $from) return $amount; + return $this->convert($amount, $from, $base->code, $date); + } + + public function getRate(string $from, string $to, ?\Carbon\Carbon $date = null): ?float { + return ExchangeRate::getRate($this->tenantId, $from, $to, $date); + } + + public function getSupportedCurrencies(): \Illuminate\Support\Collection { + return Currency::withoutGlobalScopes() + ->where('tenant_id', $this->tenantId) + ->where('is_active', true) + ->orderBy('code') + ->get(); + } +} diff --git a/erp/app/Modules/Finance/Traits/HasAttachments.php b/erp/app/Modules/Finance/Traits/HasAttachments.php new file mode 100644 index 00000000000..94dd41902c6 --- /dev/null +++ b/erp/app/Modules/Finance/Traits/HasAttachments.php @@ -0,0 +1,14 @@ +morphMany(Attachment::class, 'attachable'); + } +} diff --git a/erp/app/Modules/Finance/Traits/HasLineItemTotals.php b/erp/app/Modules/Finance/Traits/HasLineItemTotals.php new file mode 100644 index 00000000000..ceabc900f62 --- /dev/null +++ b/erp/app/Modules/Finance/Traits/HasLineItemTotals.php @@ -0,0 +1,41 @@ +items->sum(fn ($i) => (float) $i->quantity * (float) $i->unit_price); + } + + public function getTaxTotalAttribute(): float + { + return $this->items->sum(function ($i) { + $sub = (float) $i->quantity * (float) $i->unit_price; + return $sub * ((float) $i->tax_rate / 100); + }); + } + + public function getTotalAttribute(): float + { + return $this->subtotal + $this->tax_total; + } + + public function getAmountPaidAttribute(): float + { + return (float) $this->payments->sum('amount'); + } + + public function getAmountDueAttribute(): float + { + return $this->total - $this->amount_paid; + } + + public function isOverdue(): bool + { + return $this->due_date !== null + && now()->startOfDay()->gt($this->due_date) + && ! in_array($this->status, ['paid', 'cancelled'], true); + } +} diff --git a/erp/app/Modules/Finance/Traits/HasStatusTransitions.php b/erp/app/Modules/Finance/Traits/HasStatusTransitions.php new file mode 100644 index 00000000000..11bdd08e97d --- /dev/null +++ b/erp/app/Modules/Finance/Traits/HasStatusTransitions.php @@ -0,0 +1,29 @@ +getTransitions()[$this->status] ?? [], true); + } + + public function availableTransitions(): array + { + return $this->getTransitions()[$this->status] ?? []; + } + + public function transitionTo(string $status): void + { + if (! $this->canTransitionTo($status)) { + throw new \DomainException( + "Cannot transition from '{$this->status}' to '{$status}'." + ); + } + + $this->update(['status' => $status]); + } +} diff --git a/erp/app/Modules/Finance/routes/finance.php b/erp/app/Modules/Finance/routes/finance.php new file mode 100644 index 00000000000..e03fde0e7be --- /dev/null +++ b/erp/app/Modules/Finance/routes/finance.php @@ -0,0 +1,467 @@ +prefix('finance')->name('finance.')->group(function() { + Route::get('dashboard', [FinanceDashboardController::class, 'index'])->name('dashboard'); +}); + +Route::middleware(['web', 'auth', 'verified'])->prefix('finance')->name('finance.')->group(function () { + + // Chart of Accounts + Route::resource('accounts', AccountController::class)->except(['show']); + + // Contacts + Route::resource('contacts', ContactController::class)->except(['show']); + + // Journal Entries + Route::resource('journal-entries', JournalEntryController::class)->except(['edit', 'update']); + Route::patch('journal-entries/{journalEntry}/post', [JournalEntryController::class, 'post']) + ->name('journal-entries.post'); + + // Invoices + Route::resource('invoices', InvoiceController::class)->except(['edit', 'update']); + Route::patch('invoices/{invoice}/send', [InvoiceController::class, 'send'])->name('invoices.send'); + Route::patch('invoices/{invoice}/cancel', [InvoiceController::class, 'cancel'])->name('invoices.cancel'); + Route::post('invoices/{invoice}/payments', [InvoiceController::class, 'recordPayment']) + ->name('invoices.payments.store'); + Route::get('invoices/{invoice}/print', [InvoiceController::class, 'print']) + ->name('invoices.print'); + + // PDF + Email routes + Route::get('/invoices/{invoice}/pdf', [InvoiceController::class, 'pdf'])->name('finance.invoices.pdf'); + Route::post('/invoices/{invoice}/email', [InvoiceController::class, 'email'])->name('finance.invoices.email'); + Route::get('/quotes/{quote}/pdf', [QuoteController::class, 'pdf'])->name('finance.quotes.pdf'); + Route::post('/quotes/{quote}/email', [QuoteController::class, 'email'])->name('finance.quotes.email'); + Route::get('/bills/{bill}/pdf', [BillController::class, 'pdf'])->name('finance.bills.pdf'); + Route::post('/bills/{bill}/email', [BillController::class, 'email'])->name('finance.bills.email'); + + // Bills (AP) + Route::resource('bills', BillController::class)->except(['edit', 'update']); + Route::patch('bills/{bill}/receive', [BillController::class, 'receive'])->name('bills.receive'); + Route::patch('bills/{bill}/cancel', [BillController::class, 'cancel'])->name('bills.cancel'); + Route::post('bills/{bill}/payments', [BillController::class, 'recordPayment']) + ->name('bills.payments.store'); + + // Quotes + Route::get('quotes', [QuoteController::class, 'index'])->name('quotes.index'); + Route::get('quotes/create', [QuoteController::class, 'create'])->name('quotes.create'); + Route::post('quotes', [QuoteController::class, 'store'])->name('quotes.store'); + Route::get('quotes/{quote}', [QuoteController::class, 'show'])->name('quotes.show'); + Route::patch('quotes/{quote}/send', [QuoteController::class, 'send'])->name('quotes.send'); + Route::patch('quotes/{quote}/accept', [QuoteController::class, 'accept'])->name('quotes.accept'); + Route::patch('quotes/{quote}/decline', [QuoteController::class, 'decline'])->name('quotes.decline'); + Route::post('quotes/{quote}/convert', [QuoteController::class, 'convertToInvoice'])->name('quotes.convert'); + Route::delete('quotes/{quote}', [QuoteController::class, 'destroy'])->name('quotes.destroy'); + + // Sales Orders + Route::get('sales-orders', [SalesOrderController::class, 'index'])->name('sales-orders.index'); + Route::get('sales-orders/create', [SalesOrderController::class, 'create'])->name('sales-orders.create'); + Route::post('sales-orders', [SalesOrderController::class, 'store'])->name('sales-orders.store'); + Route::get('sales-orders/{salesOrder}', [SalesOrderController::class, 'show'])->name('sales-orders.show'); + Route::patch('sales-orders/{salesOrder}/confirm', [SalesOrderController::class, 'confirm'])->name('sales-orders.confirm'); + Route::post('sales-orders/{salesOrder}/confirm', [SalesOrderController::class, 'confirm'])->name('sales-orders.confirm-post'); + Route::post('sales-orders/{salesOrder}/fulfill', [SalesOrderController::class, 'fulfill'])->name('sales-orders.fulfill'); + Route::patch('sales-orders/{salesOrder}/cancel', [SalesOrderController::class, 'cancel'])->name('sales-orders.cancel'); + Route::post('sales-orders/{salesOrder}/cancel', [SalesOrderController::class, 'cancel'])->name('sales-orders.cancel-post'); + Route::post('sales-orders/{salesOrder}/convert', [SalesOrderController::class, 'convertToInvoice'])->name('sales-orders.convert'); + Route::post('sales-orders/{salesOrder}/convert-to-invoice', [SalesOrderController::class, 'convertToInvoice'])->name('sales-orders.convert-to-invoice'); + Route::delete('sales-orders/{salesOrder}', [SalesOrderController::class, 'destroy'])->name('sales-orders.destroy'); + + // Recurring Invoices + Route::get('recurring-invoices', [RecurringInvoiceController::class, 'index'])->name('recurring-invoices.index'); + Route::get('recurring-invoices/create', [RecurringInvoiceController::class, 'create'])->name('recurring-invoices.create'); + Route::post('recurring-invoices', [RecurringInvoiceController::class, 'store'])->name('recurring-invoices.store'); + Route::get('recurring-invoices/{recurringInvoice}', [RecurringInvoiceController::class, 'show'])->name('recurring-invoices.show'); + Route::patch('recurring-invoices/{recurringInvoice}/pause', [RecurringInvoiceController::class, 'pause'])->name('recurring-invoices.pause'); + Route::patch('recurring-invoices/{recurringInvoice}/resume', [RecurringInvoiceController::class, 'resume'])->name('recurring-invoices.resume'); + Route::post('recurring-invoices/{recurringInvoice}/generate', [RecurringInvoiceController::class, 'generateNow'])->name('recurring-invoices.generate'); + Route::delete('recurring-invoices/{recurringInvoice}', [RecurringInvoiceController::class, 'destroy'])->name('recurring-invoices.destroy'); + + // Credit Notes — custom actions BEFORE resource + Route::post('credit-notes/{creditNote}/issue', [CreditNoteController::class, 'issue'])->name('credit-notes.issue'); + Route::post('credit-notes/{creditNote}/apply', [CreditNoteController::class, 'apply'])->name('credit-notes.apply'); + Route::post('credit-notes/{creditNote}/void', [CreditNoteController::class, 'void'])->name('credit-notes.void'); + Route::resource('credit-notes', CreditNoteController::class)->except(['edit', 'update']); + + // Reports + Route::get('reports/trial-balance', [ReportController::class, 'trialBalance']) + ->name('reports.trial-balance'); + Route::get('reports/profit-loss', [ReportController::class, 'profitAndLoss']) + ->name('reports.profit-loss'); + Route::get('reports/balance-sheet', [ReportController::class, 'balanceSheet']) + ->name('reports.balance-sheet'); + Route::get('reports/aged-receivables', [ReportController::class, 'agedReceivables'])->name('reports.aged-receivables'); + Route::get('reports/aged-payables', [ReportController::class, 'agedPayables'])->name('reports.aged-payables'); + Route::get('reports/account-ledger', [ReportController::class, 'accountLedgerIndex'])->name('reports.account-ledger.index'); + Route::get('reports/account-ledger/{account}', [ReportController::class, 'accountLedger'])->name('reports.account-ledger'); + Route::get('reports/customer-statement', [ReportController::class, 'customerStatementIndex'])->name('reports.customer-statement.index'); + Route::get('reports/customer-statement/export', [ReportController::class, 'exportCustomerStatement'])->name('reports.customer-statement.export'); + Route::get('reports/customer-statement/{contact}', [ReportController::class, 'customerStatement'])->name('reports.customer-statement'); + Route::get('reports/supplier-statement', [ReportController::class, 'supplierStatement'])->name('reports.supplier-statement'); + Route::get('reports/supplier-statement/export', [ReportController::class, 'exportSupplierStatement'])->name('reports.supplier-statement.export'); + Route::get('reports/vat-report', [ReportController::class, 'vatReport'])->name('reports.vat-report'); + Route::get('reports/cash-flow-forecast', [ReportController::class, 'cashFlowForecast'])->name('reports.cash-flow-forecast'); + + // CSV exports + Route::get('reports/profit-loss/export', [ReportController::class, 'exportProfitLoss'])->name('reports.profit-loss.export'); + Route::get('reports/balance-sheet/export', [ReportController::class, 'exportBalanceSheet'])->name('reports.balance-sheet.export'); + Route::get('reports/aged-receivables/export', [ReportController::class, 'exportAgedReceivables'])->name('reports.aged-receivables.export'); + Route::get('reports/aged-payables/export', [ReportController::class, 'exportAgedPayables'])->name('reports.aged-payables.export'); + Route::get('reports/account-ledger/{account}/export', [ReportController::class, 'exportAccountLedger'])->name('reports.account-ledger.export'); + Route::get('reports/vat-report/export', [ReportController::class, 'exportVatReport'])->name('reports.vat-report.export'); + Route::get('reports/comparative-profit-loss', [ReportController::class, 'comparativeProfitLoss'])->name('reports.comparative-profit-loss'); + Route::get('reports/comparative-profit-loss/export', [ReportController::class, 'exportComparativeProfitLoss'])->name('reports.comparative-profit-loss.export'); + Route::get('reports/cash-flow-forecast/export', [ReportController::class, 'exportCashFlowForecast'])->name('reports.cash-flow-forecast.export'); + + // Currencies — set-base BEFORE resource + Route::post('currencies/{currency}/set-base', [CurrencyController::class, 'setBase'])->name('currencies.set-base'); + Route::resource('currencies', CurrencyController::class)->except(['show']); + + // Exchange Rates — convert and report BEFORE resource to avoid them being treated as IDs + Route::get('exchange-rates/convert', [ExchangeRateController::class, 'convert'])->name('exchange-rates.convert'); + Route::get('exchange-rates/report', [ExchangeRateController::class, 'report'])->name('exchange-rates.report'); + Route::resource('exchange-rates', ExchangeRateController::class)->only(['index', 'create', 'store', 'destroy']); + + // Budgets — custom actions BEFORE resource + Route::post('budgets/{budget}/activate', [BudgetController::class, 'activate'])->name('budgets.activate'); + Route::post('budgets/{budget}/close', [BudgetController::class, 'close'])->name('budgets.close'); + Route::post('budgets/{budget}/lines', [BudgetController::class, 'addLine'])->name('budgets.lines.add'); + Route::patch('budgets/{budget}/lines/{line}/actual', [BudgetController::class, 'updateActual'])->name('budgets.lines.actual'); + Route::delete('budgets/{budget}/lines/{line}', [BudgetController::class, 'removeLine'])->name('budgets.lines.remove'); + Route::resource('budgets', BudgetController::class); + + // Budget Lines (legacy routes kept for backwards compatibility) + Route::patch('budget-lines/{budgetLine}', [BudgetLineController::class, 'update'])->name('budget-lines.update'); + Route::delete('budget-lines/{budgetLine}', [BudgetLineController::class, 'destroy'])->name('budget-lines.destroy'); + + // Bank Accounts + Route::resource('bank-accounts', BankAccountController::class); + Route::post('bank-accounts/{bankAccount}/import', [BankStatementController::class, 'import'])->name('bank-accounts.import'); + + // Reconciliation + Route::get('reconciliation', [ReconciliationController::class, 'index'])->name('reconciliation.index'); + Route::post('reconciliation/{bankTransaction}/match', [ReconciliationController::class, 'match'])->name('reconciliation.match'); + Route::post('reconciliation/{bankTransaction}/unmatch', [ReconciliationController::class, 'unmatch'])->name('reconciliation.unmatch'); + + // Bank Transactions + Route::patch('bank-transactions/{bankTransaction}/reconcile', [BankTransactionController::class, 'reconcile'])->name('bank-transactions.reconcile'); + Route::resource('bank-transactions', BankTransactionController::class)->except(['show', 'edit', 'update']); + + // Bank Reconciliations + Route::post('bank-reconciliations/{bankReconciliation}/complete', [BankReconciliationController::class, 'complete'])->name('bank-reconciliations.complete'); + Route::resource('bank-reconciliations', BankReconciliationController::class)->except(['edit', 'update']); + + // Fixed Assets + Route::post('fixed-assets/{fixedAsset}/depreciate', [FixedAssetController::class, 'depreciate'])->name('fixed-assets.depreciate'); + Route::post('fixed-assets/{fixedAsset}/dispose', [FixedAssetController::class, 'dispose'])->name('fixed-assets.dispose'); + Route::resource('fixed-assets', FixedAssetController::class)->except(['edit', 'update']); + + // Price Lists + Route::get('price-lists/price-for-contact', [PriceListController::class, 'priceForContact'])->name('price-lists.price-for-contact'); + Route::post('price-lists/{priceList}/items', [PriceListController::class, 'addItem'])->name('price-lists.items.add'); + Route::delete('price-lists/{priceList}/items/{item}', [PriceListController::class, 'removeItem'])->name('price-lists.items.remove'); + Route::resource('price-lists', PriceListController::class)->except(['edit']); + + // Projects — actions before resource + Route::post('projects/{project}/activate', [ProjectController::class, 'activate'])->name('projects.activate'); + Route::post('projects/{project}/complete', [ProjectController::class, 'complete'])->name('projects.complete'); + Route::post('projects/{project}/tasks', [ProjectController::class, 'addTask'])->name('projects.tasks.add'); + Route::patch('projects/{project}/tasks/{task}', [ProjectTaskController::class, 'update'])->name('projects.tasks.update'); + Route::delete('projects/{project}/tasks/{task}', [ProjectTaskController::class, 'destroy'])->name('projects.tasks.destroy'); + Route::post('projects/{project}/time-entries', [ProjectController::class, 'addTimeEntry'])->name('projects.time-entries.add'); + Route::resource('projects', ProjectController::class)->except(['edit', 'update']); + + // Batch Payments + Route::resource('batch-payments', BatchPaymentController::class)->except(['edit', 'update']); + + // File Attachments + Route::post('attachments/{modelType}/{modelId}', [AttachmentController::class, 'store'])->name('attachments.store'); + Route::get('attachments/{attachment}/download', [AttachmentController::class, 'download'])->name('attachments.download'); + Route::delete('attachments/{attachment}', [AttachmentController::class, 'destroy'])->name('attachments.destroy'); + // Delivery Notes + Route::resource('delivery-notes', DeliveryNoteController::class)->except(['edit', 'update']); + Route::post('delivery-notes/{deliveryNote}/dispatch', [DeliveryNoteController::class, 'dispatch'])->name('delivery-notes.dispatch'); + Route::post('delivery-notes/{deliveryNote}/deliver', [DeliveryNoteController::class, 'deliver'])->name('delivery-notes.deliver'); + + // Vendor Profiles (nested under contacts) + Route::get('vendors/{contact}/profile', [VendorProfileController::class, 'show'])->name('vendors.profile.show'); + Route::put('vendors/{contact}/profile', [VendorProfileController::class, 'update'])->name('vendors.profile.update'); + + // Vendor Evaluations (nested under contacts) + Route::get('vendors/{contact}/evaluations', [VendorEvaluationController::class, 'index'])->name('vendors.evaluations.index'); + Route::post('vendors/{contact}/evaluations', [VendorEvaluationController::class, 'store'])->name('vendors.evaluations.store'); + Route::delete('vendors/{contact}/evaluations/{evaluation}', [VendorEvaluationController::class, 'destroy'])->name('vendors.evaluations.destroy'); + + // Document Templates + Route::get('document-templates/preview', [DocumentTemplateController::class, 'preview'])->name('document-templates.preview'); + Route::resource('document-templates', DocumentTemplateController::class)->except(['show']); + Route::get('document-templates/{documentTemplate}', [DocumentTemplateController::class, 'show'])->name('document-templates.show'); + + // Subscription Plans + Route::resource('subscription-plans', SubscriptionPlanController::class)->except(['edit', 'update']); + + // Subscriptions + Route::post('subscriptions/{subscription}/activate', [SubscriptionController::class, 'activate'])->name('subscriptions.activate'); + Route::post('subscriptions/{subscription}/cancel', [SubscriptionController::class, 'cancel'])->name('subscriptions.cancel'); + Route::post('subscriptions/{subscription}/pause', [SubscriptionController::class, 'pause'])->name('subscriptions.pause'); + Route::post('subscriptions/{subscription}/generate-invoice',[SubscriptionController::class, 'generateInvoice'])->name('subscriptions.generate-invoice'); + Route::resource('subscriptions', SubscriptionController::class)->except(['edit', 'update']); + + // Customer Portal Token (admin generates token for a contact) + Route::post('contacts/{contact}/portal-token', [\App\Modules\Finance\Http\Controllers\CustomerPortalController::class, 'generateToken'])->name('contacts.portal-token'); + + + // Commission Rules + Route::resource('commission-rules', CommissionRuleController::class)->except(['edit', 'update']); + + // Commissions + Route::post('commissions/{commission}/approve', [CommissionController::class, 'approve'])->name('commissions.approve'); + Route::post('commissions/{commission}/mark-paid',[CommissionController::class, 'markPaid'])->name('commissions.mark-paid'); + Route::post('commissions/generate', [CommissionController::class, 'generate'])->name('commissions.generate'); + Route::resource('commissions', CommissionController::class)->except(['edit', 'update']); + + + // Contracts — custom actions BEFORE resource + Route::post('contracts/{contract}/activate', [ContractController::class, 'activate'])->name('contracts.activate'); + Route::post('contracts/{contract}/terminate', [ContractController::class, 'terminate'])->name('contracts.terminate'); + Route::post('contracts/{contract}/renew', [ContractController::class, 'renew'])->name('contracts.renew'); + Route::resource('contracts', ContractController::class)->only(['index', 'create', 'store', 'show', 'edit', 'update', 'destroy']); + + // Return Requests — custom actions BEFORE resource + Route::post('return-requests/{returnRequest}/approve', [ReturnRequestController::class, 'approve'])->name('return-requests.approve'); + Route::post('return-requests/{returnRequest}/reject', [ReturnRequestController::class, 'reject'])->name('return-requests.reject'); + Route::post('return-requests/{returnRequest}/mark-refunded', [ReturnRequestController::class, 'markRefunded'])->name('return-requests.mark-refunded'); + Route::resource('return-requests', ReturnRequestController::class)->except(['edit', 'update']); + // Tax Rates + Route::resource('tax-rates', TaxRateController::class)->except(['edit', 'update']); + + // Tax Groups — custom actions BEFORE resource + Route::post('tax-groups/{taxGroup}/rates', [TaxGroupController::class, 'addRate'])->name('tax-groups.rates.add'); + Route::delete('tax-groups/{taxGroup}/rates/{item}', [TaxGroupController::class, 'removeRate'])->name('tax-groups.rates.remove'); + Route::resource('tax-groups', TaxGroupController::class)->except(['edit', 'update']); + + // Service Agreements — custom actions BEFORE resource + Route::post('service-agreements/{serviceAgreement}/activate', [ServiceAgreementController::class, 'activate'])->name('service-agreements.activate'); + Route::post('service-agreements/{serviceAgreement}/terminate', [ServiceAgreementController::class, 'terminate'])->name('service-agreements.terminate'); + Route::post('service-agreements/{serviceAgreement}/items', [ServiceAgreementController::class, 'addItem'])->name('service-agreements.items.add'); + Route::post('service-agreements/{serviceAgreement}/logs', [ServiceAgreementController::class, 'addLog'])->name('service-agreements.logs.add'); + Route::post('service-agreements/{serviceAgreement}/logs/{log}/complete', [ServiceAgreementController::class, 'completeLog'])->name('service-agreements.logs.complete'); + Route::resource('service-agreements', ServiceAgreementController::class)->except(['edit', 'update']); + + // Loyalty Programs + Route::post("loyalty-programs/{loyaltyProgram}/enroll", [LoyaltyProgramController::class, "enroll"])->name("loyalty-programs.enroll"); + Route::post("loyalty-programs/{loyaltyProgram}/earn-points", [LoyaltyProgramController::class, "earnPoints"])->name("loyalty-programs.earn-points"); + Route::post("loyalty-programs/{loyaltyProgram}/redeem-points", [LoyaltyProgramController::class, "redeemPoints"])->name("loyalty-programs.redeem-points"); + Route::resource("loyalty-programs", LoyaltyProgramController::class)->except(["edit", "update"]); + + // Leads + Route::post('leads/{lead}/mark-won', [LeadController::class, 'markWon'])->name('leads.mark-won'); + Route::post('leads/{lead}/mark-lost', [LeadController::class, 'markLost'])->name('leads.mark-lost'); + Route::post('leads/{lead}/activities', [LeadController::class, 'addActivity'])->name('leads.activities.add'); + Route::resource('leads', LeadController::class)->except(['edit']); + + // Support Tickets + Route::post('support-tickets/{supportTicket}/resolve', [SupportTicketController::class, 'resolve'])->name('support-tickets.resolve'); + Route::post('support-tickets/{supportTicket}/close', [SupportTicketController::class, 'close'])->name('support-tickets.close'); + Route::post('support-tickets/{supportTicket}/reopen', [SupportTicketController::class, 'reopen'])->name('support-tickets.reopen'); + Route::patch('support-tickets/{supportTicket}/assign', [SupportTicketController::class, 'assign'])->name('support-tickets.assign'); + Route::post('support-tickets/{supportTicket}/comments', [SupportTicketController::class, 'addComment'])->name('support-tickets.comments.add'); + Route::resource('support-tickets', SupportTicketController::class)->except(['edit', 'update']); + + // Customer Groups — custom member actions BEFORE resource + Route::post('customer-groups/{customerGroup}/members', [CustomerGroupController::class, 'addMember'])->name('finance.customer-groups.members.add'); + Route::delete('customer-groups/{customerGroup}/members/{contact}', [CustomerGroupController::class, 'removeMember'])->name('finance.customer-groups.members.remove'); + Route::resource('customer-groups', CustomerGroupController::class)->except(['create', 'edit']); + + // Multi-Currency Consolidation + Route::get('multi-currency/report', [MultiCurrencyReportController::class, 'index'])->name('multi-currency.report'); + Route::get('multi-currency/convert', [MultiCurrencyReportController::class, 'convertPreview'])->name('multi-currency.convert'); + +}); + + +// Expense Claims — custom actions BEFORE resource +use App\Modules\Finance\Http\Controllers\ExpenseClaimController; +Route::middleware(['web', 'auth', 'verified'])->prefix('finance')->name('finance.')->group(function () { + Route::post('expense-claims/{expenseClaim}/submit', [ExpenseClaimController::class, 'submit'])->name('expense-claims.submit'); + Route::post('expense-claims/{expenseClaim}/approve', [ExpenseClaimController::class, 'approve'])->name('expense-claims.approve'); + Route::post('expense-claims/{expenseClaim}/reject', [ExpenseClaimController::class, 'reject'])->name('expense-claims.reject'); + Route::post('expense-claims/{expenseClaim}/mark-paid',[ExpenseClaimController::class, 'markPaid'])->name('expense-claims.mark-paid'); + Route::resource('expense-claims', ExpenseClaimController::class)->only(['index', 'create', 'store', 'show', 'destroy']); +}); + +// Vendor Bills — custom actions BEFORE resource +use App\Modules\Finance\Http\Controllers\VendorBillController; +Route::middleware(['web', 'auth', 'verified'])->prefix('finance')->name('finance.')->group(function () { + Route::post('vendor-bills/{vendorBill}/submit', [VendorBillController::class, 'submit'])->name('vendor-bills.submit'); + Route::post('vendor-bills/{vendorBill}/approve', [VendorBillController::class, 'approve'])->name('vendor-bills.approve'); + Route::post('vendor-bills/{vendorBill}/pay', [VendorBillController::class, 'pay'])->name('vendor-bills.pay'); + Route::post('vendor-bills/{vendorBill}/cancel', [VendorBillController::class, 'cancel'])->name('vendor-bills.cancel'); + Route::resource('vendor-bills', VendorBillController::class); +}); + +// Payment Terms +Route::middleware(['web', 'auth', 'verified'])->prefix('finance')->name('finance.')->group(function () { + Route::resource('payment-terms', PaymentTermController::class)->except(['create', 'edit']); +}); + +// Petty Cash — custom actions BEFORE resource +use App\Modules\Finance\Http\Controllers\PettyCashFundController; +Route::middleware(['web', 'auth', 'verified'])->prefix('finance')->name('finance.')->group(function () { + Route::post('petty-cash/{pettyCashFund}/replenish', [PettyCashFundController::class, 'replenish'])->name('petty-cash.replenish'); + Route::post('petty-cash/{pettyCashFund}/expense', [PettyCashFundController::class, 'expense'])->name('petty-cash.expense'); + Route::resource('petty-cash', PettyCashFundController::class)->except(['create', 'edit', 'update']); +}); + +// Bank Transfers — custom actions BEFORE resource +use App\Modules\Finance\Http\Controllers\BankTransferController; +Route::middleware(['web', 'auth', 'verified'])->prefix('finance')->name('finance.')->group(function () { + Route::post('bank-transfers/{bankTransfer}/complete', [BankTransferController::class, 'complete'])->name('bank-transfers.complete'); + Route::post('bank-transfers/{bankTransfer}/fail', [BankTransferController::class, 'fail'])->name('bank-transfers.fail'); + Route::post('bank-transfers/{bankTransfer}/cancel', [BankTransferController::class, 'cancel'])->name('bank-transfers.cancel'); + Route::resource('bank-transfers', BankTransferController::class)->except(['create', 'edit', 'update']); +}); + +// Advance Payments — custom actions BEFORE resource +use App\Modules\Finance\Http\Controllers\AdvancePaymentController; +Route::middleware(['web', 'auth', 'verified'])->prefix('finance')->name('finance.')->group(function () { + Route::post('advance-payments/{advancePayment}/apply', [AdvancePaymentController::class, 'apply'])->name('advance-payments.apply'); + Route::post('advance-payments/{advancePayment}/refund', [AdvancePaymentController::class, 'refund'])->name('advance-payments.refund'); + Route::resource('advance-payments', AdvancePaymentController::class)->except(['create', 'edit', 'update']); +}); + + +// Debit Notes — custom actions BEFORE resource +use App\Modules\Finance\Http\Controllers\DebitNoteController; +Route::middleware(['web', 'auth', 'verified'])->prefix('finance')->name('finance.')->group(function () { + Route::post('debit-notes/{debitNote}/issue', [DebitNoteController::class, 'issue'])->name('debit-notes.issue'); + Route::post('debit-notes/{debitNote}/apply', [DebitNoteController::class, 'apply'])->name('debit-notes.apply'); + Route::post('debit-notes/{debitNote}/void', [DebitNoteController::class, 'void'])->name('debit-notes.void'); + Route::resource('debit-notes', DebitNoteController::class)->only(['index', 'store', 'show', 'destroy']); +}); + +// Write-offs — custom actions BEFORE resource +use App\Modules\Finance\Http\Controllers\WriteOffController; +Route::middleware(['web', 'auth', 'verified'])->prefix('finance')->name('finance.')->group(function () { + Route::post('write-offs/{writeOff}/approve', [WriteOffController::class, 'approve'])->name('write-offs.approve'); + Route::post('write-offs/{writeOff}/reverse', [WriteOffController::class, 'reverse'])->name('write-offs.reverse'); + Route::resource('write-offs', WriteOffController::class)->only(['index', 'store', 'show', 'destroy']); +}); + +// Intercompany Transactions — custom actions BEFORE resource +use App\Modules\Finance\Http\Controllers\IntercompanyController; +Route::middleware(['web', 'auth', 'verified'])->prefix('finance')->name('finance.')->group(function () { + Route::post('intercompany/{intercompany}/post', [IntercompanyController::class, 'post'])->name('intercompany.post'); + Route::post('intercompany/{intercompany}/reconcile', [IntercompanyController::class, 'reconcile'])->name('intercompany.reconcile'); + Route::post('intercompany/{intercompany}/reverse', [IntercompanyController::class, 'reverse'])->name('intercompany.reverse'); + Route::resource('intercompany', IntercompanyController::class)->only(['index', 'store', 'show', 'destroy']); +}); + +// Cash Flow Forecasts +use App\Modules\Finance\Http\Controllers\CashFlowForecastController; +Route::middleware(['web', 'auth', 'verified'])->prefix('finance')->name('finance.')->group(function () { + Route::post('cash-flow-forecasts/{cash_flow_forecast}/publish', [CashFlowForecastController::class, 'publish'])->name('cash-flow-forecasts.publish'); + Route::post('cash-flow-forecasts/{cash_flow_forecast}/archive', [CashFlowForecastController::class, 'archive'])->name('cash-flow-forecasts.archive'); + Route::resource('cash-flow-forecasts', CashFlowForecastController::class); +}); + +// Recurring Expenses +use App\Modules\Finance\Http\Controllers\RecurringExpenseController; +Route::middleware(['web', 'auth', 'verified'])->prefix('finance')->name('finance.')->group(function () { + Route::post('recurring-expenses/{recurring_expense}/pause', [RecurringExpenseController::class, 'pause'])->name('recurring-expenses.pause'); + Route::post('recurring-expenses/{recurring_expense}/resume', [RecurringExpenseController::class, 'resume'])->name('recurring-expenses.resume'); + Route::post('recurring-expenses/{recurring_expense}/cancel', [RecurringExpenseController::class, 'cancel'])->name('recurring-expenses.cancel'); + Route::resource('recurring-expenses', RecurringExpenseController::class); +}); + +// Vendor Payments +use App\Modules\Finance\Http\Controllers\VendorPaymentController; +Route::middleware(['web', 'auth', 'verified'])->prefix('finance')->name('finance.')->group(function () { + Route::post('vendor-payments/{vendor_payment}/approve', [VendorPaymentController::class, 'approve'])->name('vendor-payments.approve'); + Route::post('vendor-payments/{vendor_payment}/process', [VendorPaymentController::class, 'process'])->name('vendor-payments.process'); + Route::post('vendor-payments/{vendor_payment}/reject', [VendorPaymentController::class, 'reject'])->name('vendor-payments.reject'); + Route::post('vendor-payments/{vendor_payment}/cancel', [VendorPaymentController::class, 'cancel'])->name('vendor-payments.cancel'); + Route::resource('vendor-payments', VendorPaymentController::class); +}); + +// Payment Schedules +use App\Modules\Finance\Http\Controllers\PaymentScheduleController; +Route::middleware(['web', 'auth', 'verified'])->prefix('finance')->name('finance.')->group(function () { + Route::post('payment-schedules/{payment_schedule}/pause', [PaymentScheduleController::class, 'pause'])->name('payment-schedules.pause'); + Route::post('payment-schedules/{payment_schedule}/resume', [PaymentScheduleController::class, 'resume'])->name('payment-schedules.resume'); + Route::post('payment-schedules/{payment_schedule}/cancel', [PaymentScheduleController::class, 'cancel'])->name('payment-schedules.cancel'); + Route::resource('payment-schedules', PaymentScheduleController::class); +}); + +// Customer Credits +use App\Modules\Finance\Http\Controllers\CustomerCreditController; +Route::middleware(['web', 'auth', 'verified'])->prefix('finance')->name('finance.')->group(function () { + Route::post('customer-credits/{customer_credit}/issue', [CustomerCreditController::class, 'issue'])->name('customer-credits.issue'); + Route::post('customer-credits/{customer_credit}/expire', [CustomerCreditController::class, 'expire'])->name('customer-credits.expire'); + Route::post('customer-credits/{customer_credit}/cancel', [CustomerCreditController::class, 'cancel'])->name('customer-credits.cancel'); + Route::resource('customer-credits', CustomerCreditController::class); +}); + +// Expense Budgets +use App\Modules\Finance\Http\Controllers\ExpenseBudgetController; +Route::middleware(['web', 'auth', 'verified'])->prefix('finance')->name('finance.')->group(function () { + Route::post('expense-budgets/{expense_budget}/freeze', [ExpenseBudgetController::class, 'freeze'])->name('expense-budgets.freeze'); + Route::post('expense-budgets/{expense_budget}/close', [ExpenseBudgetController::class, 'close'])->name('expense-budgets.close'); + Route::resource('expense-budgets', ExpenseBudgetController::class); +}); + +// Profit Centers +use App\Modules\Finance\Http\Controllers\ProfitCenterController; +Route::middleware(['web', 'auth', 'verified'])->prefix('finance')->name('finance.')->group(function () { + Route::post('profit-centers/{profit_center}/activate', [ProfitCenterController::class, 'activate'])->name('profit-centers.activate'); + Route::post('profit-centers/{profit_center}/deactivate', [ProfitCenterController::class, 'deactivate'])->name('profit-centers.deactivate'); + Route::resource('profit-centers', ProfitCenterController::class); +}); diff --git a/erp/app/Modules/Fleet/Http/Controllers/FleetDashboardController.php b/erp/app/Modules/Fleet/Http/Controllers/FleetDashboardController.php new file mode 100644 index 00000000000..56a318b8437 --- /dev/null +++ b/erp/app/Modules/Fleet/Http/Controllers/FleetDashboardController.php @@ -0,0 +1,51 @@ +count(); + $inService = Vehicle::where('status', 'in_service')->count(); + + $monthlyFuelCost = FuelLog::whereYear('log_date', now()->year) + ->whereMonth('log_date', now()->month) + ->sum('total_cost'); + + $expiringInsurance = Vehicle::whereNotNull('insurance_expiry') + ->whereDate('insurance_expiry', '>', now()) + ->whereDate('insurance_expiry', '<=', now()->addDays(30)) + ->count(); + + $upcomingMaintenances = VehicleMaintenance::where('status', 'scheduled') + ->whereDate('due_date', '>=', now()) + ->whereDate('due_date', '<=', now()->addDays(30)) + ->count(); + + $recentFuelLogs = FuelLog::with(['vehicle', 'driver']) + ->orderByDesc('log_date') + ->limit(5) + ->get(); + + return Inertia::render('Fleet/Dashboard', [ + 'stats' => [ + 'totalVehicles' => $totalVehicles, + 'activeVehicles' => $activeVehicles, + 'inService' => $inService, + 'monthlyFuelCost' => (float) $monthlyFuelCost, + 'expiringInsurance' => $expiringInsurance, + 'upcomingMaintenances' => $upcomingMaintenances, + ], + 'recentFuelLogs' => $recentFuelLogs, + ]); + } +} diff --git a/erp/app/Modules/Fleet/Http/Controllers/FuelLogController.php b/erp/app/Modules/Fleet/Http/Controllers/FuelLogController.php new file mode 100644 index 00000000000..96fb80b5b64 --- /dev/null +++ b/erp/app/Modules/Fleet/Http/Controllers/FuelLogController.php @@ -0,0 +1,63 @@ +when($request->vehicle_id, fn ($q) => $q->where('vehicle_id', $request->vehicle_id)) + ->orderByDesc('log_date') + ->paginate(25) + ->withQueryString(); + + return Inertia::render('Fleet/FuelLogs/Index', [ + 'fuelLogs' => $fuelLogs, + 'vehicles' => Vehicle::orderBy('name')->get(['id', 'name']), + 'filters' => $request->only(['vehicle_id']), + ]); + } + + public function store(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'vehicle_id' => 'required|exists:fleet_vehicles,id', + 'log_date' => 'required|date', + 'odometer_km' => 'required|numeric|min:0', + 'liters' => 'required|numeric|min:0', + 'cost_per_liter' => 'required|numeric|min:0', + 'total_cost' => 'nullable|numeric|min:0', + 'fuel_type' => 'nullable|string|max:50', + 'station' => 'nullable|string|max:255', + 'driver_id' => 'nullable|exists:users,id', + 'notes' => 'nullable|string', + ]); + + if (empty($validated['total_cost'])) { + $validated['total_cost'] = round($validated['liters'] * $validated['cost_per_liter'], 2); + } + + FuelLog::create([ + ...$validated, + 'tenant_id' => auth()->user()->tenant_id, + ]); + + return redirect()->back()->with('success', 'Fuel log added.'); + } + + public function destroy(FuelLog $fuelLog): RedirectResponse + { + $fuelLog->delete(); + + return redirect()->back()->with('success', 'Fuel log deleted.'); + } +} diff --git a/erp/app/Modules/Fleet/Http/Controllers/VehicleController.php b/erp/app/Modules/Fleet/Http/Controllers/VehicleController.php new file mode 100644 index 00000000000..a1e2464c9dc --- /dev/null +++ b/erp/app/Modules/Fleet/Http/Controllers/VehicleController.php @@ -0,0 +1,130 @@ +withCount([ + 'maintenances as due_soon_count' => function ($q) { + $q->where('status', 'scheduled') + ->whereDate('due_date', '>=', now()) + ->whereDate('due_date', '<=', now()->addDays(30)); + }, + ]) + ->orderBy('name') + ->get(); + + return Inertia::render('Fleet/Vehicles/Index', [ + 'vehicles' => $vehicles, + ]); + } + + public function create(): Response + { + return Inertia::render('Fleet/Vehicles/Create', [ + 'users' => User::orderBy('name')->get(['id', 'name']), + ]); + } + + public function store(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'plate_number' => 'nullable|string|max:50', + 'make' => 'nullable|string|max:100', + 'model' => 'nullable|string|max:100', + 'year' => 'nullable|integer|min:1900|max:2100', + 'color' => 'nullable|string|max:50', + 'vin' => 'nullable|string|max:50', + 'type' => 'required|in:car,truck,van,motorcycle,other', + 'status' => 'required|in:active,in_service,out_of_service,sold', + 'odometer_km' => 'nullable|numeric|min:0', + 'fuel_type' => 'required|in:petrol,diesel,electric,hybrid', + 'assigned_to' => 'nullable|exists:users,id', + 'insurance_expiry' => 'nullable|date', + 'registration_expiry' => 'nullable|date', + 'notes' => 'nullable|string', + ]); + + $vehicle = Vehicle::create([ + ...$validated, + 'tenant_id' => auth()->user()->tenant_id, + ]); + + return redirect()->route('fleet.vehicles.show', $vehicle)->with('success', 'Vehicle created.'); + } + + public function show(Vehicle $vehicle): Response + { + $vehicle->load(['assignedDriver', 'fuelLogs.driver', 'maintenances', 'assignments.driver']); + + $recentFuelLogs = $vehicle->fuelLogs() + ->with('driver') + ->orderByDesc('log_date') + ->limit(5) + ->get(); + + $upcomingMaintenances = $vehicle->maintenances() + ->whereIn('status', ['scheduled', 'in_progress']) + ->orderBy('due_date') + ->get(); + + return Inertia::render('Fleet/Vehicles/Show', [ + 'vehicle' => $vehicle, + 'recentFuelLogs' => $recentFuelLogs, + 'upcomingMaintenances' => $upcomingMaintenances, + 'users' => User::orderBy('name')->get(['id', 'name']), + ]); + } + + public function edit(Vehicle $vehicle): Response + { + return Inertia::render('Fleet/Vehicles/Edit', [ + 'vehicle' => $vehicle, + 'users' => User::orderBy('name')->get(['id', 'name']), + ]); + } + + public function update(Request $request, Vehicle $vehicle): RedirectResponse + { + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'plate_number' => 'nullable|string|max:50', + 'make' => 'nullable|string|max:100', + 'model' => 'nullable|string|max:100', + 'year' => 'nullable|integer|min:1900|max:2100', + 'color' => 'nullable|string|max:50', + 'vin' => 'nullable|string|max:50', + 'type' => 'required|in:car,truck,van,motorcycle,other', + 'status' => 'required|in:active,in_service,out_of_service,sold', + 'odometer_km' => 'nullable|numeric|min:0', + 'fuel_type' => 'required|in:petrol,diesel,electric,hybrid', + 'assigned_to' => 'nullable|exists:users,id', + 'insurance_expiry' => 'nullable|date', + 'registration_expiry' => 'nullable|date', + 'notes' => 'nullable|string', + ]); + + $vehicle->update($validated); + + return redirect()->route('fleet.vehicles.show', $vehicle)->with('success', 'Vehicle updated.'); + } + + public function destroy(Vehicle $vehicle): RedirectResponse + { + $vehicle->delete(); + + return redirect()->route('fleet.vehicles.index')->with('success', 'Vehicle deleted.'); + } +} diff --git a/erp/app/Modules/Fleet/Http/Controllers/VehicleMaintenanceController.php b/erp/app/Modules/Fleet/Http/Controllers/VehicleMaintenanceController.php new file mode 100644 index 00000000000..93bf207de1f --- /dev/null +++ b/erp/app/Modules/Fleet/Http/Controllers/VehicleMaintenanceController.php @@ -0,0 +1,68 @@ +when($request->status, fn ($q) => $q->where('status', $request->status)) + ->when($request->vehicle_id, fn ($q) => $q->where('vehicle_id', $request->vehicle_id)) + ->orderByDesc('service_date') + ->paginate(25) + ->withQueryString(); + + return Inertia::render('Fleet/Maintenances/Index', [ + 'maintenances' => $maintenances, + 'vehicles' => Vehicle::orderBy('name')->get(['id', 'name']), + 'filters' => $request->only(['status', 'vehicle_id']), + ]); + } + + public function store(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'vehicle_id' => 'required|exists:fleet_vehicles,id', + 'type' => 'required|in:scheduled,repair,inspection,other', + 'description' => 'nullable|string', + 'vendor' => 'nullable|string|max:255', + 'service_date' => 'required|date', + 'due_date' => 'nullable|date', + 'odometer_km' => 'nullable|numeric|min:0', + 'cost' => 'nullable|numeric|min:0', + 'status' => 'required|in:scheduled,in_progress,completed,cancelled', + 'notes' => 'nullable|string', + ]); + + VehicleMaintenance::create([ + ...$validated, + 'tenant_id' => auth()->user()->tenant_id, + 'created_by' => auth()->id(), + ]); + + return redirect()->back()->with('success', 'Maintenance record added.'); + } + + public function complete(VehicleMaintenance $maintenance): RedirectResponse + { + $maintenance->complete(); + + return redirect()->back()->with('success', 'Maintenance marked as completed.'); + } + + public function destroy(VehicleMaintenance $maintenance): RedirectResponse + { + $maintenance->delete(); + + return redirect()->back()->with('success', 'Maintenance record deleted.'); + } +} diff --git a/erp/app/Modules/Fleet/Models/FuelLog.php b/erp/app/Modules/Fleet/Models/FuelLog.php new file mode 100644 index 00000000000..677a1758e53 --- /dev/null +++ b/erp/app/Modules/Fleet/Models/FuelLog.php @@ -0,0 +1,47 @@ + 'date', + 'liters' => 'float', + 'cost_per_liter' => 'float', + 'total_cost' => 'float', + 'odometer_km' => 'float', + ]; + + public function vehicle(): BelongsTo + { + return $this->belongsTo(Vehicle::class, 'vehicle_id'); + } + + public function driver(): BelongsTo + { + return $this->belongsTo(User::class, 'driver_id'); + } +} diff --git a/erp/app/Modules/Fleet/Models/Vehicle.php b/erp/app/Modules/Fleet/Models/Vehicle.php new file mode 100644 index 00000000000..586ac403e3c --- /dev/null +++ b/erp/app/Modules/Fleet/Models/Vehicle.php @@ -0,0 +1,85 @@ + 'float', + 'insurance_expiry' => 'date', + 'registration_expiry' => 'date', + ]; + + public function assignedDriver(): BelongsTo + { + return $this->belongsTo(User::class, 'assigned_to'); + } + + public function fuelLogs(): HasMany + { + return $this->hasMany(FuelLog::class, 'vehicle_id'); + } + + public function maintenances(): HasMany + { + return $this->hasMany(VehicleMaintenance::class, 'vehicle_id'); + } + + public function assignments(): HasMany + { + return $this->hasMany(VehicleAssignment::class, 'vehicle_id'); + } + + public function isInsuranceExpiring(int $days = 30): bool + { + return $this->insurance_expiry !== null + && $this->insurance_expiry->diffInDays(now()) <= $days + && $this->insurance_expiry > now(); + } + + public function isRegistrationExpiring(int $days = 30): bool + { + return $this->registration_expiry !== null + && $this->registration_expiry->diffInDays(now()) <= $days + && $this->registration_expiry > now(); + } + + public function totalFuelCost(): float + { + return (float) $this->fuelLogs()->sum('total_cost'); + } + + public function totalMaintenanceCost(): float + { + return (float) $this->maintenances()->sum('cost'); + } +} diff --git a/erp/app/Modules/Fleet/Models/VehicleAssignment.php b/erp/app/Modules/Fleet/Models/VehicleAssignment.php new file mode 100644 index 00000000000..a616b860281 --- /dev/null +++ b/erp/app/Modules/Fleet/Models/VehicleAssignment.php @@ -0,0 +1,55 @@ + 'datetime', + 'returned_at' => 'datetime', + 'start_odometer' => 'float', + 'end_odometer' => 'float', + ]; + + public function vehicle(): BelongsTo + { + return $this->belongsTo(Vehicle::class, 'vehicle_id'); + } + + public function driver(): BelongsTo + { + return $this->belongsTo(User::class, 'driver_id'); + } + + public function return(float $endOdometer = null): void + { + $this->returned_at = now(); + $this->end_odometer = $endOdometer; + $this->save(); + + if ($endOdometer !== null) { + $this->vehicle()->update(['odometer_km' => $endOdometer]); + } + } +} diff --git a/erp/app/Modules/Fleet/Models/VehicleMaintenance.php b/erp/app/Modules/Fleet/Models/VehicleMaintenance.php new file mode 100644 index 00000000000..98ac1206e3a --- /dev/null +++ b/erp/app/Modules/Fleet/Models/VehicleMaintenance.php @@ -0,0 +1,53 @@ + 'date', + 'due_date' => 'date', + 'cost' => 'float', + 'odometer_km' => 'float', + ]; + + public function vehicle(): BelongsTo + { + return $this->belongsTo(Vehicle::class, 'vehicle_id'); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function complete(): void + { + $this->status = 'completed'; + $this->save(); + } +} diff --git a/erp/app/Modules/Fleet/Policies/FleetPolicy.php b/erp/app/Modules/Fleet/Policies/FleetPolicy.php new file mode 100644 index 00000000000..a9d84981bd8 --- /dev/null +++ b/erp/app/Modules/Fleet/Policies/FleetPolicy.php @@ -0,0 +1,33 @@ +can('inventory.create'); + } + + public function update(User $user): bool + { + return $user->can('inventory.create'); + } + + public function delete(User $user): bool + { + return $user->can('inventory.delete'); + } +} diff --git a/erp/app/Modules/Fleet/Providers/FleetServiceProvider.php b/erp/app/Modules/Fleet/Providers/FleetServiceProvider.php new file mode 100644 index 00000000000..f749a64b241 --- /dev/null +++ b/erp/app/Modules/Fleet/Providers/FleetServiceProvider.php @@ -0,0 +1,25 @@ +loadRoutesFrom(__DIR__ . '/../routes/fleet.php'); + Gate::policy(Vehicle::class, FleetPolicy::class); + Gate::policy(FuelLog::class, FleetPolicy::class); + Gate::policy(VehicleMaintenance::class, FleetPolicy::class); + Gate::policy(VehicleAssignment::class, FleetPolicy::class); + } +} diff --git a/erp/app/Modules/Fleet/routes/fleet.php b/erp/app/Modules/Fleet/routes/fleet.php new file mode 100644 index 00000000000..63e8c604bcc --- /dev/null +++ b/erp/app/Modules/Fleet/routes/fleet.php @@ -0,0 +1,20 @@ +prefix('fleet')->name('fleet.')->group(function () { + Route::get('dashboard', [FleetDashboardController::class, 'index'])->name('dashboard'); + + Route::resource('vehicles', VehicleController::class); + + Route::delete('fuel-logs/{fuel_log}', [FuelLogController::class, 'destroy'])->name('fuel-logs.destroy'); + Route::resource('fuel-logs', FuelLogController::class)->only(['index', 'store']); + + Route::post('maintenances/{maintenance}/complete', [VehicleMaintenanceController::class, 'complete'])->name('maintenances.complete'); + Route::delete('maintenances/{maintenance}', [VehicleMaintenanceController::class, 'destroy'])->name('maintenances.destroy'); + Route::resource('maintenances', VehicleMaintenanceController::class)->only(['index', 'store']); +}); diff --git a/erp/app/Modules/Frontdesk/Http/Controllers/FrontdeskController.php b/erp/app/Modules/Frontdesk/Http/Controllers/FrontdeskController.php new file mode 100644 index 00000000000..5957ad22ade --- /dev/null +++ b/erp/app/Modules/Frontdesk/Http/Controllers/FrontdeskController.php @@ -0,0 +1,160 @@ +startOfDay(); + + $stats = [ + 'currently_in' => VisitorLog::where('status', 'checked_in')->count(), + 'expected_today' => VisitorLog::where('status', 'expected') + ->whereDate('expected_at', today()) + ->count(), + 'checked_in_today' => VisitorLog::where('status', 'checked_in') + ->whereDate('check_in_at', today()) + ->count(), + 'checked_out_today' => VisitorLog::where('status', 'checked_out') + ->whereDate('check_out_at', today()) + ->count(), + 'no_shows_today' => VisitorLog::where('status', 'no_show') + ->whereDate('updated_at', today()) + ->count(), + 'stations_count' => FrontdeskStation::where('is_active', true)->count(), + ]; + + $recentVisitors = VisitorLog::with(['station', 'host']) + ->orderByDesc('check_in_at') + ->limit(10) + ->get(); + + return Inertia::render('Frontdesk/Dashboard', [ + 'stats' => $stats, + 'recentVisitors' => $recentVisitors, + ]); + } + + public function stations(): Response + { + $stations = FrontdeskStation::with('responsible')->get(); + + return Inertia::render('Frontdesk/Stations/Index', [ + 'stations' => $stations, + ]); + } + + public function storeStation(Request $request): RedirectResponse + { + $data = $request->validate([ + 'name' => 'required|string|max:255', + 'location' => 'nullable|string|max:255', + 'responsible_id' => 'nullable|exists:users,id', + ]); + + FrontdeskStation::create($data); + + return redirect()->back()->with('success', 'Station created successfully.'); + } + + public function visitors(Request $request): Response + { + $date = $request->input('date', today()->toDateString()); + $status = $request->input('status'); + + $query = VisitorLog::with(['station', 'host']) + ->whereDate('created_at', $date); + + if ($status) { + $query->where('status', $status); + } + + $visitors = $query->orderByDesc('created_at')->paginate(20); + + return Inertia::render('Frontdesk/Visitors/Index', [ + 'visitors' => $visitors, + 'filters' => ['date' => $date, 'status' => $status], + ]); + } + + public function checkIn(Request $request): Response|RedirectResponse + { + if ($request->isMethod('POST')) { + $data = $request->validate([ + 'visitor_name' => 'required|string|max:255', + 'visitor_email' => 'nullable|email|max:255', + 'visitor_phone' => 'nullable|string|max:50', + 'visitor_company' => 'nullable|string|max:255', + 'visit_purpose' => 'nullable|string|max:255', + 'host_employee_id' => 'nullable|exists:users,id', + 'station_id' => 'nullable|exists:frontdesk_stations,id', + 'expected_at' => 'nullable|date', + 'badge_number' => 'nullable|string|max:50', + ]); + + $visitor = VisitorLog::create(array_merge($data, [ + 'status' => 'checked_in', + 'check_in_at' => now(), + ])); + + return redirect()->route('frontdesk.visitors')->with('success', 'Visitor checked in successfully.'); + } + + $stations = FrontdeskStation::where('is_active', true)->get(); + $expectedToday = VisitorLog::where('status', 'expected') + ->whereDate('expected_at', today()) + ->with(['host', 'station']) + ->get(); + + return Inertia::render('Frontdesk/CheckIn', [ + 'stations' => $stations, + 'expectedToday' => $expectedToday, + 'users' => User::orderBy('name')->get(['id', 'name']), + ]); + } + + public function doCheckOut(VisitorLog $visitor): RedirectResponse + { + $visitor->checkOut(); + + return redirect()->back()->with('success', 'Visitor checked out successfully.'); + } + + public function markNoShow(VisitorLog $visitor): RedirectResponse + { + $visitor->markNoShow(); + + return redirect()->back(); + } + + public function preRegister(Request $request): RedirectResponse + { + $data = $request->validate([ + 'visitor_name' => 'required|string|max:255', + 'visitor_email' => 'nullable|email|max:255', + 'visitor_phone' => 'nullable|string|max:50', + 'visitor_company' => 'nullable|string|max:255', + 'visit_purpose' => 'nullable|string|max:255', + 'host_employee_id' => 'nullable|exists:users,id', + 'station_id' => 'nullable|exists:frontdesk_stations,id', + 'expected_at' => 'nullable|date', + 'badge_number' => 'nullable|string|max:50', + ]); + + VisitorLog::create(array_merge($data, [ + 'status' => 'expected', + ])); + + return redirect()->route('frontdesk.visitors')->with('success', 'Visitor pre-registered successfully.'); + } +} diff --git a/erp/app/Modules/Frontdesk/Models/FrontdeskStation.php b/erp/app/Modules/Frontdesk/Models/FrontdeskStation.php new file mode 100644 index 00000000000..16b054d008c --- /dev/null +++ b/erp/app/Modules/Frontdesk/Models/FrontdeskStation.php @@ -0,0 +1,43 @@ + 'boolean', + ]; + + public function visitors(): HasMany + { + return $this->hasMany(VisitorLog::class, 'station_id'); + } + + public function responsible(): BelongsTo + { + return $this->belongsTo(User::class, 'responsible_id'); + } + + public function checkedInCount(): int + { + return $this->visitors()->where('status', 'checked_in')->count(); + } +} diff --git a/erp/app/Modules/Frontdesk/Models/VisitorLog.php b/erp/app/Modules/Frontdesk/Models/VisitorLog.php new file mode 100644 index 00000000000..8161cbaf5c4 --- /dev/null +++ b/erp/app/Modules/Frontdesk/Models/VisitorLog.php @@ -0,0 +1,84 @@ + 'datetime', + 'check_in_at' => 'datetime', + 'check_out_at' => 'datetime', + ]; + + public function station(): BelongsTo + { + return $this->belongsTo(FrontdeskStation::class, 'station_id'); + } + + public function host(): BelongsTo + { + return $this->belongsTo(User::class, 'host_employee_id'); + } + + public function checkIn(): void + { + $this->update([ + 'status' => 'checked_in', + 'check_in_at' => now(), + ]); + } + + public function checkOut(): void + { + $this->update([ + 'status' => 'checked_out', + 'check_out_at' => now(), + ]); + } + + public function markNoShow(): void + { + $this->update(['status' => 'no_show']); + } + + public function durationMinutes(): ?int + { + if ($this->check_in_at && $this->check_out_at) { + return (int) $this->check_in_at->diffInMinutes($this->check_out_at); + } + + return null; + } + + public function generateBadge(): string + { + return 'VIS-' . strtoupper(substr($this->visitor_name, 0, 3)) . '-' . str_pad($this->id, 4, '0', STR_PAD_LEFT); + } +} diff --git a/erp/app/Modules/Frontdesk/Providers/FrontdeskServiceProvider.php b/erp/app/Modules/Frontdesk/Providers/FrontdeskServiceProvider.php new file mode 100644 index 00000000000..5c720538a92 --- /dev/null +++ b/erp/app/Modules/Frontdesk/Providers/FrontdeskServiceProvider.php @@ -0,0 +1,15 @@ +loadRoutesFrom(__DIR__ . '/../routes/frontdesk.php'); + } +} diff --git a/erp/app/Modules/Frontdesk/routes/frontdesk.php b/erp/app/Modules/Frontdesk/routes/frontdesk.php new file mode 100644 index 00000000000..2969c6a6c44 --- /dev/null +++ b/erp/app/Modules/Frontdesk/routes/frontdesk.php @@ -0,0 +1,16 @@ +prefix('frontdesk')->name('frontdesk.')->group(function () { + Route::get('dashboard', [FrontdeskController::class, 'dashboard'])->name('dashboard'); + Route::get('stations', [FrontdeskController::class, 'stations'])->name('stations'); + Route::post('stations', [FrontdeskController::class, 'storeStation'])->name('stations.store'); + Route::get('visitors', [FrontdeskController::class, 'visitors'])->name('visitors'); + Route::get('check-in', [FrontdeskController::class, 'checkIn'])->name('check-in'); + Route::post('check-in', [FrontdeskController::class, 'checkIn'])->name('check-in.store'); + Route::post('visitors/{visitor}/check-out', [FrontdeskController::class, 'doCheckOut'])->name('visitors.check-out'); + Route::post('visitors/{visitor}/no-show', [FrontdeskController::class, 'markNoShow'])->name('visitors.no-show'); + Route::post('pre-register', [FrontdeskController::class, 'preRegister'])->name('pre-register'); +}); diff --git a/erp/app/Modules/HR/Http/Controllers/AttendanceController.php b/erp/app/Modules/HR/Http/Controllers/AttendanceController.php new file mode 100644 index 00000000000..c054558f82b --- /dev/null +++ b/erp/app/Modules/HR/Http/Controllers/AttendanceController.php @@ -0,0 +1,115 @@ +authorize('viewAny', AttendanceRecord::class); + + $query = AttendanceRecord::with('employee'); + + if ($request->filled('employee_id')) { + $query->where('employee_id', $request->employee_id); + } + + if ($request->filled('month')) { + [$year, $month] = explode('-', $request->month); + $query->whereYear('work_date', $year)->whereMonth('work_date', $month); + } + + $records = $query->orderByDesc('work_date')->paginate(20)->withQueryString(); + $employees = Employee::where('status', 'active')->orderBy('first_name')->get(['id', 'first_name', 'last_name']); + + return Inertia::render('HR/Attendance/Index', compact('records', 'employees')); + } + + public function create(): Response + { + $this->authorize('create', AttendanceRecord::class); + + $employees = Employee::where('status', 'active')->orderBy('first_name')->get(['id', 'first_name', 'last_name']); + + return Inertia::render('HR/Attendance/Create', compact('employees')); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', AttendanceRecord::class); + + $data = $request->validate([ + 'employee_id' => ['required', Rule::exists('employees', 'id')], + 'work_date' => [ + 'required', + 'date', + function ($attribute, $value, $fail) use ($request) { + $exists = \Illuminate\Support\Facades\DB::table('attendance_records') + ->whereNull('deleted_at') + ->where('employee_id', $request->input('employee_id')) + ->whereDate('work_date', $value) + ->exists(); + if ($exists) { + $fail('The employee already has an attendance record for this date.'); + } + }, + ], + 'clock_in' => ['nullable', 'date_format:H:i'], + 'clock_out' => ['nullable', 'date_format:H:i', 'after:clock_in'], + 'break_minutes' => ['nullable', 'integer', 'min:0'], + 'status' => ['required', Rule::in(['present', 'absent', 'half_day', 'holiday', 'leave'])], + 'notes' => ['nullable', 'string'], + ]); + + AttendanceRecord::create([ + 'tenant_id' => auth()->user()->tenant_id, + ...$data, + ]); + + return redirect()->route('hr.attendance.index')->with('success', 'Attendance record logged.'); + } + + public function show(AttendanceRecord $attendance): Response + { + $this->authorize('view', $attendance); + + $attendance->load('employee'); + + return Inertia::render('HR/Attendance/Show', compact('attendance')); + } + + public function update(Request $request, AttendanceRecord $attendance): RedirectResponse + { + $this->authorize('update', $attendance); + + $data = $request->validate([ + 'clock_in' => ['nullable', 'date_format:H:i'], + 'clock_out' => ['nullable', 'date_format:H:i', 'after:clock_in'], + 'break_minutes' => ['nullable', 'integer', 'min:0'], + 'status' => ['nullable', Rule::in(['present', 'absent', 'half_day', 'holiday', 'leave'])], + 'notes' => ['nullable', 'string'], + ]); + + $attendance->update($data); + + return back()->with('success', 'Attendance record updated.'); + } + + public function destroy(AttendanceRecord $attendance): RedirectResponse + { + $this->authorize('delete', $attendance); + + $attendance->delete(); + + return redirect()->route('hr.attendance.index')->with('success', 'Attendance record deleted.'); + } +} diff --git a/erp/app/Modules/HR/Http/Controllers/BenefitPlanController.php b/erp/app/Modules/HR/Http/Controllers/BenefitPlanController.php new file mode 100644 index 00000000000..ee6e59cca40 --- /dev/null +++ b/erp/app/Modules/HR/Http/Controllers/BenefitPlanController.php @@ -0,0 +1,76 @@ +authorize('viewAny', BenefitPlan::class); + + $plans = BenefitPlan::query() + ->when($request->type, fn ($q) => $q->where('type', $request->type)) + ->when($request->has('is_active') && $request->is_active !== null, fn ($q) => $q->where('is_active', $request->boolean('is_active'))) + ->orderBy('name') + ->paginate(20) + ->withQueryString(); + + return Inertia::render('HR/BenefitPlans/Index', [ + 'plans' => $plans, + 'filters' => $request->only(['type', 'is_active']), + ]); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', BenefitPlan::class); + + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'type' => 'required|in:health,dental,vision,life,retirement,other', + 'employee_cost' => 'nullable|numeric|min:0', + 'employer_cost' => 'nullable|numeric|min:0', + 'is_active' => 'nullable|boolean', + 'description' => 'nullable|string', + ]); + + BenefitPlan::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'name' => $validated['name'], + 'type' => $validated['type'], + 'employee_cost' => $validated['employee_cost'] ?? 0, + 'employer_cost' => $validated['employer_cost'] ?? 0, + 'is_active' => $validated['is_active'] ?? true, + 'description' => $validated['description'] ?? null, + ]); + + return redirect()->back(); + } + + public function show(BenefitPlan $benefitPlan): Response + { + $this->authorize('view', $benefitPlan); + + $benefitPlan->load('enrollments.employee'); + + return Inertia::render('HR/BenefitPlans/Show', [ + 'plan' => $benefitPlan, + ]); + } + + public function destroy(BenefitPlan $benefitPlan): RedirectResponse + { + $this->authorize('delete', $benefitPlan); + + $benefitPlan->delete(); + + return redirect()->back(); + } +} diff --git a/erp/app/Modules/HR/Http/Controllers/CompetencyFrameworkController.php b/erp/app/Modules/HR/Http/Controllers/CompetencyFrameworkController.php new file mode 100644 index 00000000000..b79cbf3fecc --- /dev/null +++ b/erp/app/Modules/HR/Http/Controllers/CompetencyFrameworkController.php @@ -0,0 +1,118 @@ +authorize('viewAny', CompetencyFramework::class); + + $frameworks = CompetencyFramework::with('competencies') + ->latest() + ->paginate(25) + ->withQueryString(); + + return Inertia::render('HR/CompetencyFrameworks/Index', [ + 'frameworks' => $frameworks, + ]); + } + + public function create(): Response + { + $this->authorize('create', CompetencyFramework::class); + + return Inertia::render('HR/CompetencyFrameworks/Create'); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', CompetencyFramework::class); + + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'description' => ['nullable', 'string'], + ]); + + CompetencyFramework::create([ + ...$validated, + 'tenant_id' => app('tenant')->id, + 'created_by' => auth()->id(), + ]); + + return redirect()->route('hr.competency-frameworks.index') + ->with('success', 'Competency framework created.'); + } + + public function show(CompetencyFramework $competencyFramework): Response + { + $this->authorize('view', $competencyFramework); + + $competencyFramework->load('competencies'); + + return Inertia::render('HR/CompetencyFrameworks/Show', [ + 'framework' => $competencyFramework, + ]); + } + + public function edit(CompetencyFramework $competencyFramework): Response + { + $this->authorize('update', $competencyFramework); + + return Inertia::render('HR/CompetencyFrameworks/Edit', [ + 'framework' => $competencyFramework, + ]); + } + + public function update(Request $request, CompetencyFramework $competencyFramework): RedirectResponse + { + $this->authorize('update', $competencyFramework); + + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'description' => ['nullable', 'string'], + ]); + + $competencyFramework->update($validated); + + return redirect()->route('hr.competency-frameworks.index') + ->with('success', 'Competency framework updated.'); + } + + public function destroy(CompetencyFramework $competencyFramework): RedirectResponse + { + $this->authorize('delete', $competencyFramework); + + $competencyFramework->delete(); + + return redirect()->route('hr.competency-frameworks.index') + ->with('success', 'Competency framework deleted.'); + } + + public function activate(CompetencyFramework $competencyFramework): RedirectResponse + { + $this->authorize('activate', $competencyFramework); + + $competencyFramework->activate(); + + return redirect()->route('hr.competency-frameworks.index') + ->with('success', 'Competency framework activated.'); + } + + public function archive(CompetencyFramework $competencyFramework): RedirectResponse + { + $this->authorize('archive', $competencyFramework); + + $competencyFramework->archive(); + + return redirect()->route('hr.competency-frameworks.index') + ->with('success', 'Competency framework archived.'); + } +} diff --git a/erp/app/Modules/HR/Http/Controllers/DepartmentController.php b/erp/app/Modules/HR/Http/Controllers/DepartmentController.php new file mode 100644 index 00000000000..bf0220c7758 --- /dev/null +++ b/erp/app/Modules/HR/Http/Controllers/DepartmentController.php @@ -0,0 +1,108 @@ +authorize('viewAny', Department::class); + + $departments = Department::withCount('employees') + ->orderBy('name') + ->get(); + + return Inertia::render('HR/Departments/Index', [ + 'departments' => DepartmentResource::collection($departments), + 'breadcrumbs' => [ + ['label' => 'HR'], + ['label' => 'Departments', 'href' => route('hr.departments.index')], + ], + ]); + } + + public function create(): Response + { + $this->authorize('create', Department::class); + + return Inertia::render('HR/Departments/Create', [ + 'breadcrumbs' => [ + ['label' => 'HR'], + ['label' => 'Departments', 'href' => route('hr.departments.index')], + ['label' => 'New Department'], + ], + ]); + } + + public function store(StoreDepartmentRequest $request): RedirectResponse + { + $this->authorize('create', Department::class); + + $department = Department::create([ + ...$request->validated(), + 'tenant_id' => auth()->user()->tenant_id, + ]); + + return redirect()->route('hr.departments.show', $department) + ->with('success', 'Department created.'); + } + + public function show(Department $department): Response + { + $this->authorize('view', $department); + + $department->loadCount('employees'); + + return Inertia::render('HR/Departments/Show', [ + 'department' => new DepartmentResource($department), + 'breadcrumbs' => [ + ['label' => 'HR'], + ['label' => 'Departments', 'href' => route('hr.departments.index')], + ['label' => $department->name], + ], + ]); + } + + public function edit(Department $department): Response + { + $this->authorize('update', $department); + + return Inertia::render('HR/Departments/Edit', [ + 'department' => new DepartmentResource($department), + 'breadcrumbs' => [ + ['label' => 'HR'], + ['label' => 'Departments', 'href' => route('hr.departments.index')], + ['label' => $department->name, 'href' => route('hr.departments.show', $department)], + ['label' => 'Edit'], + ], + ]); + } + + public function update(StoreDepartmentRequest $request, Department $department): RedirectResponse + { + $this->authorize('update', $department); + + $department->update($request->validated()); + + return redirect()->route('hr.departments.show', $department) + ->with('success', 'Department updated.'); + } + + public function destroy(Department $department): RedirectResponse + { + $this->authorize('delete', $department); + + $department->delete(); + + return redirect()->route('hr.departments.index') + ->with('success', 'Department deleted.'); + } +} diff --git a/erp/app/Modules/HR/Http/Controllers/DisciplinaryCaseController.php b/erp/app/Modules/HR/Http/Controllers/DisciplinaryCaseController.php new file mode 100644 index 00000000000..8f8e22e5f0b --- /dev/null +++ b/erp/app/Modules/HR/Http/Controllers/DisciplinaryCaseController.php @@ -0,0 +1,131 @@ +authorize('viewAny', DisciplinaryCase::class); + + $cases = DisciplinaryCase::with(['employee', 'handledBy']) + ->when($request->status, fn ($q) => $q->where('status', $request->status)) + ->when($request->employee_id, fn ($q) => $q->where('employee_id', $request->employee_id)) + ->orderBy('created_at', 'desc') + ->paginate(20) + ->withQueryString(); + + return Inertia::render('HR/DisciplinaryCases/Index', [ + 'cases' => $cases, + 'filters' => $request->only(['status', 'employee_id']), + ]); + } + + public function create(): Response + { + $this->authorize('create', DisciplinaryCase::class); + + $employees = Employee::where('status', 'active') + ->orderBy('last_name') + ->get(['id', 'first_name', 'last_name']); + + return Inertia::render('HR/DisciplinaryCases/Create', [ + 'employees' => $employees, + ]); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', DisciplinaryCase::class); + + $validated = $request->validate([ + 'employee_id' => 'required|exists:employees,id', + 'incident_type' => 'required|in:misconduct,poor_performance,attendance,policy_violation,other', + 'incident_date' => 'required|date', + 'description' => 'required|string', + 'severity' => 'required|in:minor,moderate,major,gross', + ]); + + $case = DisciplinaryCase::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'employee_id' => $validated['employee_id'], + 'incident_type' => $validated['incident_type'], + 'incident_date' => $validated['incident_date'], + 'description' => $validated['description'], + 'severity' => $validated['severity'], + 'status' => 'open', + 'handled_by' => auth()->id(), + ]); + + $case->update([ + 'reference' => 'DISC-' . now()->year . '-' . str_pad($case->id, 4, '0', STR_PAD_LEFT), + ]); + + return redirect()->route('hr.disciplinary-cases.show', $case); + } + + public function show(DisciplinaryCase $disciplinaryCase): Response + { + $this->authorize('view', $disciplinaryCase); + + $disciplinaryCase->load(['employee', 'handledBy']); + + return Inertia::render('HR/DisciplinaryCases/Show', [ + 'disciplinaryCase' => $disciplinaryCase, + ]); + } + + public function destroy(DisciplinaryCase $disciplinaryCase): RedirectResponse + { + $this->authorize('delete', $disciplinaryCase); + + $disciplinaryCase->delete(); + + return redirect()->route('hr.disciplinary-cases.index'); + } + + public function scheduleHearing(Request $request, DisciplinaryCase $disciplinaryCase): RedirectResponse + { + $this->authorize('update', $disciplinaryCase); + + $validated = $request->validate([ + 'hearing_date' => 'required|date|after:today', + ]); + + $disciplinaryCase->scheduleHearing(Carbon::parse($validated['hearing_date'])); + + return redirect()->back(); + } + + public function resolve(Request $request, DisciplinaryCase $disciplinaryCase): RedirectResponse + { + $this->authorize('update', $disciplinaryCase); + + $validated = $request->validate([ + 'outcome' => 'required|in:warning,final_warning,suspension,dismissal,no_action', + 'outcome_notes' => 'nullable|string', + ]); + + $disciplinaryCase->resolve($validated['outcome'], $validated['outcome_notes'] ?? null); + + return redirect()->back(); + } + + public function close(Request $request, DisciplinaryCase $disciplinaryCase): RedirectResponse + { + $this->authorize('update', $disciplinaryCase); + + $disciplinaryCase->close(); + + return redirect()->back(); + } +} diff --git a/erp/app/Modules/HR/Http/Controllers/EmployeeBenefitController.php b/erp/app/Modules/HR/Http/Controllers/EmployeeBenefitController.php new file mode 100644 index 00000000000..c4dece62621 --- /dev/null +++ b/erp/app/Modules/HR/Http/Controllers/EmployeeBenefitController.php @@ -0,0 +1,80 @@ +authorize('viewAny', EmployeeBenefit::class); + + $benefits = EmployeeBenefit::with(['employee', 'plan']) + ->when($request->employee_id, fn ($q) => $q->where('employee_id', $request->employee_id)) + ->when($request->status, fn ($q) => $q->where('status', $request->status)) + ->orderBy('created_at', 'desc') + ->paginate(20) + ->withQueryString(); + + return Inertia::render('HR/EmployeeBenefits/Index', [ + 'benefits' => $benefits, + 'filters' => $request->only(['employee_id', 'status']), + ]); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', EmployeeBenefit::class); + + $validated = $request->validate([ + 'employee_id' => 'required|exists:employees,id', + 'benefit_plan_id' => 'required|exists:benefit_plans,id', + 'enrolled_at' => 'required|date', + 'notes' => 'nullable|string', + ]); + + EmployeeBenefit::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'employee_id' => $validated['employee_id'], + 'benefit_plan_id' => $validated['benefit_plan_id'], + 'enrolled_at' => $validated['enrolled_at'], + 'notes' => $validated['notes'] ?? null, + 'status' => 'active', + ]); + + return redirect()->back(); + } + + public function waive(EmployeeBenefit $employeeBenefit): RedirectResponse + { + $this->authorize('update', $employeeBenefit); + + $employeeBenefit->waive(); + + return redirect()->back(); + } + + public function end(Request $request, EmployeeBenefit $employeeBenefit): RedirectResponse + { + $this->authorize('update', $employeeBenefit); + + $employeeBenefit->end(); + + return redirect()->back(); + } + + public function destroy(EmployeeBenefit $employeeBenefit): RedirectResponse + { + $this->authorize('delete', $employeeBenefit); + + $employeeBenefit->delete(); + + return redirect()->back(); + } +} diff --git a/erp/app/Modules/HR/Http/Controllers/EmployeeCertificationController.php b/erp/app/Modules/HR/Http/Controllers/EmployeeCertificationController.php new file mode 100644 index 00000000000..65b16bac735 --- /dev/null +++ b/erp/app/Modules/HR/Http/Controllers/EmployeeCertificationController.php @@ -0,0 +1,58 @@ +authorize('viewAny', EmployeeCertification::class); + + $query = EmployeeCertification::with(['employee']); + + if ($request->filled('employee_id')) { + $query->where('employee_id', $request->employee_id); + } + + $certifications = $query->latest()->paginate(20); + + return Inertia::render('HR/EmployeeCertifications/Index', compact('certifications')); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', EmployeeCertification::class); + + $data = $request->validate([ + 'employee_id' => 'required|exists:employees,id', + 'name' => 'required|string|max:255', + 'issuing_body' => 'nullable|string|max:255', + 'certificate_number' => 'nullable|string|max:255', + 'issued_date' => 'required|date', + 'expiry_date' => 'nullable|date|after:issued_date', + ]); + + EmployeeCertification::create([ + 'tenant_id' => auth()->user()->tenant_id, + ...$data, + ]); + + return redirect()->back()->with('success', 'Certification added.'); + } + + public function destroy(EmployeeCertification $employeeCertification): RedirectResponse + { + $this->authorize('delete', $employeeCertification); + + $employeeCertification->delete(); + + return redirect()->back()->with('success', 'Certification deleted.'); + } +} diff --git a/erp/app/Modules/HR/Http/Controllers/EmployeeController.php b/erp/app/Modules/HR/Http/Controllers/EmployeeController.php new file mode 100644 index 00000000000..971969ae0e4 --- /dev/null +++ b/erp/app/Modules/HR/Http/Controllers/EmployeeController.php @@ -0,0 +1,144 @@ +authorize('viewAny', Employee::class); + + $employees = Employee::with('department') + ->when($request->search, fn ($q) => $q->search($request->search)) + ->when($request->department_id, fn ($q) => $q->where('department_id', $request->department_id)) + ->when($request->status, fn ($q) => $q->where('status', $request->status)) + ->orderBy('last_name') + ->paginate(25) + ->withQueryString(); + + return Inertia::render('HR/Employees/Index', [ + 'employees' => EmployeeResource::collection($employees), + 'departments' => Department::active()->orderBy('name')->get(['id', 'name']), + 'filters' => $request->only(['search', 'department_id', 'status']), + 'breadcrumbs' => [ + ['label' => 'HR'], + ['label' => 'Employees', 'href' => route('hr.employees.index')], + ], + ]); + } + + public function create(): Response + { + $this->authorize('create', Employee::class); + + return Inertia::render('HR/Employees/Create', [ + 'departments' => Department::active()->orderBy('name')->get(['id', 'name']), + 'users' => User::orderBy('name')->get(['id', 'name', 'email']), + 'breadcrumbs' => [ + ['label' => 'HR'], + ['label' => 'Employees', 'href' => route('hr.employees.index')], + ['label' => 'New Employee'], + ], + ]); + } + + public function store(StoreEmployeeRequest $request): RedirectResponse + { + $this->authorize('create', Employee::class); + + $employee = Employee::create([ + ...$request->validated(), + 'tenant_id' => auth()->user()->tenant_id, + ]); + + // Generate employee code + if (!$employee->employee_number) { + $employee->employee_number = 'EMP-' . str_pad((string) $employee->id, 5, '0', STR_PAD_LEFT); + $employee->save(); + } + + return redirect()->route('hr.employees.show', $employee) + ->with('success', 'Employee created.'); + } + + public function show(Employee $employee): Response + { + $this->authorize('view', $employee); + + $employee->load(['department', 'user', 'leaveRequests.leaveType']); + + return Inertia::render('HR/Employees/Show', [ + 'employee' => new EmployeeResource($employee), + 'breadcrumbs' => [ + ['label' => 'HR'], + ['label' => 'Employees', 'href' => route('hr.employees.index')], + ['label' => $employee->full_name], + ], + ]); + } + + public function edit(Employee $employee): Response + { + $this->authorize('update', $employee); + + return Inertia::render('HR/Employees/Edit', [ + 'employee' => new EmployeeResource($employee), + 'departments' => Department::active()->orderBy('name')->get(['id', 'name']), + 'users' => User::orderBy('name')->get(['id', 'name', 'email']), + 'breadcrumbs' => [ + ['label' => 'HR'], + ['label' => 'Employees', 'href' => route('hr.employees.index')], + ['label' => $employee->full_name, 'href' => route('hr.employees.show', $employee)], + ['label' => 'Edit'], + ], + ]); + } + + public function update(StoreEmployeeRequest $request, Employee $employee): RedirectResponse + { + $this->authorize('update', $employee); + + $employee->update($request->validated()); + + return redirect()->route('hr.employees.show', $employee) + ->with('success', 'Employee updated.'); + } + + public function destroy(Employee $employee): RedirectResponse + { + $this->authorize('delete', $employee); + + $employee->delete(); + + return redirect()->route('hr.employees.index') + ->with('success', 'Employee deleted.'); + } + + public function terminate(Employee $employee): RedirectResponse + { + $this->authorize('update', $employee); + + if ($employee->status !== 'active') { + return back()->withErrors(['status' => 'Only active employees can be terminated.']); + } + + $employee->update([ + 'status' => 'terminated', + 'end_date' => now()->toDateString(), + ]); + + return redirect()->route('hr.employees.show', $employee) + ->with('success', 'Employee terminated.'); + } +} diff --git a/erp/app/Modules/HR/Http/Controllers/EmployeeDocumentController.php b/erp/app/Modules/HR/Http/Controllers/EmployeeDocumentController.php new file mode 100644 index 00000000000..b022f8ff92d --- /dev/null +++ b/erp/app/Modules/HR/Http/Controllers/EmployeeDocumentController.php @@ -0,0 +1,80 @@ +authorize('viewAny', EmployeeDocument::class); + + $documents = EmployeeDocument::with('employee') + ->when($request->employee_id, fn ($q) => $q->where('employee_id', $request->employee_id)) + ->when($request->document_type, fn ($q) => $q->where('document_type', $request->document_type)) + ->latest() + ->paginate(20) + ->withQueryString(); + + return Inertia::render('HR/EmployeeDocuments/Index', [ + 'documents' => $documents, + 'filters' => $request->only(['employee_id', 'document_type']), + ]); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', EmployeeDocument::class); + + $validated = $request->validate([ + 'employee_id' => 'required|exists:employees,id', + 'document_type' => 'required|string|max:50', + 'document_name' => 'required|string|max:255', + 'document_number' => 'nullable|string|max:100', + 'file_url' => 'nullable|string|max:500', + 'issued_date' => 'nullable|date', + 'expiry_date' => 'nullable|date', + 'notes' => 'nullable|string', + ]); + + EmployeeDocument::create([ + 'tenant_id' => auth()->user()->tenant_id, + ...$validated, + ]); + + return back()->with('success', 'Document added.'); + } + + public function show(EmployeeDocument $employeeDocument): Response + { + $this->authorize('view', $employeeDocument); + $employeeDocument->load(['employee', 'verifiedBy']); + + return Inertia::render('HR/EmployeeDocuments/Show', [ + 'document' => $employeeDocument, + ]); + } + + public function verify(EmployeeDocument $employeeDocument): RedirectResponse + { + $this->authorize('update', $employeeDocument); + $employeeDocument->verify(auth()->id()); + + return back()->with('success', 'Document verified.'); + } + + public function destroy(EmployeeDocument $employeeDocument): RedirectResponse + { + $this->authorize('delete', $employeeDocument); + $employeeDocument->delete(); + + return back()->with('success', 'Document deleted.'); + } +} diff --git a/erp/app/Modules/HR/Http/Controllers/EmployeeEmergencyContactController.php b/erp/app/Modules/HR/Http/Controllers/EmployeeEmergencyContactController.php new file mode 100644 index 00000000000..2e5be4631f7 --- /dev/null +++ b/erp/app/Modules/HR/Http/Controllers/EmployeeEmergencyContactController.php @@ -0,0 +1,99 @@ +emergencyContacts()->orderByDesc('is_primary')->get(); + return Inertia::render('HR/EmergencyContacts/Index', compact('employee', 'contacts')); + } + + public function create(Employee $employee): Response + { + return Inertia::render('HR/EmergencyContacts/Create', compact('employee')); + } + + public function store(Request $request, Employee $employee): RedirectResponse + { + $data = $request->validate([ + 'name' => 'required|string|max:255', + 'relationship' => 'required|string|max:100', + 'phone_primary' => 'required|string|max:50', + 'phone_secondary' => 'nullable|string|max:50', + 'email' => 'nullable|email|max:255', + 'address' => 'nullable|string', + 'is_primary' => 'boolean', + 'notes' => 'nullable|string', + ]); + + $data['employee_id'] = $employee->id; + + $contact = EmployeeEmergencyContact::create($data); + + if (!empty($data['is_primary'])) { + $contact->markAsPrimary(); + } + + return redirect()->route('hr.employees.emergency-contacts.index', $employee); + } + + public function show(EmployeeEmergencyContact $emergencyContact): Response + { + $emergencyContact->load('employee'); + return Inertia::render('HR/EmergencyContacts/Show', [ + 'employee' => $emergencyContact->employee, + 'contact' => $emergencyContact, + ]); + } + + public function edit(EmployeeEmergencyContact $emergencyContact): Response + { + return Inertia::render('HR/EmergencyContacts/Edit', [ + 'contact' => $emergencyContact, + ]); + } + + public function update(Request $request, EmployeeEmergencyContact $emergencyContact): RedirectResponse + { + $data = $request->validate([ + 'name' => 'required|string|max:255', + 'relationship' => 'required|string|max:100', + 'phone_primary' => 'required|string|max:50', + 'phone_secondary' => 'nullable|string|max:50', + 'email' => 'nullable|email|max:255', + 'address' => 'nullable|string', + 'is_primary' => 'boolean', + 'notes' => 'nullable|string', + ]); + + $emergencyContact->update($data); + + if (!empty($data['is_primary'])) { + $emergencyContact->markAsPrimary(); + } + + return redirect()->route('hr.employees.emergency-contacts.index', $emergencyContact->employee_id); + } + + public function destroy(EmployeeEmergencyContact $emergencyContact): RedirectResponse + { + $employeeId = $emergencyContact->employee_id; + $emergencyContact->delete(); + return redirect()->route('hr.employees.emergency-contacts.index', $employeeId); + } + + public function markPrimary(Employee $employee, EmployeeEmergencyContact $emergencyContact): RedirectResponse + { + $emergencyContact->markAsPrimary(); + return redirect()->route('hr.employees.emergency-contacts.index', $employee); + } +} diff --git a/erp/app/Modules/HR/Http/Controllers/EmployeeExitController.php b/erp/app/Modules/HR/Http/Controllers/EmployeeExitController.php new file mode 100644 index 00000000000..8321d44e632 --- /dev/null +++ b/erp/app/Modules/HR/Http/Controllers/EmployeeExitController.php @@ -0,0 +1,100 @@ +authorize('viewAny', EmployeeExit::class); + + $exits = EmployeeExit::with('employee') + ->when($request->status, fn ($q) => $q->where('status', $request->status)) + ->orderBy('created_at', 'desc') + ->paginate(20) + ->withQueryString(); + + return Inertia::render('HR/EmployeeExits/Index', [ + 'exits' => $exits, + 'filters' => $request->only(['status']), + ]); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', EmployeeExit::class); + + $validated = $request->validate([ + 'employee_id' => 'required|exists:employees,id', + 'exit_date' => 'required|date', + 'exit_type' => 'required|string|in:resignation,termination,retirement,redundancy,contract_end', + 'reason' => 'nullable|string', + 'exit_interview_notes' => 'nullable|string', + 'equipment_returned' => 'boolean', + 'access_revoked' => 'boolean', + ]); + + $exit = EmployeeExit::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'employee_id' => $validated['employee_id'], + 'exit_date' => $validated['exit_date'], + 'exit_type' => $validated['exit_type'], + 'reason' => $validated['reason'] ?? null, + 'exit_interview_notes' => $validated['exit_interview_notes'] ?? null, + 'equipment_returned' => $validated['equipment_returned'] ?? false, + 'access_revoked' => $validated['access_revoked'] ?? false, + 'status' => 'pending', + ]); + + // Update employee status to terminated + Employee::where('id', $validated['employee_id'])->update(['status' => 'terminated']); + + return redirect()->route('hr.employee-exits.show', $exit); + } + + public function show(EmployeeExit $employeeExit): Response + { + $this->authorize('view', $employeeExit); + + $employeeExit->load(['employee', 'processedBy']); + + return Inertia::render('HR/EmployeeExits/Show', [ + 'exit' => $employeeExit, + ]); + } + + public function complete(EmployeeExit $employeeExit): RedirectResponse + { + $this->authorize('update', $employeeExit); + + $employeeExit->complete(auth()->id()); + + return redirect()->back()->with('success', 'Exit record marked as completed.'); + } + + public function markInProgress(EmployeeExit $employeeExit): RedirectResponse + { + $this->authorize('update', $employeeExit); + + $employeeExit->markInProgress(); + + return redirect()->back()->with('success', 'Exit record marked as in progress.'); + } + + public function destroy(EmployeeExit $employeeExit): RedirectResponse + { + $this->authorize('delete', $employeeExit); + + $employeeExit->delete(); + + return redirect()->route('hr.employee-exits.index'); + } +} diff --git a/erp/app/Modules/HR/Http/Controllers/EmployeeGoalController.php b/erp/app/Modules/HR/Http/Controllers/EmployeeGoalController.php new file mode 100644 index 00000000000..39f21198c1a --- /dev/null +++ b/erp/app/Modules/HR/Http/Controllers/EmployeeGoalController.php @@ -0,0 +1,153 @@ +authorize('viewAny', EmployeeGoal::class); + + $query = EmployeeGoal::with('employee') + ->orderByDesc('created_at'); + + if ($request->filled('employee_id')) { + $query->where('employee_id', $request->employee_id); + } + + if ($request->filled('status')) { + $query->where('status', $request->status); + } + + $goals = $query->paginate(15); + $filters = $request->only(['employee_id', 'status']); + + return Inertia::render('HR/EmployeeGoals/Index', compact('goals', 'filters')); + } + + public function create(): Response + { + $this->authorize('create', EmployeeGoal::class); + + $employees = Employee::where('status', 'active') + ->orderBy('first_name') + ->get(['id', 'first_name', 'last_name']); + + return Inertia::render('HR/EmployeeGoals/Create', compact('employees')); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', EmployeeGoal::class); + + $data = $request->validate([ + 'employee_id' => ['required', 'exists:employees,id'], + 'title' => ['required', 'string'], + 'start_date' => ['required', 'date'], + 'due_date' => ['required', 'date', 'after_or_equal:start_date'], + 'priority' => ['nullable', 'in:low,medium,high'], + ]); + + $data['tenant_id'] = app('tenant')->id; + $data['created_by'] = auth()->id(); + + EmployeeGoal::create($data); + + return redirect()->route('hr.employee-goals.index'); + } + + public function show(EmployeeGoal $employeeGoal): Response + { + $this->authorize('view', $employeeGoal); + + $employeeGoal->load('employee'); + + return Inertia::render('HR/EmployeeGoals/Show', compact('employeeGoal')); + } + + public function edit(EmployeeGoal $employeeGoal): Response + { + $this->authorize('update', $employeeGoal); + + $employees = Employee::where('status', 'active') + ->orderBy('first_name') + ->get(['id', 'first_name', 'last_name']); + + $employeeGoal->load('employee'); + + return Inertia::render('HR/EmployeeGoals/Edit', compact('employeeGoal', 'employees')); + } + + public function update(Request $request, EmployeeGoal $employeeGoal): RedirectResponse + { + $this->authorize('update', $employeeGoal); + + $data = $request->validate([ + 'title' => ['required', 'string'], + 'start_date' => ['required', 'date'], + 'due_date' => ['required', 'date', 'after_or_equal:start_date'], + 'priority' => ['nullable', 'in:low,medium,high'], + ]); + + $employeeGoal->update($data); + + return redirect()->route('hr.employee-goals.index'); + } + + public function destroy(EmployeeGoal $employeeGoal): RedirectResponse + { + $this->authorize('delete', $employeeGoal); + + $employeeGoal->delete(); + + return redirect()->route('hr.employee-goals.index'); + } + + public function complete(EmployeeGoal $employeeGoal): RedirectResponse + { + $this->authorize('complete', $employeeGoal); + + $employeeGoal->complete(); + + return redirect()->route('hr.employee-goals.index'); + } + + public function miss(EmployeeGoal $employeeGoal): RedirectResponse + { + $this->authorize('miss', $employeeGoal); + + $employeeGoal->miss(); + + return redirect()->route('hr.employee-goals.index'); + } + + public function cancel(EmployeeGoal $employeeGoal): RedirectResponse + { + $this->authorize('cancel', $employeeGoal); + + $employeeGoal->cancel(); + + return redirect()->route('hr.employee-goals.index'); + } + + public function updateProgress(Request $request, EmployeeGoal $employeeGoal): RedirectResponse + { + $this->authorize('update', $employeeGoal); + + $data = $request->validate([ + 'progress' => ['required', 'integer', 'min:0', 'max:100'], + ]); + + $employeeGoal->updateProgress($data['progress']); + + return redirect()->route('hr.employee-goals.index'); + } +} diff --git a/erp/app/Modules/HR/Http/Controllers/EmployeeLoanController.php b/erp/app/Modules/HR/Http/Controllers/EmployeeLoanController.php new file mode 100644 index 00000000000..d8933828649 --- /dev/null +++ b/erp/app/Modules/HR/Http/Controllers/EmployeeLoanController.php @@ -0,0 +1,144 @@ +authorize('viewAny', EmployeeLoan::class); + + $loans = EmployeeLoan::with('employee') + ->when($request->status, fn ($q) => $q->where('status', $request->status)) + ->orderBy('created_at', 'desc') + ->paginate(15) + ->withQueryString(); + + return Inertia::render('HR/EmployeeLoans/Index', [ + 'loans' => $loans, + 'filters' => $request->only(['status']), + ]); + } + + public function create(): Response + { + $this->authorize('create', EmployeeLoan::class); + + return Inertia::render('HR/EmployeeLoans/Create', [ + 'employees' => Employee::active()->orderBy('last_name')->get()->map(fn ($e) => [ + 'id' => $e->id, + 'full_name' => $e->full_name, + ]), + ]); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', EmployeeLoan::class); + + $validated = $request->validate([ + 'employee_id' => ['required', Rule::exists('employees', 'id')], + 'type' => ['required', Rule::in(['loan', 'advance'])], + 'amount' => ['required', 'numeric', 'min:0.01'], + 'interest_rate' => ['nullable', 'numeric', 'min:0', 'max:100'], + 'purpose' => ['nullable', 'string', 'max:255'], + 'notes' => ['nullable', 'string'], + 'repayment_start_date' => ['nullable', 'date'], + ]); + + $loan = EmployeeLoan::create([ + ...$validated, + 'tenant_id' => auth()->user()->tenant_id, + 'outstanding_balance' => $validated['amount'], + 'status' => 'pending', + ]); + + return redirect()->route('hr.employee-loans.show', $loan) + ->with('success', 'Loan created successfully.'); + } + + public function show(EmployeeLoan $employeeLoan): Response + { + $this->authorize('view', $employeeLoan); + + $employeeLoan->load(['employee', 'repayments']); + + return Inertia::render('HR/EmployeeLoans/Show', [ + 'loan' => array_merge($employeeLoan->toArray(), [ + 'total_repaid' => $employeeLoan->total_repaid, + 'is_fully_repaid' => $employeeLoan->is_fully_repaid, + ]), + 'can' => [ + 'create' => auth()->user()->can('create', EmployeeLoan::class), + 'delete' => auth()->user()->can('delete', $employeeLoan), + ], + ]); + } + + public function destroy(EmployeeLoan $employeeLoan): RedirectResponse + { + $this->authorize('delete', $employeeLoan); + + if ($employeeLoan->status !== 'pending') { + return back()->withErrors(['status' => 'Only pending loans can be deleted.']); + } + + $employeeLoan->delete(); + + return redirect()->route('hr.employee-loans.index') + ->with('success', 'Loan deleted.'); + } + + public function approve(EmployeeLoan $employeeLoan): RedirectResponse + { + $this->authorize('create', EmployeeLoan::class); + + $employeeLoan->approve(auth()->user()); + + return back()->with('success', 'Loan approved.'); + } + + public function cancel(EmployeeLoan $employeeLoan): RedirectResponse + { + $this->authorize('create', EmployeeLoan::class); + + $employeeLoan->cancel(); + + return back()->with('success', 'Loan cancelled.'); + } + + public function addRepayment(Request $request, EmployeeLoan $employeeLoan): RedirectResponse + { + $this->authorize('create', EmployeeLoan::class); + + $validated = $request->validate([ + 'amount' => ['required', 'numeric', 'min:0.01'], + 'payment_date' => ['required', 'date'], + 'notes' => ['nullable', 'string'], + ]); + + $repayment = LoanRepayment::create([ + ...$validated, + 'tenant_id' => auth()->user()->tenant_id, + 'employee_loan_id' => $employeeLoan->id, + ]); + + $employeeLoan->decrement('outstanding_balance', $repayment->amount); + + if ($employeeLoan->fresh()->outstanding_balance <= 0) { + $employeeLoan->update(['status' => 'completed', 'outstanding_balance' => 0]); + } + + return back()->with('success', 'Repayment recorded.'); + } +} diff --git a/erp/app/Modules/HR/Http/Controllers/EmployeeOnboardingController.php b/erp/app/Modules/HR/Http/Controllers/EmployeeOnboardingController.php new file mode 100644 index 00000000000..b1be1286375 --- /dev/null +++ b/erp/app/Modules/HR/Http/Controllers/EmployeeOnboardingController.php @@ -0,0 +1,159 @@ +authorize('viewAny', EmployeeOnboarding::class); + + $onboardings = $employee->onboardings() + ->withCount('tasks') + ->orderByDesc('created_at') + ->get() + ->map(function ($onboarding) { + $onboarding->progress = $onboarding->progress; + return $onboarding; + }); + + return Inertia::render('HR/Employees/Onboardings/Index', [ + 'employee' => $employee, + 'onboardings' => $onboardings, + 'breadcrumbs' => [ + ['label' => 'HR'], + ['label' => 'Employees', 'href' => route('hr.employees.index')], + ['label' => $employee->full_name, 'href' => route('hr.employees.show', $employee)], + ['label' => 'Onboardings'], + ], + ]); + } + + public function create(Employee $employee): Response + { + $this->authorize('create', EmployeeOnboarding::class); + + $templates = OnboardingTemplate::where('is_active', true) + ->orderBy('name') + ->get(['id', 'name', 'description']); + + return Inertia::render('HR/Employees/Onboardings/Create', [ + 'employee' => $employee, + 'templates' => $templates, + 'breadcrumbs' => [ + ['label' => 'HR'], + ['label' => 'Employees', 'href' => route('hr.employees.index')], + ['label' => $employee->full_name, 'href' => route('hr.employees.show', $employee)], + ['label' => 'Onboardings', 'href' => route('hr.employees.onboardings.index', $employee)], + ['label' => 'New Onboarding'], + ], + ]); + } + + public function store(Request $request, Employee $employee): RedirectResponse + { + $this->authorize('create', EmployeeOnboarding::class); + + $validated = $request->validate([ + 'template_id' => 'nullable|exists:onboarding_templates,id', + 'title' => 'nullable|string|max:255', + 'started_at' => 'required|date', + ]); + + if ($validated['template_id']) { + $template = OnboardingTemplate::findOrFail($validated['template_id']); + $onboarding = EmployeeOnboarding::fromTemplate($employee, $template); + // Override started_at if explicitly provided + if ($validated['started_at']) { + $onboarding->update(['started_at' => $validated['started_at']]); + } + } else { + $onboarding = EmployeeOnboarding::create([ + 'tenant_id' => $employee->tenant_id, + 'employee_id' => $employee->id, + 'template_id' => null, + 'title' => $validated['title'] ?? 'Onboarding', + 'status' => 'in_progress', + 'started_at' => $validated['started_at'], + ]); + } + + return redirect()->route('hr.employees.onboardings.show', [$employee, $onboarding]) + ->with('success', 'Onboarding created.'); + } + + public function show(Employee $employee, EmployeeOnboarding $onboarding): Response + { + $this->authorize('view', $onboarding); + + $onboarding->load('tasks'); + + return Inertia::render('HR/Employees/Onboardings/Show', [ + 'employee' => $employee, + 'onboarding' => array_merge($onboarding->toArray(), ['progress' => $onboarding->progress]), + 'breadcrumbs' => [ + ['label' => 'HR'], + ['label' => 'Employees', 'href' => route('hr.employees.index')], + ['label' => $employee->full_name, 'href' => route('hr.employees.show', $employee)], + ['label' => 'Onboardings', 'href' => route('hr.employees.onboardings.index', $employee)], + ['label' => $onboarding->title], + ], + ]); + } + + public function completeTask(Employee $employee, EmployeeOnboarding $onboarding, EmployeeOnboardingTask $task): RedirectResponse + { + $this->authorize('update', $onboarding); + + $task->update([ + 'completed_at' => now(), + 'completed_by' => auth()->id(), + ]); + + return back()->with('success', 'Task marked as complete.'); + } + + public function uncompleteTask(Employee $employee, EmployeeOnboarding $onboarding, EmployeeOnboardingTask $task): RedirectResponse + { + $this->authorize('update', $onboarding); + + $task->update([ + 'completed_at' => null, + 'completed_by' => null, + ]); + + return back()->with('success', 'Task marked as incomplete.'); + } + + public function complete(Employee $employee, EmployeeOnboarding $onboarding): RedirectResponse + { + $this->authorize('update', $onboarding); + + $onboarding->update([ + 'status' => 'completed', + 'completed_at' => now()->toDateString(), + ]); + + return back()->with('success', 'Onboarding marked as complete.'); + } + + public function destroy(Employee $employee, EmployeeOnboarding $onboarding): RedirectResponse + { + $this->authorize('delete', $onboarding); + + $onboarding->delete(); + + return redirect()->route('hr.employees.onboardings.index', $employee) + ->with('success', 'Onboarding deleted.'); + } +} diff --git a/erp/app/Modules/HR/Http/Controllers/EmployeeOnboardingTrackingController.php b/erp/app/Modules/HR/Http/Controllers/EmployeeOnboardingTrackingController.php new file mode 100644 index 00000000000..cc9eae6932b --- /dev/null +++ b/erp/app/Modules/HR/Http/Controllers/EmployeeOnboardingTrackingController.php @@ -0,0 +1,140 @@ +authorize('viewAny', EmployeeOnboarding::class); + + $query = EmployeeOnboarding::with(['employee', 'checklist']) + ->whereNotNull('onboarding_checklist_id'); + + if ($request->filled('employee_id')) { + $query->where('employee_id', $request->employee_id); + } + + if ($request->filled('status')) { + $query->where('status', $request->status); + } + + $onboardings = $query->orderByDesc('created_at')->paginate(20); + + $employees = Employee::orderBy('first_name')->get(['id', 'first_name', 'last_name']); + $checklists = OnboardingChecklist::where('is_active', true)->orderBy('name')->get(['id', 'name']); + + return Inertia::render('HR/EmployeeOnboardings/Index', [ + 'onboardings' => $onboardings, + 'employees' => $employees, + 'checklists' => $checklists, + 'filters' => $request->only(['employee_id', 'status']), + ]); + } + + public function create(): Response + { + $this->authorize('create', EmployeeOnboarding::class); + + $employees = Employee::orderBy('first_name')->get(['id', 'first_name', 'last_name']); + $checklists = OnboardingChecklist::where('is_active', true)->orderBy('name')->get(['id', 'name']); + + return Inertia::render('HR/EmployeeOnboardings/Create', [ + 'employees' => $employees, + 'checklists' => $checklists, + ]); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', EmployeeOnboarding::class); + + $validated = $request->validate([ + 'employee_id' => 'required|exists:employees,id', + 'onboarding_checklist_id' => 'required|exists:onboarding_checklists,id', + 'start_date' => 'required|date', + ]); + + $checklist = OnboardingChecklist::with('tasks')->findOrFail($validated['onboarding_checklist_id']); + + $onboarding = EmployeeOnboarding::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'employee_id' => $validated['employee_id'], + 'onboarding_checklist_id' => $validated['onboarding_checklist_id'], + 'start_date' => $validated['start_date'], + 'started_at' => $validated['start_date'], + 'title' => $checklist->name, + 'status' => 'in_progress', + 'assigned_by' => auth()->id(), + ]); + + foreach ($checklist->tasks as $task) { + OnboardingProgress::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'employee_onboarding_id' => $onboarding->id, + 'onboarding_task_id' => $task->id, + 'status' => 'pending', + ]); + } + + return redirect()->route('hr.employee-onboardings.show', $onboarding) + ->with('success', 'Employee onboarding created.'); + } + + public function show(EmployeeOnboarding $employeeOnboarding): Response + { + $this->authorize('view', $employeeOnboarding); + + $employeeOnboarding->load(['employee', 'checklist.tasks', 'progress.task']); + + return Inertia::render('HR/EmployeeOnboardings/Show', [ + 'onboarding' => $employeeOnboarding, + ]); + } + + public function completeTask(Request $request, EmployeeOnboarding $employeeOnboarding, OnboardingProgress $progress): RedirectResponse + { + $this->authorize('update', $employeeOnboarding); + + $validated = $request->validate([ + 'notes' => 'nullable|string', + ]); + + $progress->complete(auth()->user(), $validated['notes'] ?? null); + + return back()->with('success', 'Task completed.'); + } + + public function skipTask(Request $request, EmployeeOnboarding $employeeOnboarding, OnboardingProgress $progress): RedirectResponse + { + $this->authorize('update', $employeeOnboarding); + + $validated = $request->validate([ + 'notes' => 'nullable|string', + ]); + + $progress->skip($validated['notes'] ?? null); + + return back()->with('success', 'Task skipped.'); + } + + public function destroy(EmployeeOnboarding $employeeOnboarding): RedirectResponse + { + $this->authorize('delete', $employeeOnboarding); + + $employeeOnboarding->delete(); + + return redirect()->route('hr.employee-onboardings.index') + ->with('success', 'Onboarding deleted.'); + } +} diff --git a/erp/app/Modules/HR/Http/Controllers/EmployeePositionChangeController.php b/erp/app/Modules/HR/Http/Controllers/EmployeePositionChangeController.php new file mode 100644 index 00000000000..0df1f8b227e --- /dev/null +++ b/erp/app/Modules/HR/Http/Controllers/EmployeePositionChangeController.php @@ -0,0 +1,84 @@ +authorize('viewAny', EmployeePositionChange::class); + + $changes = EmployeePositionChange::with(['employee', 'fromDepartment', 'toDepartment']) + ->when($request->employee_id, fn ($q) => $q->where('employee_id', $request->employee_id)) + ->orderBy('effective_date', 'desc') + ->paginate(20) + ->withQueryString(); + + return Inertia::render('HR/PositionChanges/Index', [ + 'changes' => $changes, + 'filters' => $request->only(['employee_id']), + ]); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', EmployeePositionChange::class); + + $validated = $request->validate([ + 'employee_id' => 'required|exists:employees,id', + 'change_type' => 'required|string|in:promotion,demotion,transfer,salary_change,title_change,department_change', + 'effective_date' => 'required|date', + 'from_title' => 'nullable|string|max:255', + 'to_title' => 'nullable|string|max:255', + 'from_department_id' => 'nullable|exists:departments,id', + 'to_department_id' => 'nullable|exists:departments,id', + 'from_salary' => 'nullable|numeric|min:0', + 'to_salary' => 'nullable|numeric|min:0', + 'reason' => 'nullable|string', + 'notes' => 'nullable|string', + ]); + + $change = EmployeePositionChange::create([ + 'tenant_id' => auth()->user()->tenant_id, + ...$validated, + ]); + + return redirect()->route('hr.position-changes.show', $change); + } + + public function show(EmployeePositionChange $positionChange): Response + { + $this->authorize('view', $positionChange); + + $positionChange->load(['employee', 'fromDepartment', 'toDepartment', 'approvedBy']); + + return Inertia::render('HR/PositionChanges/Show', [ + 'change' => $positionChange, + ]); + } + + public function approve(EmployeePositionChange $positionChange): RedirectResponse + { + $this->authorize('update', $positionChange); + + $positionChange->approve(auth()->id()); + + return redirect()->back()->with('success', 'Position change approved successfully.'); + } + + public function destroy(EmployeePositionChange $positionChange): RedirectResponse + { + $this->authorize('delete', $positionChange); + + $positionChange->delete(); + + return redirect()->route('hr.position-changes.index'); + } +} diff --git a/erp/app/Modules/HR/Http/Controllers/EmployeeScheduleController.php b/erp/app/Modules/HR/Http/Controllers/EmployeeScheduleController.php new file mode 100644 index 00000000000..a0c14619c37 --- /dev/null +++ b/erp/app/Modules/HR/Http/Controllers/EmployeeScheduleController.php @@ -0,0 +1,62 @@ +authorize('viewAny', EmployeeSchedule::class); + + $assignments = EmployeeSchedule::with(['employee', 'schedule']) + ->when($request->employee_id, fn ($q) => $q->where('employee_id', $request->employee_id)) + ->orderByDesc('created_at') + ->paginate(20) + ->withQueryString(); + + return Inertia::render('HR/EmployeeSchedules/Index', [ + 'assignments' => $assignments, + 'filters' => $request->only(['employee_id']), + ]); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', EmployeeSchedule::class); + + $validated = $request->validate([ + 'employee_id' => 'required|exists:employees,id', + 'work_schedule_id' => 'required|exists:work_schedules,id', + 'effective_from' => 'required|date', + 'effective_to' => 'nullable|date', + 'is_active' => 'nullable|boolean', + ]); + + EmployeeSchedule::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'employee_id' => $validated['employee_id'], + 'work_schedule_id' => $validated['work_schedule_id'], + 'effective_from' => $validated['effective_from'], + 'effective_to' => $validated['effective_to'] ?? null, + 'is_active' => $validated['is_active'] ?? true, + ]); + + return redirect()->back(); + } + + public function destroy(EmployeeSchedule $employeeSchedule): RedirectResponse + { + $this->authorize('delete', $employeeSchedule); + + $employeeSchedule->delete(); + + return redirect()->back(); + } +} diff --git a/erp/app/Modules/HR/Http/Controllers/EmployeeSkillController.php b/erp/app/Modules/HR/Http/Controllers/EmployeeSkillController.php new file mode 100644 index 00000000000..e8e02e26648 --- /dev/null +++ b/erp/app/Modules/HR/Http/Controllers/EmployeeSkillController.php @@ -0,0 +1,76 @@ +authorize('viewAny', EmployeeSkill::class); + + $query = EmployeeSkill::with(['employee', 'definition']); + + if ($request->filled('employee_id')) { + $query->where('employee_id', $request->employee_id); + } + + $skills = $query->latest()->paginate(20); + + return Inertia::render('HR/EmployeeSkills/Index', compact('skills')); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', EmployeeSkill::class); + + $data = $request->validate([ + 'employee_id' => 'required|exists:employees,id', + 'skill_name' => 'required|string|max:255', + 'skill_definition_id' => 'nullable|exists:skill_definitions,id', + 'proficiency_level' => 'required|integer|min:1|max:5', + 'acquired_date' => 'nullable|date', + 'notes' => 'nullable|string', + ]); + + EmployeeSkill::create([ + 'tenant_id' => auth()->user()->tenant_id, + ...$data, + ]); + + return redirect()->back()->with('success', 'Skill added.'); + } + + public function show(EmployeeSkill $employeeSkill): Response + { + $this->authorize('view', $employeeSkill); + + $employeeSkill->load(['employee', 'definition']); + + return Inertia::render('HR/EmployeeSkills/Show', compact('employeeSkill')); + } + + public function verify(EmployeeSkill $employeeSkill): RedirectResponse + { + $this->authorize('update', $employeeSkill); + + $employeeSkill->verify(auth()->id()); + + return redirect()->back()->with('success', 'Skill verified.'); + } + + public function destroy(EmployeeSkill $employeeSkill): RedirectResponse + { + $this->authorize('delete', $employeeSkill); + + $employeeSkill->delete(); + + return redirect()->back()->with('success', 'Skill deleted.'); + } +} diff --git a/erp/app/Modules/HR/Http/Controllers/EmployeeSurveyController.php b/erp/app/Modules/HR/Http/Controllers/EmployeeSurveyController.php new file mode 100644 index 00000000000..f35dbda6fb9 --- /dev/null +++ b/erp/app/Modules/HR/Http/Controllers/EmployeeSurveyController.php @@ -0,0 +1,82 @@ +authorize('viewAny', EmployeeSurvey::class); + $surveys = EmployeeSurvey::where('tenant_id', app('tenant')->id) + ->latest() + ->paginate(20); + return Inertia::render('HR/Surveys/Index', compact('surveys')); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', EmployeeSurvey::class); + $validated = $request->validate([ + 'title' => 'required|string|max:255', + 'description' => 'nullable|string', + 'start_date' => 'nullable|date', + 'end_date' => 'nullable|date|after_or_equal:start_date', + 'is_anonymous' => 'nullable|boolean', + ]); + $validated['tenant_id'] = app('tenant')->id; + $validated['created_by'] = auth()->id(); + EmployeeSurvey::create($validated); + return back()->with('success', 'Survey created.'); + } + + public function show(EmployeeSurvey $survey): Response + { + $this->authorize('view', $survey); + return Inertia::render('HR/Surveys/Show', compact('survey')); + } + + public function publish(EmployeeSurvey $survey): RedirectResponse + { + $this->authorize('update', $survey); + $survey->publish(); + return back()->with('success', 'Survey published.'); + } + + public function close(EmployeeSurvey $survey): RedirectResponse + { + $this->authorize('update', $survey); + $survey->close(); + return back()->with('success', 'Survey closed.'); + } + + public function respond(Request $request, EmployeeSurvey $survey): RedirectResponse + { + $validated = $request->validate([ + 'answers' => 'required|array', + 'employee_id' => 'nullable|exists:employees,id', + ]); + SurveyResponse::create([ + 'tenant_id' => app('tenant')->id, + 'employee_survey_id' => $survey->id, + 'employee_id' => $validated['employee_id'] ?? null, + 'answers' => $validated['answers'], + 'submitted_at' => now(), + ]); + return back()->with('success', 'Response submitted.'); + } + + public function destroy(EmployeeSurvey $survey): RedirectResponse + { + $this->authorize('delete', $survey); + $survey->delete(); + return back()->with('success', 'Survey deleted.'); + } +} diff --git a/erp/app/Modules/HR/Http/Controllers/EmployeeTrainingRecordController.php b/erp/app/Modules/HR/Http/Controllers/EmployeeTrainingRecordController.php new file mode 100644 index 00000000000..d2bcde55863 --- /dev/null +++ b/erp/app/Modules/HR/Http/Controllers/EmployeeTrainingRecordController.php @@ -0,0 +1,96 @@ +authorize('viewAny', EmployeeTrainingRecord::class); + + $records = EmployeeTrainingRecord::with(['employee', 'trainingCourse']) + ->orderByDesc('completed_date') + ->paginate(25); + + return Inertia::render('HR/TrainingRecords/Index', compact('records')); + } + + public function create(Request $request): Response + { + $this->authorize('create', EmployeeTrainingRecord::class); + + $employees = Employee::where('status', 'active') + ->orderBy('first_name') + ->get(['id', 'first_name', 'last_name']); + + $courses = TrainingCourse::where('is_active', true) + ->orderBy('title') + ->get(['id', 'title']); + + $employeeId = $request->get('employee_id'); + $courseId = $request->get('training_course_id'); + + return Inertia::render('HR/TrainingRecords/Create', compact('employees', 'courses', 'employeeId', 'courseId')); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', EmployeeTrainingRecord::class); + + $data = $request->validate([ + 'employee_id' => 'required|exists:employees,id', + 'training_course_id' => 'nullable|exists:training_courses,id', + 'course_title' => 'required|string|max:255', + 'completed_date' => 'required|date', + 'expiry_date' => 'nullable|date|after_or_equal:completed_date', + 'score' => 'nullable|numeric|min:0', + 'passed' => 'boolean', + 'certificate_number' => 'nullable|string|max:255', + 'notes' => 'nullable|string', + ]); + + // Snapshot course_title from selected course if not provided or if course is selected + if (!empty($data['training_course_id'])) { + $course = TrainingCourse::find($data['training_course_id']); + if ($course) { + $data['course_title'] = $course->title; + } + } + + $record = EmployeeTrainingRecord::create([ + 'tenant_id' => auth()->user()->tenant_id, + ...$data, + ]); + + return redirect()->route('hr.training-records.show', $record) + ->with('success', 'Training record created.'); + } + + public function show(EmployeeTrainingRecord $trainingRecord): Response + { + $this->authorize('view', $trainingRecord); + + $trainingRecord->load(['employee', 'trainingCourse']); + + return Inertia::render('HR/TrainingRecords/Show', compact('trainingRecord')); + } + + public function destroy(EmployeeTrainingRecord $trainingRecord): RedirectResponse + { + $this->authorize('delete', $trainingRecord); + + $trainingRecord->delete(); + + return redirect()->route('hr.training-records.index') + ->with('success', 'Training record deleted.'); + } +} diff --git a/erp/app/Modules/HR/Http/Controllers/ExpenseClaimController.php b/erp/app/Modules/HR/Http/Controllers/ExpenseClaimController.php new file mode 100644 index 00000000000..1bf58ad0c1a --- /dev/null +++ b/erp/app/Modules/HR/Http/Controllers/ExpenseClaimController.php @@ -0,0 +1,164 @@ +authorize('viewAny', ExpenseClaim::class); + + $claims = ExpenseClaim::with(['employee']) + ->when($request->status, fn ($q) => $q->where('status', $request->status)) + ->orderBy('created_at', 'desc') + ->paginate(15) + ->withQueryString(); + + return Inertia::render('HR/ExpenseClaims/Index', [ + 'claims' => $claims, + 'filters' => $request->only(['status']), + ]); + } + + public function create(): Response + { + $this->authorize('create', ExpenseClaim::class); + + $employees = Employee::where('status', 'active') + ->orderBy('last_name') + ->get(['id', 'first_name', 'last_name']); + + return Inertia::render('HR/ExpenseClaims/Create', [ + 'employees' => $employees, + ]); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', ExpenseClaim::class); + + $validated = $request->validate([ + 'title' => 'required|string', + 'employee_id' => 'required|exists:employees,id', + 'description' => 'nullable|string', + ]); + + $claim = ExpenseClaim::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'employee_id' => $validated['employee_id'], + 'title' => $validated['title'], + 'description' => $validated['description'] ?? null, + 'status' => 'draft', + 'total_amount' => 0, + ]); + + return redirect()->route('hr.expense-claims.show', $claim); + } + + public function show(ExpenseClaim $expenseClaim): Response + { + $this->authorize('view', $expenseClaim); + + $expenseClaim->load(['items', 'employee', 'approvedBy']); + + return Inertia::render('HR/ExpenseClaims/Show', [ + 'expenseClaim' => $expenseClaim, + ]); + } + + public function destroy(ExpenseClaim $expenseClaim): RedirectResponse + { + $this->authorize('delete', $expenseClaim); + + $expenseClaim->delete(); + + return redirect()->route('hr.expense-claims.index'); + } + + public function submit(ExpenseClaim $expenseClaim): RedirectResponse + { + $this->authorize('update', $expenseClaim); + + $expenseClaim->submit(); + + return back()->with('success', 'Expense claim submitted.'); + } + + public function approve(ExpenseClaim $expenseClaim): RedirectResponse + { + $this->authorize('update', $expenseClaim); + + $expenseClaim->approve(auth()->user()); + + return back()->with('success', 'Expense claim approved.'); + } + + public function reject(Request $request, ExpenseClaim $expenseClaim): RedirectResponse + { + $this->authorize('update', $expenseClaim); + + $validated = $request->validate([ + 'reason' => 'required|string', + ]); + + $expenseClaim->reject($validated['reason']); + + return back()->with('success', 'Expense claim rejected.'); + } + + public function markPaid(ExpenseClaim $expenseClaim): RedirectResponse + { + $this->authorize('update', $expenseClaim); + + $expenseClaim->markPaid(); + + return back(); + } + + public function addItem(Request $request, ExpenseClaim $expenseClaim): RedirectResponse + { + $this->authorize('update', $expenseClaim); + + $validated = $request->validate([ + 'category' => 'required|string|max:50', + 'description' => 'required|string', + 'amount' => 'required|numeric|min:0.01', + 'expense_date' => 'required|date', + 'receipt_reference' => 'nullable|string', + ]); + + $expenseClaim->items()->create([ + 'tenant_id' => auth()->user()->tenant_id, + 'expense_claim_id' => $expenseClaim->id, + 'category' => $validated['category'], + 'description' => $validated['description'], + 'amount' => $validated['amount'], + 'expense_date' => $validated['expense_date'], + 'receipt_reference' => $validated['receipt_reference'] ?? null, + ]); + + $expenseClaim->recalculateTotal(); + + return back()->with('success', 'Item added.'); + } + + public function removeItem(ExpenseClaim $expenseClaim, ExpenseClaimItem $item): RedirectResponse + { + $this->authorize('update', $expenseClaim); + + $item->delete(); + + $expenseClaim->recalculateTotal(); + + return back(); + } +} diff --git a/erp/app/Modules/HR/Http/Controllers/FlexibleWorkController.php b/erp/app/Modules/HR/Http/Controllers/FlexibleWorkController.php new file mode 100644 index 00000000000..32c6fb3345a --- /dev/null +++ b/erp/app/Modules/HR/Http/Controllers/FlexibleWorkController.php @@ -0,0 +1,85 @@ +authorize('viewAny', FlexibleWorkArrangement::class); + + $arrangements = FlexibleWorkArrangement::with('employee') + ->orderByDesc('created_at') + ->paginate(20); + + return Inertia::render('HR/FlexibleWork/Index', compact('arrangements')); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', FlexibleWorkArrangement::class); + + $data = $request->validate([ + 'employee_id' => ['required', 'exists:employees,id'], + 'arrangement_type' => ['required', 'string', 'max:50'], + 'start_date' => ['required', 'date'], + 'end_date' => ['nullable', 'date', 'after_or_equal:start_date'], + 'hours_per_week' => ['nullable', 'integer', 'min:1', 'max:168'], + 'description' => ['nullable', 'string'], + ]); + + FlexibleWorkArrangement::create([ + 'tenant_id' => auth()->user()->tenant_id, + ...$data, + ]); + + return redirect()->route('hr.flexible-work.index')->with('success', 'Flexible work arrangement created.'); + } + + public function show(FlexibleWorkArrangement $flexibleWork): Response + { + $this->authorize('view', $flexibleWork); + + $flexibleWork->load('employee'); + + return Inertia::render('HR/FlexibleWork/Show', compact('flexibleWork')); + } + + public function approve(FlexibleWorkArrangement $flexibleWork): RedirectResponse + { + $this->authorize('update', $flexibleWork); + + $flexibleWork->approve(auth()->id()); + + return back()->with('success', 'Arrangement approved.'); + } + + public function reject(Request $request, FlexibleWorkArrangement $flexibleWork): RedirectResponse + { + $this->authorize('update', $flexibleWork); + + $request->validate([ + 'reason' => ['required', 'string'], + ]); + + $flexibleWork->reject($request->reason); + + return back()->with('success', 'Arrangement rejected.'); + } + + public function destroy(FlexibleWorkArrangement $flexibleWork): RedirectResponse + { + $this->authorize('delete', $flexibleWork); + + $flexibleWork->delete(); + + return back()->with('success', 'Arrangement deleted.'); + } +} diff --git a/erp/app/Modules/HR/Http/Controllers/GrievanceController.php b/erp/app/Modules/HR/Http/Controllers/GrievanceController.php new file mode 100644 index 00000000000..f55a37c70c8 --- /dev/null +++ b/erp/app/Modules/HR/Http/Controllers/GrievanceController.php @@ -0,0 +1,127 @@ +authorize('viewAny', Grievance::class); + + $grievances = Grievance::with(['employee', 'assignedTo']) + ->when($request->status, fn ($q) => $q->where('status', $request->status)) + ->orderBy('created_at', 'desc') + ->paginate(20) + ->withQueryString(); + + return Inertia::render('HR/Grievances/Index', [ + 'grievances' => $grievances, + 'filters' => $request->only(['status']), + ]); + } + + public function create(): Response + { + $this->authorize('create', Grievance::class); + + $employees = Employee::where('status', 'active') + ->orderBy('last_name') + ->get(['id', 'first_name', 'last_name']); + + return Inertia::render('HR/Grievances/Create', [ + 'employees' => $employees, + ]); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', Grievance::class); + + $validated = $request->validate([ + 'employee_id' => 'required|exists:employees,id', + 'category' => 'required|string', + 'description' => 'required|string', + 'submitted_date' => 'required|date', + 'is_anonymous' => 'nullable|boolean', + ]); + + $grievance = Grievance::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'employee_id' => $validated['employee_id'], + 'category' => $validated['category'], + 'description' => $validated['description'], + 'submitted_date' => $validated['submitted_date'], + 'is_anonymous' => $validated['is_anonymous'] ?? false, + 'status' => 'submitted', + ]); + + $grievance->update([ + 'reference' => 'GRV-' . now()->year . '-' . str_pad($grievance->id, 4, '0', STR_PAD_LEFT), + ]); + + return redirect()->route('hr.grievances.show', $grievance); + } + + public function show(Grievance $grievance): Response + { + $this->authorize('view', $grievance); + + $grievance->load(['employee', 'assignedTo']); + + return Inertia::render('HR/Grievances/Show', [ + 'grievance' => $grievance, + ]); + } + + public function destroy(Grievance $grievance): RedirectResponse + { + $this->authorize('delete', $grievance); + + $grievance->delete(); + + return redirect()->route('hr.grievances.index'); + } + + public function assign(Request $request, Grievance $grievance): RedirectResponse + { + $this->authorize('update', $grievance); + + $validated = $request->validate([ + 'assigned_to' => 'required|integer|exists:users,id', + ]); + + $grievance->assign($validated['assigned_to']); + + return redirect()->back(); + } + + public function resolve(Request $request, Grievance $grievance): RedirectResponse + { + $this->authorize('update', $grievance); + + $validated = $request->validate([ + 'resolution' => 'required|string', + ]); + + $grievance->resolve($validated['resolution']); + + return redirect()->back(); + } + + public function close(Request $request, Grievance $grievance): RedirectResponse + { + $this->authorize('update', $grievance); + + $grievance->close(); + + return redirect()->back(); + } +} diff --git a/erp/app/Modules/HR/Http/Controllers/HRDashboardController.php b/erp/app/Modules/HR/Http/Controllers/HRDashboardController.php new file mode 100644 index 00000000000..214180817c0 --- /dev/null +++ b/erp/app/Modules/HR/Http/Controllers/HRDashboardController.php @@ -0,0 +1,55 @@ +user()->tenant_id; + + $totalEmployees = Employee::where('tenant_id', $tenantId)->where('status', 'active')->count(); + $pendingLeaves = LeaveRequest::where('tenant_id', $tenantId)->where('status', 'pending')->count(); + $onLeaveToday = LeaveRequest::where('tenant_id', $tenantId)->where('status', 'approved') + ->whereDate('start_date', '<=', now())->whereDate('end_date', '>=', now())->count(); + $totalDepartments = Department::where('tenant_id', $tenantId)->count(); + $openPositions = JobPosition::where('tenant_id', $tenantId)->where('status', 'open')->count(); + $newHiresThisMonth = Employee::where('tenant_id', $tenantId) + ->whereYear('created_at', now()->year)->whereMonth('created_at', now()->month)->count(); + + $recentLeaveRequests = LeaveRequest::where('tenant_id', $tenantId) + ->where('status', 'pending') + ->with(['employee', 'leaveType']) + ->latest() + ->take(5) + ->get() + ->map(fn($lr) => [ + 'id' => $lr->id, + 'employee' => $lr->employee?->first_name . ' ' . $lr->employee?->last_name, + 'type' => $lr->leaveType?->name, + 'start_date' => $lr->start_date, + 'end_date' => $lr->end_date, + 'status' => $lr->status, + ]); + + $departmentHeadcount = Department::where('tenant_id', $tenantId) + ->withCount(['employees as active_count' => fn($q) => $q->where('status', 'active')]) + ->orderByDesc('active_count') + ->take(8) + ->get() + ->map(fn($d) => ['name' => $d->name, 'count' => $d->active_count]); + + return Inertia::render('HR/Dashboard', compact( + 'totalEmployees', 'pendingLeaves', 'onLeaveToday', 'totalDepartments', + 'openPositions', 'newHiresThisMonth', 'recentLeaveRequests', 'departmentHeadcount' + )); + } +} diff --git a/erp/app/Modules/HR/Http/Controllers/HRReportController.php b/erp/app/Modules/HR/Http/Controllers/HRReportController.php new file mode 100644 index 00000000000..93b210943a5 --- /dev/null +++ b/erp/app/Modules/HR/Http/Controllers/HRReportController.php @@ -0,0 +1,225 @@ +user()->tenant_id; + + $totalActive = Employee::where('tenant_id', $tenantId) + ->where('status', 'active') + ->count(); + + $newHiresThisMonth = Employee::where('tenant_id', $tenantId) + ->whereYear('start_date', now()->year) + ->whereMonth('start_date', now()->month) + ->count(); + + $terminationsThisMonth = Employee::where('tenant_id', $tenantId) + ->whereYear('end_date', now()->year) + ->whereMonth('end_date', now()->month) + ->count(); + + $turnoverRate = $totalActive > 0 + ? round(($terminationsThisMonth / max($totalActive, 1)) * 100, 2) + : 0; + + $departments = Department::where('tenant_id', $tenantId) + ->withCount(['employees as active_count' => fn($q) => $q->where('status', 'active')]) + ->orderByDesc('active_count') + ->get() + ->map(fn($d) => [ + 'id' => $d->id, + 'name' => $d->name, + 'count' => $d->active_count, + 'percentage' => $totalActive > 0 + ? round(($d->active_count / $totalActive) * 100, 1) + : 0, + ]); + + return Inertia::render('HR/Reports/Headcount', [ + 'totalActive' => $totalActive, + 'newHiresThisMonth' => $newHiresThisMonth, + 'terminationsThisMonth' => $terminationsThisMonth, + 'turnoverRate' => $turnoverRate, + 'departments' => $departments, + ]); + } + + // GET /hr/reports/leave-summary + public function leaveSummary(Request $request): Response + { + $tenantId = auth()->user()->tenant_id; + $dateFrom = $request->input('date_from', now()->startOfMonth()->toDateString()); + $dateTo = $request->input('date_to', now()->endOfMonth()->toDateString()); + + $query = LeaveRequest::where('tenant_id', $tenantId) + ->where(function ($q) use ($dateFrom, $dateTo) { + $q->whereBetween('start_date', [$dateFrom, $dateTo]) + ->orWhereBetween('end_date', [$dateFrom, $dateTo]); + }); + + $totalRequests = (clone $query)->count(); + $approvedCount = (clone $query)->where('status', 'approved')->count(); + $pendingCount = (clone $query)->where('status', 'pending')->count(); + $rejectedCount = (clone $query)->where('status', 'rejected')->count(); + + // Total days from approved requests + $totalDays = (clone $query)->where('status', 'approved') + ->get() + ->sum(fn($lr) => $lr->days ?? 0); + + // Leave type breakdown + $leaveTypes = LeaveType::where('tenant_id', $tenantId)->get(); + $typeBreakdown = $leaveTypes->map(function ($lt) use ($query, $tenantId, $dateFrom, $dateTo) { + $typeQuery = LeaveRequest::where('tenant_id', $tenantId) + ->where('leave_type_id', $lt->id) + ->where(function ($q) use ($dateFrom, $dateTo) { + $q->whereBetween('start_date', [$dateFrom, $dateTo]) + ->orWhereBetween('end_date', [$dateFrom, $dateTo]); + }); + + $approvedDays = (clone $typeQuery)->where('status', 'approved') + ->get() + ->sum(fn($lr) => $lr->days ?? 0); + $pendingDays = (clone $typeQuery)->where('status', 'pending') + ->get() + ->sum(fn($lr) => $lr->days ?? 0); + + return [ + 'id' => $lt->id, + 'name' => $lt->name, + 'count' => (clone $typeQuery)->count(), + 'approvedDays' => $approvedDays, + 'pendingDays' => $pendingDays, + ]; + })->filter(fn($t) => $t['count'] > 0)->values(); + + // Detailed list + $details = (clone $query) + ->with(['employee', 'leaveType']) + ->orderByDesc('start_date') + ->get() + ->map(fn($lr) => [ + 'id' => $lr->id, + 'employee' => $lr->employee?->first_name . ' ' . $lr->employee?->last_name, + 'leaveType' => $lr->leaveType?->name ?? '—', + 'start_date' => $lr->start_date?->toDateString(), + 'end_date' => $lr->end_date?->toDateString(), + 'days' => $lr->days ?? 0, + 'status' => $lr->status, + ]); + + return Inertia::render('HR/Reports/LeaveSummary', [ + 'dateFrom' => $dateFrom, + 'dateTo' => $dateTo, + 'totalRequests' => $totalRequests, + 'totalDays' => $totalDays, + 'approvedCount' => $approvedCount, + 'pendingCount' => $pendingCount, + 'rejectedCount' => $rejectedCount, + 'typeBreakdown' => $typeBreakdown, + 'details' => $details, + ]); + } + + // GET /hr/reports/department-summary + public function departmentSummary(Request $request): Response + { + $tenantId = auth()->user()->tenant_id; + + $totalEmployees = Employee::where('tenant_id', $tenantId) + ->where('status', 'active') + ->count(); + + $departments = Department::where('tenant_id', $tenantId) + ->with(['employees' => fn($q) => $q->where('status', 'active')->whereNotNull('start_date')]) + ->orderBy('name') + ->get() + ->map(function ($dept) use ($totalEmployees) { + $employees = $dept->employees; + $count = $employees->count(); + $avgTenure = 0; + + if ($count > 0) { + $tenureSum = $employees->sum(fn($e) => $e->start_date + ? $e->start_date->diffInMonths(now()) + : 0); + $avgTenure = round($tenureSum / $count, 1); + } + + return [ + 'id' => $dept->id, + 'name' => $dept->name, + 'count' => $count, + 'avgTenure' => $avgTenure, + 'percentage' => $totalEmployees > 0 + ? round(($count / $totalEmployees) * 100, 1) + : 0, + ]; + }); + + return Inertia::render('HR/Reports/DepartmentSummary', [ + 'departments' => $departments, + 'totalEmployees' => $totalEmployees, + ]); + } + + // GET /hr/reports/employee-tenure + public function employeeTenure(Request $request): Response + { + $tenantId = auth()->user()->tenant_id; + + $employees = Employee::where('tenant_id', $tenantId) + ->where('status', 'active') + ->whereNotNull('start_date') + ->with('department') + ->orderBy('start_date') + ->get() + ->map(function ($e) { + $tenureMonths = $e->start_date->diffInMonths(now()); + $tenureYears = round($tenureMonths / 12, 1); + + $bucket = match (true) { + $tenureMonths < 12 => '<1yr', + $tenureMonths < 36 => '1-3yr', + $tenureMonths < 60 => '3-5yr', + default => '5+yr', + }; + + return [ + 'id' => $e->id, + 'name' => $e->first_name . ' ' . $e->last_name, + 'department' => $e->department?->name ?? '—', + 'hire_date' => $e->start_date->toDateString(), + 'tenureMonths' => $tenureMonths, + 'tenureYears' => $tenureYears, + 'bucket' => $bucket, + ]; + }); + + $buckets = [ + '<1yr' => $employees->filter(fn($e) => $e['bucket'] === '<1yr')->count(), + '1-3yr' => $employees->filter(fn($e) => $e['bucket'] === '1-3yr')->count(), + '3-5yr' => $employees->filter(fn($e) => $e['bucket'] === '3-5yr')->count(), + '5+yr' => $employees->filter(fn($e) => $e['bucket'] === '5+yr')->count(), + ]; + + return Inertia::render('HR/Reports/EmployeeTenure', [ + 'employees' => $employees->values(), + 'buckets' => $buckets, + ]); + } +} diff --git a/erp/app/Modules/HR/Http/Controllers/HrAnnouncementController.php b/erp/app/Modules/HR/Http/Controllers/HrAnnouncementController.php new file mode 100644 index 00000000000..4cfcbb7f2cb --- /dev/null +++ b/erp/app/Modules/HR/Http/Controllers/HrAnnouncementController.php @@ -0,0 +1,96 @@ +authorize('viewAny', HrAnnouncement::class); + + $announcements = HrAnnouncement::with('createdBy') + ->when($request->target_audience, fn ($q) => $q->where('target_audience', $request->target_audience)) + ->latest() + ->paginate(20) + ->withQueryString(); + + return Inertia::render('HR/Announcements/Index', [ + 'announcements' => $announcements, + 'filters' => $request->only(['target_audience']), + ]); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', HrAnnouncement::class); + + $validated = $request->validate([ + 'title' => 'required|string|max:255', + 'body' => 'required|string', + 'target_audience' => 'nullable|string|in:all,department,role', + 'department_id' => 'nullable|exists:departments,id', + 'priority' => 'nullable|string|in:low,normal,high,urgent', + 'publish_at' => 'nullable|date', + 'expire_at' => 'nullable|date', + ]); + + $announcement = HrAnnouncement::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'created_by' => auth()->id(), + 'title' => $validated['title'], + 'body' => $validated['body'], + 'target_audience' => $validated['target_audience'] ?? 'all', + 'department_id' => $validated['department_id'] ?? null, + 'priority' => $validated['priority'] ?? 'normal', + 'publish_at' => $validated['publish_at'] ?? null, + 'expire_at' => $validated['expire_at'] ?? null, + ]); + + return redirect()->route('hr.announcements.show', $announcement); + } + + public function show(HrAnnouncement $announcement): Response + { + $this->authorize('view', $announcement); + + $announcement->load(['createdBy', 'department']); + + return Inertia::render('HR/Announcements/Show', [ + 'announcement' => $announcement, + ]); + } + + public function publish(HrAnnouncement $announcement): RedirectResponse + { + $this->authorize('update', $announcement); + + $announcement->publish(); + + return redirect()->back()->with('success', 'Announcement published successfully.'); + } + + public function archive(HrAnnouncement $announcement): RedirectResponse + { + $this->authorize('update', $announcement); + + $announcement->archive(); + + return redirect()->back()->with('success', 'Announcement archived successfully.'); + } + + public function destroy(HrAnnouncement $announcement): RedirectResponse + { + $this->authorize('delete', $announcement); + + $announcement->delete(); + + return redirect()->route('hr.announcements.index'); + } +} diff --git a/erp/app/Modules/HR/Http/Controllers/InterviewScheduleController.php b/erp/app/Modules/HR/Http/Controllers/InterviewScheduleController.php new file mode 100644 index 00000000000..3a012a91f4e --- /dev/null +++ b/erp/app/Modules/HR/Http/Controllers/InterviewScheduleController.php @@ -0,0 +1,158 @@ +authorize('viewAny', InterviewSchedule::class); + + $interviews = InterviewSchedule::with('interviewer') + ->latest() + ->paginate(15); + + return Inertia::render('HR/InterviewSchedules/Index', [ + 'interviews' => $interviews, + ]); + } + + public function create(): Response + { + $this->authorize('create', InterviewSchedule::class); + + return Inertia::render('HR/InterviewSchedules/Create'); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', InterviewSchedule::class); + + $data = $request->validate([ + 'candidate_name' => ['required', 'string', 'max:255'], + 'position_title' => ['required', 'string', 'max:255'], + 'scheduled_at' => ['required', 'date'], + 'candidate_email' => ['nullable', 'email', 'max:255'], + 'interview_type' => ['nullable', 'in:in-person,video,phone,panel'], + 'duration_minutes' => ['nullable', 'integer', 'min:15'], + 'location' => ['nullable', 'string', 'max:255'], + 'meeting_link' => ['nullable', 'string', 'max:255'], + 'notes' => ['nullable', 'string'], + 'interviewer_id' => ['nullable', 'exists:users,id'], + 'job_application_id' => ['nullable', 'exists:job_applications,id'], + ]); + + $data['tenant_id'] = app('tenant')->id; + $data['created_by'] = auth()->id(); + + InterviewSchedule::create($data); + + return redirect()->route('hr.interview-schedules.index') + ->with('success', 'Interview schedule created.'); + } + + public function show(InterviewSchedule $interviewSchedule): Response + { + $this->authorize('view', $interviewSchedule); + + $interviewSchedule->load('interviewer', 'jobApplication', 'createdBy'); + + return Inertia::render('HR/InterviewSchedules/Show', [ + 'interview' => $interviewSchedule, + ]); + } + + public function edit(InterviewSchedule $interviewSchedule): Response + { + $this->authorize('update', $interviewSchedule); + + return Inertia::render('HR/InterviewSchedules/Edit', [ + 'interview' => $interviewSchedule, + ]); + } + + public function update(Request $request, InterviewSchedule $interviewSchedule): RedirectResponse + { + $this->authorize('update', $interviewSchedule); + + $data = $request->validate([ + 'candidate_name' => ['required', 'string', 'max:255'], + 'position_title' => ['required', 'string', 'max:255'], + 'scheduled_at' => ['required', 'date'], + 'candidate_email' => ['nullable', 'email', 'max:255'], + 'interview_type' => ['nullable', 'in:in-person,video,phone,panel'], + 'duration_minutes' => ['nullable', 'integer', 'min:15'], + 'location' => ['nullable', 'string', 'max:255'], + 'meeting_link' => ['nullable', 'string', 'max:255'], + 'notes' => ['nullable', 'string'], + 'interviewer_id' => ['nullable', 'exists:users,id'], + ]); + + $interviewSchedule->update($data); + + return redirect()->route('hr.interview-schedules.index') + ->with('success', 'Interview schedule updated.'); + } + + public function destroy(InterviewSchedule $interviewSchedule): RedirectResponse + { + $this->authorize('delete', $interviewSchedule); + + $interviewSchedule->delete(); + + return redirect()->route('hr.interview-schedules.index') + ->with('success', 'Interview schedule deleted.'); + } + + public function confirm(InterviewSchedule $interviewSchedule): RedirectResponse + { + $this->authorize('confirm', $interviewSchedule); + + $interviewSchedule->confirm(); + + return redirect()->route('hr.interview-schedules.index') + ->with('success', 'Interview confirmed.'); + } + + public function complete(Request $request, InterviewSchedule $interviewSchedule): RedirectResponse + { + $this->authorize('complete', $interviewSchedule); + + $request->validate([ + 'outcome' => ['nullable', 'string', 'in:pass,fail,hold'], + 'feedback' => ['nullable', 'string'], + ]); + + $interviewSchedule->complete($request->outcome, $request->feedback); + + return redirect()->route('hr.interview-schedules.index') + ->with('success', 'Interview completed.'); + } + + public function cancel(InterviewSchedule $interviewSchedule): RedirectResponse + { + $this->authorize('cancel', $interviewSchedule); + + $interviewSchedule->cancel(); + + return redirect()->route('hr.interview-schedules.index') + ->with('success', 'Interview cancelled.'); + } + + public function noShow(InterviewSchedule $interviewSchedule): RedirectResponse + { + $this->authorize('markNoShow', $interviewSchedule); + + $interviewSchedule->markNoShow(); + + return redirect()->route('hr.interview-schedules.index') + ->with('success', 'Interview marked as no-show.'); + } +} diff --git a/erp/app/Modules/HR/Http/Controllers/JobApplicationController.php b/erp/app/Modules/HR/Http/Controllers/JobApplicationController.php new file mode 100644 index 00000000000..507264d8740 --- /dev/null +++ b/erp/app/Modules/HR/Http/Controllers/JobApplicationController.php @@ -0,0 +1,132 @@ +authorize('viewAny', JobApplication::class); + + $query = JobApplication::with('jobPosition')->latest(); + + if ($request->filled('job_position_id')) { + $query->where('job_position_id', $request->job_position_id); + } + if ($request->filled('status')) { + $query->where('status', $request->status); + } + + $applications = $query->paginate(15); + + return Inertia::render('HR/JobApplications/Index', compact('applications')); + } + + public function create(): Response + { + $this->authorize('create', JobApplication::class); + + $positions = JobPosition::where('is_active', true)->orWhere('status', 'open') + ->orderBy('title')->get(['id', 'title']); + + return Inertia::render('HR/JobApplications/Create', compact('positions')); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', JobApplication::class); + + $data = $request->validate([ + 'job_position_id' => ['required', Rule::exists('job_positions', 'id')], + 'applicant_name' => ['required', 'string', 'max:255'], + 'applicant_email' => ['required', 'email', 'max:255'], + 'applicant_phone' => ['nullable', 'string', 'max:50'], + 'cover_letter' => ['nullable', 'string'], + 'resume_url' => ['nullable', 'string'], + 'source' => ['nullable', 'string', 'max:100'], + 'rating' => ['nullable', 'integer', 'min:1', 'max:5'], + ]); + + $data['tenant_id'] = auth()->user()->tenant_id; + $data['status'] = 'new'; + + $application = JobApplication::create($data); + + return redirect()->back()->with('success', 'Job application created.'); + } + + public function show(JobApplication $jobApplication): Response + { + $this->authorize('view', $jobApplication); + + $jobApplication->load('jobPosition', 'reviewer'); + + return Inertia::render('HR/JobApplications/Show', [ + 'application' => $jobApplication, + ]); + } + + public function destroy(JobApplication $jobApplication): RedirectResponse + { + $this->authorize('delete', $jobApplication); + + $jobApplication->delete(); + + return redirect()->back()->with('success', 'Job application deleted.'); + } + + public function advance(Request $request, JobApplication $jobApplication): RedirectResponse + { + $this->authorize('update', $jobApplication); + + $validStages = ['screening', 'interview', 'offer', 'applied', 'hired', 'rejected']; + + $data = $request->validate([ + 'status' => ['nullable', Rule::in($validStages)], + 'stage' => ['nullable', Rule::in($validStages)], + ]); + + $newStatus = $data['status'] ?? $data['stage'] ?? null; + + if ($newStatus === null) { + return back()->withErrors(['status' => 'A status is required.']); + } + + $jobApplication->advance($newStatus); + + return redirect()->back()->with('success', 'Application status updated.'); + } + + public function hire(JobApplication $jobApplication): RedirectResponse + { + $this->authorize('update', $jobApplication); + + $jobApplication->hire(); + + return redirect()->back()->with('success', 'Applicant hired.'); + } + + public function reject(Request $request, JobApplication $jobApplication): RedirectResponse + { + $this->authorize('update', $jobApplication); + + $request->validate([ + 'notes' => ['nullable', 'string'], + 'reason' => ['nullable', 'string'], + ]); + + $notes = $request->notes ?? $request->reason ?? ''; + $jobApplication->reject($notes); + + return redirect()->back()->with('success', 'Application rejected.'); + } +} diff --git a/erp/app/Modules/HR/Http/Controllers/JobOfferController.php b/erp/app/Modules/HR/Http/Controllers/JobOfferController.php new file mode 100644 index 00000000000..744e617ffdb --- /dev/null +++ b/erp/app/Modules/HR/Http/Controllers/JobOfferController.php @@ -0,0 +1,88 @@ +authorize('viewAny', JobOfferLetter::class); + + $offers = JobOfferLetter::orderByDesc('created_at')->paginate(20); + + return Inertia::render('HR/JobOffers/Index', compact('offers')); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', JobOfferLetter::class); + + $data = $request->validate([ + 'candidate_name' => ['required', 'string', 'max:255'], + 'candidate_email' => ['required', 'email'], + 'position_title' => ['required', 'string', 'max:255'], + 'offered_salary' => ['nullable', 'numeric', 'min:0'], + 'proposed_start_date'=> ['nullable', 'date'], + 'offer_expiry_date' => ['nullable', 'date'], + 'offer_terms' => ['nullable', 'string'], + ]); + + JobOfferLetter::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'created_by' => auth()->id(), + ...$data, + ]); + + return redirect()->route('hr.job-offers.index')->with('success', 'Job offer letter created.'); + } + + public function show(JobOfferLetter $jobOffer): Response + { + $this->authorize('view', $jobOffer); + + return Inertia::render('HR/JobOffers/Show', compact('jobOffer')); + } + + public function send(JobOfferLetter $jobOffer): RedirectResponse + { + $this->authorize('update', $jobOffer); + + $jobOffer->send(); + + return back()->with('success', 'Offer letter sent.'); + } + + public function accept(JobOfferLetter $jobOffer): RedirectResponse + { + $this->authorize('update', $jobOffer); + + $jobOffer->accept(); + + return back()->with('success', 'Offer letter accepted.'); + } + + public function decline(JobOfferLetter $jobOffer): RedirectResponse + { + $this->authorize('update', $jobOffer); + + $jobOffer->decline(); + + return back()->with('success', 'Offer letter declined.'); + } + + public function destroy(JobOfferLetter $jobOffer): RedirectResponse + { + $this->authorize('delete', $jobOffer); + + $jobOffer->delete(); + + return back()->with('success', 'Offer letter deleted.'); + } +} diff --git a/erp/app/Modules/HR/Http/Controllers/JobPositionController.php b/erp/app/Modules/HR/Http/Controllers/JobPositionController.php new file mode 100644 index 00000000000..54a24cd83ba --- /dev/null +++ b/erp/app/Modules/HR/Http/Controllers/JobPositionController.php @@ -0,0 +1,114 @@ +authorize('viewAny', JobPosition::class); + + $query = JobPosition::with('department') + ->withCount('applications') + ->latest(); + + if ($request->filled('department')) { + $query->where('department', $request->department); + } + if ($request->has('is_active') && $request->is_active !== null) { + $query->where('is_active', filter_var($request->is_active, FILTER_VALIDATE_BOOLEAN)); + } + + $positions = $query->paginate(15); + + return Inertia::render('HR/JobPositions/Index', compact('positions')); + } + + public function create(): Response + { + $this->authorize('create', JobPosition::class); + + $departments = Department::orderBy('name')->get(['id', 'name']); + + return Inertia::render('HR/JobPositions/Create', compact('departments')); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', JobPosition::class); + + $data = $request->validate([ + 'title' => ['required', 'string', 'max:255'], + 'employment_type' => ['required', Rule::in(['full_time', 'part_time', 'contract', 'internship'])], + 'department' => ['nullable', 'string', 'max:255'], + 'department_id' => ['nullable', Rule::exists('departments', 'id')], + 'location' => ['nullable', 'string', 'max:255'], + 'description' => ['nullable', 'string'], + 'requirements' => ['nullable', 'string'], + 'salary_min' => ['nullable', 'numeric', 'min:0'], + 'salary_max' => ['nullable', 'numeric', 'min:0'], + 'openings' => ['integer', 'min:1'], + 'is_active' => ['boolean'], + 'posted_at' => ['nullable', 'date'], + 'closes_at' => ['nullable', 'date'], + ]); + + $data['tenant_id'] = auth()->user()->tenant_id; + $data['openings'] = $data['openings'] ?? 1; + $data['is_active'] = $data['is_active'] ?? true; + + $position = JobPosition::create($data); + + return redirect()->back()->with('success', 'Job position created.'); + } + + public function show(JobPosition $jobPosition): Response + { + $this->authorize('view', $jobPosition); + + $jobPosition->load([ + 'department', + 'applications' => fn ($q) => $q->latest(), + ]); + + return Inertia::render('HR/JobPositions/Show', [ + 'position' => $jobPosition, + ]); + } + + public function destroy(JobPosition $jobPosition): RedirectResponse + { + $this->authorize('delete', $jobPosition); + + $jobPosition->delete(); + + return redirect()->back()->with('success', 'Job position deleted.'); + } + + public function publish(JobPosition $jobPosition): RedirectResponse + { + $this->authorize('update', $jobPosition); + + $jobPosition->publish(); + + return redirect()->back()->with('success', 'Job position published.'); + } + + public function close(JobPosition $jobPosition): RedirectResponse + { + $this->authorize('update', $jobPosition); + + $jobPosition->close(); + + return redirect()->back()->with('success', 'Job position closed.'); + } +} diff --git a/erp/app/Modules/HR/Http/Controllers/LeaveBalanceController.php b/erp/app/Modules/HR/Http/Controllers/LeaveBalanceController.php new file mode 100644 index 00000000000..575c231a204 --- /dev/null +++ b/erp/app/Modules/HR/Http/Controllers/LeaveBalanceController.php @@ -0,0 +1,47 @@ +authorize('viewAny', LeaveBalance::class); + + $balances = LeaveBalance::with(['employee', 'leaveType']) + ->when($request->employee_id, fn ($q) => $q->where('employee_id', $request->employee_id)) + ->when($request->year, fn ($q) => $q->where('year', $request->year)) + ->latest() + ->paginate(25) + ->withQueryString(); + + return Inertia::render('HR/LeaveBalances/Index', [ + 'balances' => $balances, + 'filters' => $request->only(['employee_id', 'year']), + 'breadcrumbs' => [ + ['label' => 'HR'], + ['label' => 'Leave Balances', 'href' => route('hr.leave-balances.index')], + ], + ]); + } + + public function update(Request $request, LeaveBalance $leaveBalance): RedirectResponse + { + $this->authorize('update', $leaveBalance); + + $data = $request->validate([ + 'allocated_days' => 'required|numeric|min:0', + ]); + + $leaveBalance->update($data); + + return back()->with('success', 'Leave balance updated.'); + } +} diff --git a/erp/app/Modules/HR/Http/Controllers/LeaveRequestController.php b/erp/app/Modules/HR/Http/Controllers/LeaveRequestController.php new file mode 100644 index 00000000000..fa135b79557 --- /dev/null +++ b/erp/app/Modules/HR/Http/Controllers/LeaveRequestController.php @@ -0,0 +1,239 @@ +authorize('viewAny', LeaveRequest::class); + + $requests = LeaveRequest::with(['employee', 'leaveType', 'reviewer']) + ->when($request->status, fn ($q) => $q->where('status', $request->status)) + ->when($request->employee_id, fn ($q) => $q->where('employee_id', $request->employee_id)) + ->latest() + ->paginate(25) + ->withQueryString(); + + return Inertia::render('HR/LeaveRequests/Index', [ + 'requests' => LeaveRequestResource::collection($requests), + 'employees' => Employee::active()->orderBy('last_name')->get()->map(fn ($e) => [ + 'id' => $e->id, 'full_name' => $e->full_name, + ]), + 'leaveTypes' => LeaveType::where('is_active', true)->orderBy('name')->get(['id', 'name']), + 'filters' => $request->only(['status', 'employee_id']), + 'breadcrumbs' => [ + ['label' => 'HR'], + ['label' => 'Leave Requests', 'href' => route('hr.leave-requests.index')], + ], + ]); + } + + /** Legacy index for /hr/leave (backward compat) */ + public function legacyIndex(Request $request): Response + { + $this->authorize('viewAny', Employee::class); + + $requests = LeaveRequest::with(['employee', 'leaveType', 'reviewer']) + ->when($request->status, fn ($q) => $q->where('status', $request->status)) + ->when($request->employee_id, fn ($q) => $q->where('employee_id', $request->employee_id)) + ->latest() + ->paginate(25) + ->withQueryString(); + + return Inertia::render('HR/Leave/Index', [ + 'requests' => $requests->through(fn ($lr) => [ + 'id' => $lr->id, + 'employee' => ['id' => $lr->employee->id, 'full_name' => $lr->employee->full_name], + 'leave_type' => $lr->leaveType?->name, + 'start_date' => $lr->start_date?->toDateString(), + 'end_date' => $lr->end_date?->toDateString(), + 'days' => $lr->days, + 'status' => $lr->status, + 'notes' => $lr->notes, + 'reviewed_by' => $lr->reviewer?->name, + 'reviewed_at' => $lr->reviewed_at?->toDateTimeString(), + ]), + 'employees' => Employee::active()->orderBy('last_name')->get()->map(fn ($e) => [ + 'id' => $e->id, 'full_name' => $e->full_name, + ]), + 'filters' => $request->only(['status', 'employee_id']), + 'breadcrumbs' => [ + ['label' => 'HR'], + ['label' => 'Leave Requests', 'href' => route('hr.leave.index')], + ], + ]); + } + + /** Legacy store for /hr/leave (backward compat) */ + public function legacyStore(StoreLeaveRequestRequest $request): RedirectResponse + { + $this->authorize('create', Employee::class); + + $data = $request->validated(); + + LeaveRequest::create([ + ...$data, + 'tenant_id' => auth()->user()->tenant_id, + ]); + + return back()->with('success', 'Leave request submitted.'); + } + + public function create(): Response + { + $this->authorize('create', LeaveRequest::class); + + return Inertia::render('HR/LeaveRequests/Create', [ + 'employees' => Employee::active()->orderBy('last_name')->get()->map(fn ($e) => [ + 'id' => $e->id, 'full_name' => $e->full_name, + ]), + 'leaveTypes' => LeaveType::where('is_active', true)->orderBy('name')->get(['id', 'name', 'default_days']), + 'breadcrumbs' => [ + ['label' => 'HR'], + ['label' => 'Leave Requests', 'href' => route('hr.leave-requests.index')], + ['label' => 'New Request'], + ], + ]); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', LeaveRequest::class); + + $data = $request->validate([ + 'employee_id' => 'required|integer|exists:employees,id', + 'leave_type_id' => 'required|integer|exists:leave_types,id', + 'start_date' => 'required|date', + 'end_date' => 'required|date|after_or_equal:start_date', + 'days_requested' => 'nullable|numeric|min:0.5', + 'reason' => 'nullable|string', + ]); + + // Compute days_requested if not provided + if (empty($data['days_requested'])) { + $start = \Carbon\Carbon::parse($data['start_date']); + $end = \Carbon\Carbon::parse($data['end_date']); + $data['days_requested'] = max(0.5, $start->diffInDays($end) + 1); + } + + $daysRequested = (float) $data['days_requested']; + + $leaveRequest = LeaveRequest::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'employee_id' => $data['employee_id'], + 'leave_type_id' => $data['leave_type_id'], + 'start_date' => $data['start_date'], + 'end_date' => $data['end_date'], + 'days_requested' => $daysRequested, + 'days' => (int) $daysRequested, + 'reason' => $data['reason'] ?? null, + 'notes' => $data['reason'] ?? null, + 'status' => 'pending', + ]); + + // Create or update LeaveBalance — increment pending_days + $leaveType = LeaveType::find($data['leave_type_id']); + $year = \Carbon\Carbon::parse($data['start_date'])->year; + + $balance = LeaveBalance::firstOrCreate( + [ + 'employee_id' => $data['employee_id'], + 'leave_type_id' => $data['leave_type_id'], + 'year' => $year, + ], + [ + 'tenant_id' => auth()->user()->tenant_id, + 'allocated_days' => $leaveType?->default_days ?: ($leaveType?->days_per_year ?: 0), + 'used_days' => 0, + 'pending_days' => 0, + ] + ); + + $balance->increment('pending_days', $daysRequested); + + return redirect()->route('hr.leave-requests.show', $leaveRequest) + ->with('success', 'Leave request submitted.'); + } + + public function show(LeaveRequest $leaveRequest): Response + { + $this->authorize('view', $leaveRequest); + + $leaveRequest->load(['employee', 'leaveType', 'approver', 'reviewer']); + + return Inertia::render('HR/LeaveRequests/Show', [ + 'leaveRequest' => new LeaveRequestResource($leaveRequest), + 'can' => [ + 'update' => auth()->user()->can('update', $leaveRequest), + ], + 'breadcrumbs' => [ + ['label' => 'HR'], + ['label' => 'Leave Requests', 'href' => route('hr.leave-requests.index')], + ['label' => "Leave Request #{$leaveRequest->id}"], + ], + ]); + } + + public function destroy(LeaveRequest $leaveRequest): RedirectResponse + { + $this->authorize('delete', $leaveRequest); + + $leaveRequest->delete(); + + return redirect()->route('hr.leave-requests.index') + ->with('success', 'Leave request deleted.'); + } + + public function approve(Request $request, LeaveRequest $leaveRequest): RedirectResponse + { + $this->authorize('update', $leaveRequest); + + if ($leaveRequest->status !== 'pending') { + return back()->withErrors(['status' => 'Only pending leave requests can be approved.']); + } + + $leaveRequest->approve(auth()->user()); + + return back()->with('success', 'Leave request approved.'); + } + + public function reject(Request $request, LeaveRequest $leaveRequest): RedirectResponse + { + $this->authorize('update', $leaveRequest); + + if ($leaveRequest->status !== 'pending') { + return back()->withErrors(['status' => 'Only pending leave requests can be rejected.']); + } + + $data = $request->validate([ + 'rejection_reason' => 'nullable|string', + ]); + + $leaveRequest->reject(auth()->user(), $data['rejection_reason'] ?? ''); + + return back()->with('success', 'Leave request rejected.'); + } + + public function cancel(Request $request, LeaveRequest $leaveRequest): RedirectResponse + { + $this->authorize('update', $leaveRequest); + + $leaveRequest->cancel(); + + return back()->with('success', 'Leave request cancelled.'); + } +} diff --git a/erp/app/Modules/HR/Http/Controllers/LeaveTypeController.php b/erp/app/Modules/HR/Http/Controllers/LeaveTypeController.php new file mode 100644 index 00000000000..b64756a6683 --- /dev/null +++ b/erp/app/Modules/HR/Http/Controllers/LeaveTypeController.php @@ -0,0 +1,90 @@ +authorize('viewAny', LeaveType::class); + + $leaveTypes = LeaveType::latest()->paginate(25)->withQueryString(); + + return Inertia::render('HR/LeaveTypes/Index', [ + 'leaveTypes' => $leaveTypes, + 'breadcrumbs' => [ + ['label' => 'HR'], + ['label' => 'Leave Types', 'href' => route('hr.leave-types.index')], + ], + ]); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', LeaveType::class); + + $data = $request->validate([ + 'name' => 'required|string|max:255', + 'code' => 'nullable|string|max:10', + 'default_days' => 'nullable|integer|min:0', + 'is_paid' => 'nullable|boolean', + 'requires_approval' => 'nullable|boolean', + 'description' => 'nullable|string', + ]); + + LeaveType::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'name' => $data['name'], + 'code' => $data['code'] ?? null, + 'default_days' => $data['default_days'] ?? 0, + 'days_per_year' => $data['default_days'] ?? 0, + 'is_paid' => $data['is_paid'] ?? true, + 'requires_approval' => $data['requires_approval'] ?? true, + 'description' => $data['description'] ?? null, + ]); + + return back()->with('success', 'Leave type created.'); + } + + public function update(Request $request, LeaveType $leaveType): RedirectResponse + { + $this->authorize('update', $leaveType); + + $data = $request->validate([ + 'name' => 'required|string|max:255', + 'code' => 'nullable|string|max:10', + 'default_days' => 'nullable|integer|min:0', + 'is_paid' => 'nullable|boolean', + 'requires_approval' => 'nullable|boolean', + 'description' => 'nullable|string', + ]); + + $leaveType->update([ + 'name' => $data['name'], + 'code' => $data['code'] ?? $leaveType->code, + 'default_days' => $data['default_days'] ?? $leaveType->default_days, + 'days_per_year' => $data['default_days'] ?? $leaveType->days_per_year, + 'is_paid' => $data['is_paid'] ?? $leaveType->is_paid, + 'requires_approval' => $data['requires_approval'] ?? $leaveType->requires_approval, + 'description' => $data['description'] ?? $leaveType->description, + ]); + + return back()->with('success', 'Leave type updated.'); + } + + public function destroy(LeaveType $leaveType): RedirectResponse + { + $this->authorize('delete', $leaveType); + + $leaveType->delete(); + + return back()->with('success', 'Leave type deleted.'); + } +} diff --git a/erp/app/Modules/HR/Http/Controllers/MentorshipProgramController.php b/erp/app/Modules/HR/Http/Controllers/MentorshipProgramController.php new file mode 100644 index 00000000000..5daead7a2f4 --- /dev/null +++ b/erp/app/Modules/HR/Http/Controllers/MentorshipProgramController.php @@ -0,0 +1,165 @@ +authorize('viewAny', MentorshipProgram::class); + + $query = MentorshipProgram::with(['mentor', 'mentee']) + ->orderByDesc('created_at'); + + if ($request->filled('status')) { + $query->where('status', $request->status); + } + + $programs = $query->paginate(15); + $filters = $request->only(['status']); + + return Inertia::render('HR/MentorshipPrograms/Index', compact('programs', 'filters')); + } + + public function create(): Response + { + $this->authorize('create', MentorshipProgram::class); + + $employees = Employee::where('status', 'active') + ->orderBy('first_name') + ->get(['id', 'first_name', 'last_name']); + + return Inertia::render('HR/MentorshipPrograms/Create', compact('employees')); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', MentorshipProgram::class); + + $data = $request->validate([ + 'mentor_id' => ['required', 'exists:employees,id'], + 'mentee_id' => ['required', 'exists:employees,id'], + 'title' => ['required', 'string'], + 'start_date' => ['required', 'date'], + 'objectives' => ['nullable', 'string'], + 'end_date' => ['nullable', 'date'], + 'status' => ['nullable', 'string'], + 'meeting_frequency' => ['nullable', 'string'], + 'sessions_planned' => ['nullable', 'integer'], + 'notes' => ['nullable', 'string'], + ]); + + $data['tenant_id'] = app('tenant')->id; + $data['created_by'] = auth()->id(); + + MentorshipProgram::create($data); + + return redirect()->route('hr.mentorship-programs.index'); + } + + public function show(MentorshipProgram $mentorshipProgram): Response + { + $this->authorize('view', $mentorshipProgram); + + $mentorshipProgram->load(['mentor', 'mentee']); + + return Inertia::render('HR/MentorshipPrograms/Show', compact('mentorshipProgram')); + } + + public function edit(MentorshipProgram $mentorshipProgram): Response + { + $this->authorize('update', $mentorshipProgram); + + $employees = Employee::where('status', 'active') + ->orderBy('first_name') + ->get(['id', 'first_name', 'last_name']); + + $mentorshipProgram->load(['mentor', 'mentee']); + + return Inertia::render('HR/MentorshipPrograms/Edit', compact('mentorshipProgram', 'employees')); + } + + public function update(Request $request, MentorshipProgram $mentorshipProgram): RedirectResponse + { + $this->authorize('update', $mentorshipProgram); + + $data = $request->validate([ + 'mentor_id' => ['required', 'exists:employees,id'], + 'mentee_id' => ['required', 'exists:employees,id'], + 'title' => ['required', 'string'], + 'start_date' => ['required', 'date'], + 'objectives' => ['nullable', 'string'], + 'end_date' => ['nullable', 'date'], + 'status' => ['nullable', 'string'], + 'meeting_frequency' => ['nullable', 'string'], + 'sessions_planned' => ['nullable', 'integer'], + 'notes' => ['nullable', 'string'], + ]); + + $mentorshipProgram->update($data); + + return redirect()->route('hr.mentorship-programs.index'); + } + + public function destroy(MentorshipProgram $mentorshipProgram): RedirectResponse + { + $this->authorize('delete', $mentorshipProgram); + + $mentorshipProgram->delete(); + + return redirect()->route('hr.mentorship-programs.index'); + } + + public function complete(MentorshipProgram $mentorshipProgram): RedirectResponse + { + $this->authorize('complete', $mentorshipProgram); + + $mentorshipProgram->complete(); + + return redirect()->route('hr.mentorship-programs.index'); + } + + public function pause(MentorshipProgram $mentorshipProgram): RedirectResponse + { + $this->authorize('pause', $mentorshipProgram); + + $mentorshipProgram->pause(); + + return redirect()->route('hr.mentorship-programs.index'); + } + + public function resume(MentorshipProgram $mentorshipProgram): RedirectResponse + { + $this->authorize('resume', $mentorshipProgram); + + $mentorshipProgram->resume(); + + return redirect()->route('hr.mentorship-programs.index'); + } + + public function cancel(MentorshipProgram $mentorshipProgram): RedirectResponse + { + $this->authorize('cancel', $mentorshipProgram); + + $mentorshipProgram->cancel(); + + return redirect()->route('hr.mentorship-programs.index'); + } + + public function logSession(MentorshipProgram $mentorshipProgram): RedirectResponse + { + $this->authorize('logSession', $mentorshipProgram); + + $mentorshipProgram->logSession(); + + return redirect()->route('hr.mentorship-programs.index'); + } +} diff --git a/erp/app/Modules/HR/Http/Controllers/OnboardingChecklistController.php b/erp/app/Modules/HR/Http/Controllers/OnboardingChecklistController.php new file mode 100644 index 00000000000..4c598db5475 --- /dev/null +++ b/erp/app/Modules/HR/Http/Controllers/OnboardingChecklistController.php @@ -0,0 +1,97 @@ +authorize('viewAny', OnboardingChecklist::class); + + $checklists = OnboardingChecklist::withCount('tasks') + ->orderBy('name') + ->paginate(20); + + return Inertia::render('HR/OnboardingChecklists/Index', [ + 'checklists' => $checklists, + ]); + } + + public function create(): Response + { + $this->authorize('create', OnboardingChecklist::class); + + return Inertia::render('HR/OnboardingChecklists/Create'); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', OnboardingChecklist::class); + + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'department' => 'nullable|string|max:255', + 'description' => 'nullable|string', + 'is_active' => 'boolean', + 'tasks' => 'array', + 'tasks.*.title' => 'required|string|max:255', + 'tasks.*.description' => 'nullable|string', + 'tasks.*.category' => 'nullable|string|max:255', + 'tasks.*.due_day_offset' => 'integer|min:0', + 'tasks.*.is_required' => 'boolean', + 'tasks.*.sort_order' => 'integer|min:0', + ]); + + $checklist = OnboardingChecklist::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'name' => $validated['name'], + 'department' => $validated['department'] ?? null, + 'description' => $validated['description'] ?? null, + 'is_active' => $validated['is_active'] ?? true, + ]); + + foreach ($validated['tasks'] ?? [] as $taskData) { + $checklist->tasks()->create([ + 'tenant_id' => auth()->user()->tenant_id, + 'title' => $taskData['title'], + 'description' => $taskData['description'] ?? null, + 'category' => $taskData['category'] ?? null, + 'due_day_offset' => $taskData['due_day_offset'] ?? 0, + 'is_required' => $taskData['is_required'] ?? true, + 'sort_order' => $taskData['sort_order'] ?? 0, + ]); + } + + return redirect()->route('hr.onboarding-checklists.show', $checklist) + ->with('success', 'Onboarding checklist created.'); + } + + public function show(OnboardingChecklist $onboardingChecklist): Response + { + $this->authorize('view', $onboardingChecklist); + + $onboardingChecklist->load('tasks'); + + return Inertia::render('HR/OnboardingChecklists/Show', [ + 'checklist' => $onboardingChecklist, + ]); + } + + public function destroy(OnboardingChecklist $onboardingChecklist): RedirectResponse + { + $this->authorize('delete', $onboardingChecklist); + + $onboardingChecklist->delete(); + + return redirect()->route('hr.onboarding-checklists.index') + ->with('success', 'Onboarding checklist deleted.'); + } +} diff --git a/erp/app/Modules/HR/Http/Controllers/OnboardingTemplateController.php b/erp/app/Modules/HR/Http/Controllers/OnboardingTemplateController.php new file mode 100644 index 00000000000..dc97a7baede --- /dev/null +++ b/erp/app/Modules/HR/Http/Controllers/OnboardingTemplateController.php @@ -0,0 +1,112 @@ +authorize('viewAny', OnboardingTemplate::class); + + $templates = OnboardingTemplate::withCount('tasks') + ->orderBy('name') + ->get(); + + return Inertia::render('HR/OnboardingTemplates/Index', [ + 'templates' => $templates, + 'breadcrumbs' => [ + ['label' => 'HR'], + ['label' => 'Onboarding Templates', 'href' => route('hr.onboarding-templates.index')], + ], + ]); + } + + public function create(): Response + { + $this->authorize('create', OnboardingTemplate::class); + + return Inertia::render('HR/OnboardingTemplates/Create', [ + 'breadcrumbs' => [ + ['label' => 'HR'], + ['label' => 'Onboarding Templates', 'href' => route('hr.onboarding-templates.index')], + ['label' => 'New Template'], + ], + ]); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', OnboardingTemplate::class); + + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'description' => 'nullable|string', + 'is_active' => 'boolean', + 'tasks' => 'array', + 'tasks.*.title' => 'required|string|max:255', + 'tasks.*.description' => 'nullable|string', + 'tasks.*.due_days' => 'integer|min:0|max:255', + 'tasks.*.sort_order' => 'integer|min:0|max:255', + ]); + + $template = OnboardingTemplate::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'name' => $validated['name'], + 'description' => $validated['description'] ?? null, + 'is_active' => $validated['is_active'] ?? true, + ]); + + foreach ($validated['tasks'] ?? [] as $taskData) { + $template->tasks()->create([ + 'title' => $taskData['title'], + 'description' => $taskData['description'] ?? null, + 'due_days' => $taskData['due_days'] ?? 0, + 'sort_order' => $taskData['sort_order'] ?? 0, + ]); + } + + return redirect()->route('hr.onboarding-templates.show', $template) + ->with('success', 'Onboarding template created.'); + } + + public function show(OnboardingTemplate $onboardingTemplate): Response + { + $this->authorize('view', $onboardingTemplate); + + $onboardingTemplate->load('tasks'); + + return Inertia::render('HR/OnboardingTemplates/Show', [ + 'template' => $onboardingTemplate, + 'breadcrumbs' => [ + ['label' => 'HR'], + ['label' => 'Onboarding Templates', 'href' => route('hr.onboarding-templates.index')], + ['label' => $onboardingTemplate->name], + ], + ]); + } + + public function destroy(OnboardingTemplate $onboardingTemplate): RedirectResponse + { + $this->authorize('delete', $onboardingTemplate); + + $hasActiveOnboardings = $onboardingTemplate->onboardings() + ->where('status', 'in_progress') + ->exists(); + + if ($hasActiveOnboardings) { + return back()->withErrors(['template' => 'Cannot delete a template with in-progress onboardings.']); + } + + $onboardingTemplate->delete(); + + return redirect()->route('hr.onboarding-templates.index') + ->with('success', 'Onboarding template deleted.'); + } +} diff --git a/erp/app/Modules/HR/Http/Controllers/OvertimeRequestController.php b/erp/app/Modules/HR/Http/Controllers/OvertimeRequestController.php new file mode 100644 index 00000000000..59b6ff78e9b --- /dev/null +++ b/erp/app/Modules/HR/Http/Controllers/OvertimeRequestController.php @@ -0,0 +1,74 @@ +authorize('viewAny', OvertimeRequest::class); + $requests = OvertimeRequest::with('employee') + ->where('tenant_id', app('tenant')->id) + ->latest() + ->paginate(20); + return Inertia::render('HR/OvertimeRequests/Index', compact('requests')); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', OvertimeRequest::class); + $validated = $request->validate([ + 'employee_id' => 'required|exists:employees,id', + 'work_date' => 'required|date', + 'hours' => 'required|numeric|min:0.5|max:24', + 'rate_multiplier'=> 'sometimes|numeric|min:1', + 'reason' => 'nullable|string', + ]); + $validated['tenant_id'] = app('tenant')->id; + OvertimeRequest::create($validated); + return back()->with('success', 'Overtime request submitted.'); + } + + public function show(OvertimeRequest $overtimeRequest): Response + { + $this->authorize('view', $overtimeRequest); + return Inertia::render('HR/OvertimeRequests/Show', compact('overtimeRequest')); + } + + public function approve(OvertimeRequest $overtimeRequest): RedirectResponse + { + $this->authorize('update', $overtimeRequest); + $overtimeRequest->approve(auth()->id()); + return back()->with('success', 'Overtime request approved.'); + } + + public function reject(Request $request, OvertimeRequest $overtimeRequest): RedirectResponse + { + $this->authorize('update', $overtimeRequest); + $validated = $request->validate(['reason' => 'required|string']); + $overtimeRequest->reject($validated['reason']); + return back()->with('success', 'Overtime request rejected.'); + } + + public function cancel(OvertimeRequest $overtimeRequest): RedirectResponse + { + $this->authorize('update', $overtimeRequest); + $overtimeRequest->cancel(); + return back()->with('success', 'Overtime request cancelled.'); + } + + public function destroy(OvertimeRequest $overtimeRequest): RedirectResponse + { + $this->authorize('delete', $overtimeRequest); + $overtimeRequest->delete(); + return back()->with('success', 'Overtime request deleted.'); + } +} diff --git a/erp/app/Modules/HR/Http/Controllers/PayrollController.php b/erp/app/Modules/HR/Http/Controllers/PayrollController.php new file mode 100644 index 00000000000..c5e8930e39b --- /dev/null +++ b/erp/app/Modules/HR/Http/Controllers/PayrollController.php @@ -0,0 +1,219 @@ +authorize('viewAny', PayrollRun::class); + + $query = PayrollRun::query()->latest('period_start'); + + if ($request->filled('status')) { + $query->where('status', $request->input('status')); + } + + $runs = $query->paginate(15); + + return Inertia::render('HR/Payroll/Index', [ + 'runs' => $runs, + 'filters' => $request->only(['status']), + ]); + } + + public function create(): Response + { + return Inertia::render('HR/Payroll/Create'); + } + + public function store(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'period_start' => ['required', 'date'], + 'period_end' => ['required', 'date', 'after_or_equal:period_start'], + 'run_date' => ['required', 'date'], + 'notes' => ['nullable', 'string'], + ]); + + $run = PayrollRun::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'period_start' => $validated['period_start'], + 'period_end' => $validated['period_end'], + 'run_date' => $validated['run_date'], + 'notes' => $validated['notes'] ?? null, + 'status' => 'draft', + ]); + + return redirect()->route('hr.payroll.show', $run); + } + + public function show(PayrollRun $payrollRun): Response + { + $payrollRun->load(['payslips.employee']); + + return Inertia::render('HR/Payroll/Show', [ + 'payrollRun' => $payrollRun, + ]); + } + + public function destroy(PayrollRun $payrollRun): RedirectResponse + { + $this->authorize('delete', $payrollRun); + + $payrollRun->delete(); + + return redirect()->route('hr.payroll.index'); + } + + public function generate(Request $request, PayrollRun $payrollRun): RedirectResponse + { + $this->authorize('update', $payrollRun); + + $count = $payrollRun->generatePayslips(); + $payrollRun->recalculateTotals(); + + return back()->with('success', "Generated {$count} payslips."); + } + + public function approve(PayrollRun $payrollRun): RedirectResponse + { + $this->authorize('update', $payrollRun); + + $payrollRun->approve(auth()->user()); + + return back()->with('success', 'Payroll run approved.'); + } + + public function markPaid(PayrollRun $payrollRun): RedirectResponse + { + $this->authorize('update', $payrollRun); + + $payrollRun->markPaid(); + + return back()->with('success', 'Payroll run marked as paid.'); + } + + // ─── Legacy methods (backward-compat with existing PayrollTest.php) ─── + + public function legacyIndex(): Response + { + $this->authorize('viewAny', Employee::class); + + $runs = PayrollRun::with('items') + ->latest('period_start') + ->paginate(25); + + return Inertia::render('HR/Payroll/Index', [ + 'runs' => PayrollRunResource::collection($runs), + 'filters' => [], + 'breadcrumbs' => [ + ['label' => 'HR'], + ['label' => 'Payroll', 'href' => route('hr.payroll.index')], + ], + ]); + } + + public function legacyCreate(): Response + { + $this->authorize('create', Employee::class); + + $employees = Employee::active()->with('department')->orderBy('last_name')->get(); + + return Inertia::render('HR/Payroll/Create', [ + 'employees' => $employees->map(fn ($e) => [ + 'id' => $e->id, + 'full_name' => $e->full_name, + 'position' => $e->position, + 'department' => $e->department?->name, + 'salary_type' => $e->salary_type, + 'salary_amount' => $e->salary_amount, + ]), + 'breadcrumbs' => [ + ['label' => 'HR'], + ['label' => 'Payroll', 'href' => route('hr.payroll.index')], + ['label' => 'New Run'], + ], + ]); + } + + public function legacyStore(StorePayrollRunRequest $request): RedirectResponse + { + $this->authorize('create', Employee::class); + + $data = $request->validated(); + + $run = DB::transaction(function () use ($data, $request) { + $run = PayrollRun::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'period_start' => $data['period_start'], + 'period_end' => $data['period_end'], + 'notes' => $data['notes'] ?? null, + 'created_by' => auth()->id(), + ]); + + $items = $request->input('items', []); + + foreach ($items as $item) { + $gross = (float) ($item['gross_salary'] ?? 0); + $deductions = (float) ($item['deductions'] ?? 0); + + PayrollItem::create([ + 'payroll_run_id' => $run->id, + 'employee_id' => $item['employee_id'], + 'gross_salary' => $gross, + 'deductions' => $deductions, + 'net_salary' => max(0, $gross - $deductions), + 'notes' => $item['notes'] ?? null, + ]); + } + + return $run; + }); + + return redirect()->route('hr.payroll.show', $run) + ->with('success', 'Payroll run created.'); + } + + public function legacyShow(PayrollRun $payrollRun): Response + { + $this->authorize('viewAny', Employee::class); + + $payrollRun->load(['items.employee.department', 'creator']); + + return Inertia::render('HR/Payroll/Show', [ + 'run' => new PayrollRunResource($payrollRun), + 'breadcrumbs' => [ + ['label' => 'HR'], + ['label' => 'Payroll', 'href' => route('hr.payroll.index')], + ['label' => "Run #{$payrollRun->id}"], + ], + ]); + } + + public function process(PayrollRun $payrollRun): RedirectResponse + { + $this->authorize('update', Employee::class); + + try { + $payrollRun->process(); + } catch (\DomainException $e) { + return back()->withErrors(['status' => $e->getMessage()]); + } + + return back()->with('success', 'Payroll run processed.'); + } +} diff --git a/erp/app/Modules/HR/Http/Controllers/PayrollRunController.php b/erp/app/Modules/HR/Http/Controllers/PayrollRunController.php new file mode 100644 index 00000000000..38ed566b465 --- /dev/null +++ b/erp/app/Modules/HR/Http/Controllers/PayrollRunController.php @@ -0,0 +1,99 @@ +authorize('viewAny', PayrollRun::class); + + $runs = PayrollRun::latest('period_start') + ->paginate(25); + + return Inertia::render('HR/PayrollRuns/Index', [ + 'payrollRuns' => PayrollRunResource::collection($runs), + 'breadcrumbs' => [ + ['label' => 'HR'], + ['label' => 'Payroll Runs', 'href' => route('hr.payroll-runs.index')], + ], + ]); + } + + public function create(): Response + { + $this->authorize('create', PayrollRun::class); + + return Inertia::render('HR/PayrollRuns/Create', [ + 'breadcrumbs' => [ + ['label' => 'HR'], + ['label' => 'Payroll Runs', 'href' => route('hr.payroll-runs.index')], + ['label' => 'New Payroll Run'], + ], + ]); + } + + public function store(StorePayrollRunRequest $request): RedirectResponse + { + $this->authorize('create', PayrollRun::class); + + $data = $request->validated(); + + $run = PayrollRun::create([ + ...$data, + 'tenant_id' => auth()->user()->tenant_id, + 'created_by' => auth()->id(), + ]); + + return redirect()->route('hr.payroll-runs.show', $run) + ->with('success', 'Payroll run created.'); + } + + public function show(PayrollRun $payrollRun): Response + { + $this->authorize('view', $payrollRun); + + $payrollRun->load(['creator']); + + return Inertia::render('HR/PayrollRuns/Show', [ + 'payrollRun' => new PayrollRunResource($payrollRun), + 'breadcrumbs' => [ + ['label' => 'HR'], + ['label' => 'Payroll Runs', 'href' => route('hr.payroll-runs.index')], + ['label' => $payrollRun->period_label ?? "Payroll Run #{$payrollRun->id}"], + ], + ]); + } + + public function destroy(PayrollRun $payrollRun): RedirectResponse + { + $this->authorize('delete', $payrollRun); + + $payrollRun->delete(); + + return redirect()->route('hr.payroll-runs.index') + ->with('success', 'Payroll run deleted.'); + } + + public function process(PayrollRun $payrollRun): RedirectResponse + { + $this->authorize('update', $payrollRun); + + try { + $payrollRun->process(); + } catch (\DomainException $e) { + return back()->withErrors(['status' => $e->getMessage()]); + } + + return redirect()->route('hr.payroll-runs.show', $payrollRun) + ->with('success', 'Payroll run processed.'); + } +} diff --git a/erp/app/Modules/HR/Http/Controllers/PayslipController.php b/erp/app/Modules/HR/Http/Controllers/PayslipController.php new file mode 100644 index 00000000000..b090645d422 --- /dev/null +++ b/erp/app/Modules/HR/Http/Controllers/PayslipController.php @@ -0,0 +1,82 @@ +payslips() + ->with(['employee.department', 'lines']) + ->get(); + + return Inertia::render('HR/Payslips/Index', [ + 'payrollRun' => $payrollRun, + 'payslips' => $payslips, + ]); + } + + public function show(Payslip $payslip): \Inertia\Response + { + $payslip->load(['employee.department', 'payrollRun', 'lines']); + + return Inertia::render('HR/Payslips/Show', [ + 'payslip' => $payslip, + ]); + } + + public function pdf(Payslip $payslip): Response + { + $payslip->load(['employee.department', 'payrollRun', 'lines']); + + $company = $this->resolveCompanyName(); + + $pdf = Pdf::loadView('pdf.payslip', [ + 'payslip' => $payslip, + 'company' => $company, + ]); + + $employee = $payslip->employee; + $name = $employee ? strtolower($employee->last_name . '-' . $employee->first_name) : 'employee'; + $filename = "payslip-{$name}-{$payslip->id}.pdf"; + + return response($pdf->output(), 200, [ + 'Content-Type' => 'application/pdf', + 'Content-Disposition' => "inline; filename=\"{$filename}\"", + ]); + } + + public function downloadAll(PayrollRun $payrollRun): Response + { + $payrollRun->load(['payslips.employee', 'payslips.lines']); + $company = $this->resolveCompanyName(); + + $pdf = Pdf::loadView('pdf.payroll-run-payslips', [ + 'payrollRun' => $payrollRun, + 'company' => $company, + ]); + + $filename = "payslips-{$payrollRun->id}.pdf"; + + return response($pdf->output(), 200, [ + 'Content-Type' => 'application/pdf', + 'Content-Disposition' => "attachment; filename=\"{$filename}\"", + ]); + } + + private function resolveCompanyName(): string + { + try { + return app('tenant')->name; + } catch (\Throwable) { + return config('app.name', 'ERP'); + } + } +} diff --git a/erp/app/Modules/HR/Http/Controllers/PerformanceReviewController.php b/erp/app/Modules/HR/Http/Controllers/PerformanceReviewController.php new file mode 100644 index 00000000000..42bde6c66d5 --- /dev/null +++ b/erp/app/Modules/HR/Http/Controllers/PerformanceReviewController.php @@ -0,0 +1,198 @@ +authorize('viewAny', PerformanceReview::class); + + $query = PerformanceReview::with(['employee', 'reviewer']) + ->orderByDesc('review_date'); + + if ($request->filled('employee_id')) { + $query->where('employee_id', $request->employee_id); + } + + if ($request->filled('status')) { + $query->where('status', $request->status); + } + + $reviews = $query->paginate(15); + $filters = $request->only(['employee_id', 'status']); + + return Inertia::render('HR/PerformanceReviews/Index', compact('reviews', 'filters')); + } + + public function create(): Response + { + $this->authorize('create', PerformanceReview::class); + + $employees = Employee::where('status', 'active') + ->orderBy('first_name') + ->get(['id', 'first_name', 'last_name']); + + return Inertia::render('HR/PerformanceReviews/Create', compact('employees')); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', PerformanceReview::class); + + // Normalise: if the caller used the legacy 'review_period' field, treat as 'period' + if ($request->missing('period') && $request->filled('review_period')) { + $request->merge(['period' => $request->input('review_period')]); + } + + $data = $request->validate([ + 'employee_id' => 'required|exists:employees,id', + 'period' => 'required|string|max:100', + 'review_period' => 'nullable|string|max:50', + 'review_date' => 'required|date', + 'overall_rating' => 'nullable|numeric|min:1|max:5', + 'strengths' => 'nullable|string', + 'improvements' => 'nullable|string', + 'goals' => 'nullable|string', + 'reviewer_notes' => 'nullable|string', + 'ratings' => 'nullable|array', + 'ratings.*.competency' => 'required_with:ratings|string', + 'ratings.*.rating' => 'required_with:ratings|integer|min:1|max:5', + 'ratings.*.notes' => 'nullable|string', + ]); + + $review = PerformanceReview::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'employee_id' => $data['employee_id'], + 'reviewer_id' => auth()->id(), + 'period' => $data['period'], + 'review_period' => $data['period'], + 'review_date' => $data['review_date'], + 'status' => 'draft', + 'overall_rating' => $data['overall_rating'] ?? null, + 'strengths' => $data['strengths'] ?? null, + 'improvements' => $data['improvements'] ?? null, + 'goals' => $data['goals'] ?? null, + 'reviewer_notes' => $data['reviewer_notes'] ?? null, + ]); + + if (!empty($data['ratings'])) { + foreach ($data['ratings'] as $ratingData) { + ReviewRating::create([ + 'tenant_id' => $review->tenant_id, + 'performance_review_id' => $review->id, + 'competency' => $ratingData['competency'], + 'rating' => $ratingData['rating'], + 'notes' => $ratingData['notes'] ?? null, + ]); + } + } + + return redirect()->route('hr.performance-reviews.show', $review); + } + + public function show(PerformanceReview $performanceReview): Response + { + $this->authorize('view', $performanceReview); + + $performanceReview->load(['kpis', 'employee', 'reviewer', 'ratings']); + + $reviewData = $performanceReview->toArray(); + $reviewData['average_kpi_score'] = $performanceReview->average_kpi_score; + $reviewData['is_complete'] = $performanceReview->is_complete; + $reviewData['average_rating'] = $performanceReview->average_rating; + + return Inertia::render('HR/PerformanceReviews/Show', [ + 'review' => $reviewData, + ]); + } + + public function destroy(PerformanceReview $performanceReview): RedirectResponse + { + $this->authorize('delete', $performanceReview); + + $performanceReview->delete(); + + return redirect()->route('hr.performance-reviews.index'); + } + + public function submit(PerformanceReview $performanceReview): RedirectResponse + { + $this->authorize('update', $performanceReview); + + $performanceReview->submit(); + + return back(); + } + + public function acknowledge(Request $request, PerformanceReview $performanceReview): RedirectResponse + { + $this->authorize('update', $performanceReview); + + $data = $request->validate([ + 'comments' => 'nullable|string', + ]); + + $performanceReview->acknowledge($data['comments'] ?? ''); + + return back(); + } + + public function addKpi(Request $request, PerformanceReview $performanceReview): RedirectResponse + { + $this->authorize('update', $performanceReview); + + $data = $request->validate([ + 'name' => 'required|string', + 'target_score' => 'required|numeric|min:0.01', + 'actual_score' => 'required|numeric|min:0', + 'weight' => 'nullable|numeric|min:0', + 'notes' => 'nullable|string', + ]); + + $performanceReview->kpis()->create([ + 'tenant_id' => $performanceReview->tenant_id, + 'performance_review_id' => $performanceReview->id, + 'name' => $data['name'], + 'target_score' => $data['target_score'], + 'actual_score' => $data['actual_score'], + 'weight' => $data['weight'] ?? 1, + 'notes' => $data['notes'] ?? null, + ]); + + return back()->with('success', 'KPI added.'); + } + + public function removeKpi(PerformanceReview $performanceReview, PerformanceKpi $kpi): RedirectResponse + { + $this->authorize('update', $performanceReview); + + $kpi->delete(); + + return back(); + } + + public function updateKpi(Request $request, PerformanceReview $performanceReview, PerformanceKpi $kpi): RedirectResponse + { + $this->authorize('update', $performanceReview); + + $data = $request->validate([ + 'actual_score' => 'required|numeric|min:0', + 'notes' => 'nullable|string', + ]); + + $kpi->update($data); + + return back(); + } +} diff --git a/erp/app/Modules/HR/Http/Controllers/SalaryGradeController.php b/erp/app/Modules/HR/Http/Controllers/SalaryGradeController.php new file mode 100644 index 00000000000..383568eb3ac --- /dev/null +++ b/erp/app/Modules/HR/Http/Controllers/SalaryGradeController.php @@ -0,0 +1,87 @@ +authorize('viewAny', SalaryGrade::class); + + $grades = SalaryGrade::orderBy('name') + ->paginate(20); + + return Inertia::render('HR/SalaryGrades/Index', [ + 'grades' => $grades, + ]); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', SalaryGrade::class); + + $validated = $request->validate([ + 'name' => 'required|string|max:100', + 'code' => 'nullable|string|max:20', + 'min_salary' => 'required|numeric|min:0', + 'mid_salary' => 'nullable|numeric|min:0', + 'max_salary' => 'required|numeric|min:0|gte:min_salary', + 'currency' => 'nullable|string|max:3', + 'description' => 'nullable|string', + 'is_active' => 'boolean', + ]); + + SalaryGrade::create([ + ...$validated, + 'tenant_id' => auth()->user()->tenant_id, + ]); + + return back()->with('success', 'Salary grade created.'); + } + + public function show(SalaryGrade $salaryGrade): Response + { + $this->authorize('view', $salaryGrade); + + return Inertia::render('HR/SalaryGrades/Show', [ + 'grade' => $salaryGrade, + ]); + } + + public function update(Request $request, SalaryGrade $salaryGrade): RedirectResponse + { + $this->authorize('update', $salaryGrade); + + $validated = $request->validate([ + 'name' => 'required|string|max:100', + 'code' => 'nullable|string|max:20', + 'min_salary' => 'required|numeric|min:0', + 'mid_salary' => 'nullable|numeric|min:0', + 'max_salary' => 'required|numeric|min:0|gte:min_salary', + 'currency' => 'nullable|string|max:3', + 'description' => 'nullable|string', + 'is_active' => 'boolean', + ]); + + $salaryGrade->update($validated); + + return back()->with('success', 'Salary grade updated.'); + } + + public function destroy(SalaryGrade $salaryGrade): RedirectResponse + { + $this->authorize('delete', $salaryGrade); + + $salaryGrade->delete(); + + return redirect()->route('hr.salary-grades.index') + ->with('success', 'Salary grade deleted.'); + } +} diff --git a/erp/app/Modules/HR/Http/Controllers/SalaryStructureController.php b/erp/app/Modules/HR/Http/Controllers/SalaryStructureController.php new file mode 100644 index 00000000000..b7a2bc7f5b8 --- /dev/null +++ b/erp/app/Modules/HR/Http/Controllers/SalaryStructureController.php @@ -0,0 +1,70 @@ + SalaryStructure::withCount('rules')->orderBy('name')->paginate(20), + ]); + } + + public function show(SalaryStructure $salaryStructure): Response + { + $salaryStructure->load('rules'); + return Inertia::render('HR/SalaryStructures/Show', [ + 'structure' => $salaryStructure, + 'rules' => $salaryStructure->rules, + ]); + } + + public function store(Request $request): RedirectResponse + { + $data = $request->validate([ + 'name' => 'required|string|max:100', + 'code' => 'required|string|max:50|unique:salary_structures,code', + 'description' => 'nullable|string', + ]); + $structure = SalaryStructure::create([...$data, 'tenant_id' => auth()->user()->tenant_id]); + return redirect()->route('hr.salary-structures.show', $structure)->with('success', 'Salary structure created.'); + } + + public function update(Request $request, SalaryStructure $salaryStructure): RedirectResponse + { + $salaryStructure->update($request->validate(['name' => 'required|string|max:100', 'description' => 'nullable|string', 'is_active' => 'boolean'])); + return redirect()->back()->with('success', 'Structure updated.'); + } + + public function destroy(SalaryStructure $salaryStructure): RedirectResponse + { + $salaryStructure->delete(); + return redirect()->route('hr.salary-structures.index')->with('success', 'Structure deleted.'); + } + + public function storeRule(Request $request, SalaryStructure $salaryStructure): RedirectResponse + { + $data = $request->validate([ + 'name' => 'required|string|max:100', 'code' => 'required|string|max:50', + 'category' => 'required|in:earnings,deductions,net', 'sequence' => 'required|integer|min:1', + 'amount_type' => 'required|in:fixed,percentage_of_basic,percentage_of_gross,percentage_of_rule', + 'amount' => 'nullable|numeric|min:0', 'percentage' => 'nullable|numeric|min:0|max:100', + 'base_rule_code' => 'nullable|string|max:50', 'description' => 'nullable|string', + ]); + SalaryRule::create([...$data, 'tenant_id' => auth()->user()->tenant_id, 'structure_id' => $salaryStructure->id]); + return redirect()->back()->with('success', 'Rule added.'); + } + + public function destroyRule(SalaryStructure $salaryStructure, SalaryRule $rule): RedirectResponse + { + $rule->delete(); + return redirect()->back()->with('success', 'Rule removed.'); + } +} diff --git a/erp/app/Modules/HR/Http/Controllers/ShiftAssignmentController.php b/erp/app/Modules/HR/Http/Controllers/ShiftAssignmentController.php new file mode 100644 index 00000000000..19ceaa7fae2 --- /dev/null +++ b/erp/app/Modules/HR/Http/Controllers/ShiftAssignmentController.php @@ -0,0 +1,92 @@ +authorize('viewAny', ShiftAssignment::class); + + $query = ShiftAssignment::with(['shiftTemplate', 'employee']) + ->orderBy('assigned_date', 'desc'); + + if ($request->filled('employee_id')) { + $query->where('employee_id', $request->employee_id); + } + + if ($request->filled('date_from')) { + $query->where('assigned_date', '>=', $request->date_from); + } + + if ($request->filled('date_to')) { + $query->where('assigned_date', '<=', $request->date_to); + } + + $assignments = $query->paginate(20)->withQueryString(); + + $filters = $request->only(['employee_id', 'date_from', 'date_to']); + + return Inertia::render('HR/ShiftAssignments/Index', compact('assignments', 'filters')); + } + + public function create(): Response + { + $this->authorize('create', ShiftAssignment::class); + + $shiftTemplates = ShiftTemplate::where('is_active', true)->orderBy('name')->get(); + $employees = Employee::orderBy('first_name')->get(['id', 'first_name', 'last_name']); + + return Inertia::render('HR/ShiftAssignments/Create', compact('shiftTemplates', 'employees')); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', ShiftAssignment::class); + + $data = $request->validate([ + 'shift_template_id' => ['required', 'exists:shift_templates,id'], + 'employee_id' => ['required', 'exists:employees,id'], + 'assigned_date' => ['required', 'date'], + 'notes' => ['nullable', 'string'], + ]); + + $data['tenant_id'] = auth()->user()->tenant_id; + $data['status'] = 'scheduled'; + + ShiftAssignment::create($data); + + return redirect()->route('hr.shift-assignments.index')->with('success', 'Shift assignment created.'); + } + + public function destroy(ShiftAssignment $shiftAssignment): RedirectResponse + { + $this->authorize('delete', $shiftAssignment); + + $shiftAssignment->delete(); + + return redirect()->back()->with('success', 'Shift assignment deleted.'); + } + + public function markStatus(Request $request, ShiftAssignment $shiftAssignment): RedirectResponse + { + $this->authorize('update', $shiftAssignment); + + $data = $request->validate([ + 'status' => ['required', 'in:scheduled,completed,absent,swapped'], + ]); + + $shiftAssignment->update($data); + + return redirect()->back()->with('success', 'Status updated.'); + } +} diff --git a/erp/app/Modules/HR/Http/Controllers/ShiftTemplateController.php b/erp/app/Modules/HR/Http/Controllers/ShiftTemplateController.php new file mode 100644 index 00000000000..97811f1060f --- /dev/null +++ b/erp/app/Modules/HR/Http/Controllers/ShiftTemplateController.php @@ -0,0 +1,78 @@ +authorize('viewAny', ShiftTemplate::class); + + $shiftTemplates = ShiftTemplate::withCount('assignments') + ->orderBy('name') + ->paginate(15); + + return Inertia::render('HR/ShiftTemplates/Index', compact('shiftTemplates')); + } + + public function create(): Response + { + $this->authorize('create', ShiftTemplate::class); + + return Inertia::render('HR/ShiftTemplates/Create'); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', ShiftTemplate::class); + + $data = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'start_time' => ['required', 'string'], + 'end_time' => ['required', 'string'], + 'break_minutes'=> ['integer', 'min:0'], + 'days_of_week' => ['array'], + 'color' => ['nullable', 'string', 'max:20'], + 'is_active' => ['boolean'], + ]); + + $data['break_minutes'] = $data['break_minutes'] ?? 0; + $data['tenant_id'] = auth()->user()->tenant_id; + + $template = ShiftTemplate::create($data); + + return redirect()->route('hr.shift-templates.show', $template); + } + + public function show(ShiftTemplate $shiftTemplate): Response + { + $this->authorize('view', $shiftTemplate); + + $shiftTemplate->load([ + 'assignments' => function ($query) { + $query->with('employee') + ->where('assigned_date', '>=', now()->toDateString()) + ->orderBy('assigned_date') + ->limit(20); + }, + ]); + + return Inertia::render('HR/ShiftTemplates/Show', compact('shiftTemplate')); + } + + public function destroy(ShiftTemplate $shiftTemplate): RedirectResponse + { + $this->authorize('delete', $shiftTemplate); + + $shiftTemplate->delete(); + + return redirect()->route('hr.shift-templates.index')->with('success', 'Shift template deleted.'); + } +} diff --git a/erp/app/Modules/HR/Http/Controllers/SkillDefinitionController.php b/erp/app/Modules/HR/Http/Controllers/SkillDefinitionController.php new file mode 100644 index 00000000000..1f64ef99d9b --- /dev/null +++ b/erp/app/Modules/HR/Http/Controllers/SkillDefinitionController.php @@ -0,0 +1,52 @@ +authorize('viewAny', SkillDefinition::class); + + $definitions = SkillDefinition::where('is_active', true) + ->where('tenant_id', auth()->user()->tenant_id) + ->latest() + ->get(); + + return Inertia::render('HR/SkillDefinitions/Index', compact('definitions')); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', SkillDefinition::class); + + $data = $request->validate([ + 'name' => 'required|string|max:255', + 'category' => 'nullable|string|max:100', + 'description' => 'nullable|string', + ]); + + SkillDefinition::create([ + 'tenant_id' => auth()->user()->tenant_id, + ...$data, + ]); + + return redirect()->back()->with('success', 'Skill definition created.'); + } + + public function destroy(SkillDefinition $skillDefinition): RedirectResponse + { + $this->authorize('delete', $skillDefinition); + + $skillDefinition->delete(); + + return redirect()->back()->with('success', 'Skill definition deleted.'); + } +} diff --git a/erp/app/Modules/HR/Http/Controllers/SuccessionPlanController.php b/erp/app/Modules/HR/Http/Controllers/SuccessionPlanController.php new file mode 100644 index 00000000000..a3c0d0a6b1d --- /dev/null +++ b/erp/app/Modules/HR/Http/Controllers/SuccessionPlanController.php @@ -0,0 +1,128 @@ +authorize('viewAny', SuccessionPlan::class); + + $query = SuccessionPlan::with('currentHolder') + ->orderByDesc('created_at'); + + if ($request->filled('status')) { + $query->where('status', $request->status); + } + + if ($request->filled('department')) { + $query->where('department', $request->department); + } + + $plans = $query->paginate(15); + $filters = $request->only(['status', 'department']); + + return Inertia::render('HR/SuccessionPlans/Index', compact('plans', 'filters')); + } + + public function create(): Response + { + $this->authorize('create', SuccessionPlan::class); + + $employees = Employee::where('status', 'active') + ->orderBy('first_name') + ->get(['id', 'first_name', 'last_name']); + + return Inertia::render('HR/SuccessionPlans/Create', compact('employees')); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', SuccessionPlan::class); + + $data = $request->validate([ + 'position_title' => ['required', 'string'], + 'department' => ['nullable', 'string'], + 'is_critical' => ['nullable', 'boolean'], + ]); + + $data['tenant_id'] = app('tenant')->id; + $data['created_by'] = auth()->id(); + + SuccessionPlan::create($data); + + return redirect()->route('hr.succession-plans.index'); + } + + public function show(SuccessionPlan $successionPlan): Response + { + $this->authorize('view', $successionPlan); + + $successionPlan->load(['currentHolder', 'candidates.employee']); + + return Inertia::render('HR/SuccessionPlans/Show', compact('successionPlan')); + } + + public function edit(SuccessionPlan $successionPlan): Response + { + $this->authorize('update', $successionPlan); + + $employees = Employee::where('status', 'active') + ->orderBy('first_name') + ->get(['id', 'first_name', 'last_name']); + + $successionPlan->load('currentHolder'); + + return Inertia::render('HR/SuccessionPlans/Edit', compact('successionPlan', 'employees')); + } + + public function update(Request $request, SuccessionPlan $successionPlan): RedirectResponse + { + $this->authorize('update', $successionPlan); + + $data = $request->validate([ + 'position_title' => ['required', 'string'], + 'department' => ['nullable', 'string'], + 'is_critical' => ['nullable', 'boolean'], + ]); + + $successionPlan->update($data); + + return redirect()->route('hr.succession-plans.index'); + } + + public function destroy(SuccessionPlan $successionPlan): RedirectResponse + { + $this->authorize('delete', $successionPlan); + + $successionPlan->delete(); + + return redirect()->route('hr.succession-plans.index'); + } + + public function complete(SuccessionPlan $successionPlan): RedirectResponse + { + $this->authorize('complete', $successionPlan); + + $successionPlan->complete(); + + return redirect()->route('hr.succession-plans.index'); + } + + public function deactivate(SuccessionPlan $successionPlan): RedirectResponse + { + $this->authorize('deactivate', $successionPlan); + + $successionPlan->deactivate(); + + return redirect()->route('hr.succession-plans.index'); + } +} diff --git a/erp/app/Modules/HR/Http/Controllers/TimesheetController.php b/erp/app/Modules/HR/Http/Controllers/TimesheetController.php new file mode 100644 index 00000000000..a9fbb9f4083 --- /dev/null +++ b/erp/app/Modules/HR/Http/Controllers/TimesheetController.php @@ -0,0 +1,148 @@ +authorize('viewAny', Timesheet::class); + + $timesheets = Timesheet::with(['employee']) + ->when($request->employee_id, fn ($q) => $q->where('employee_id', $request->employee_id)) + ->when($request->status, fn ($q) => $q->where('status', $request->status)) + ->orderBy('week_start', 'desc') + ->paginate(20) + ->withQueryString(); + + $employees = Employee::where('status', 'active') + ->orderBy('last_name') + ->get(['id', 'first_name', 'last_name']); + + return Inertia::render('HR/Timesheets/Index', [ + 'timesheets' => $timesheets, + 'employees' => $employees, + 'filters' => $request->only(['employee_id', 'status']), + ]); + } + + public function create(): Response + { + $this->authorize('create', Timesheet::class); + + $employees = Employee::where('status', 'active') + ->orderBy('last_name') + ->get(['id', 'first_name', 'last_name']); + + return Inertia::render('HR/Timesheets/Create', [ + 'employees' => $employees, + ]); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', Timesheet::class); + + $validated = $request->validate([ + 'week_start' => 'required|date', + 'employee_id' => 'required|exists:employees,id', + 'notes' => 'nullable|string', + ]); + + $weekEnd = Carbon::parse($validated['week_start'])->endOfWeek(Carbon::SUNDAY)->toDateString(); + + Timesheet::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'employee_id' => $validated['employee_id'], + 'week_start' => $validated['week_start'], + 'week_end' => $weekEnd, + 'notes' => $validated['notes'] ?? null, + 'status' => 'draft', + 'total_hours' => 0, + ]); + + return redirect()->back(); + } + + public function show(Timesheet $timesheet): Response + { + $this->authorize('view', $timesheet); + + $timesheet->load(['employee', 'entries', 'approvedBy']); + + return Inertia::render('HR/Timesheets/Show', [ + 'timesheet' => $timesheet, + ]); + } + + public function submit(Timesheet $timesheet): RedirectResponse + { + $this->authorize('update', $timesheet); + + $timesheet->submit(); + + return redirect()->back(); + } + + public function approve(Timesheet $timesheet): RedirectResponse + { + $this->authorize('update', $timesheet); + + $timesheet->approve(auth()->id()); + + return redirect()->back(); + } + + public function reject(Timesheet $timesheet): RedirectResponse + { + $this->authorize('update', $timesheet); + + $timesheet->reject(); + + return redirect()->back(); + } + + public function addEntry(Request $request, Timesheet $timesheet): RedirectResponse + { + $this->authorize('update', $timesheet); + + $validated = $request->validate([ + 'work_date' => 'required|date', + 'hours' => 'required|numeric|min:0.25|max:24', + 'project' => 'nullable|string|max:255', + 'description' => 'nullable|string', + ]); + + $timesheet->entries()->create([ + 'tenant_id' => auth()->user()->tenant_id, + 'timesheet_id' => $timesheet->id, + 'work_date' => $validated['work_date'], + 'hours' => $validated['hours'], + 'project' => $validated['project'] ?? null, + 'description' => $validated['description'] ?? null, + ]); + + $timesheet->recalculateHours(); + + return redirect()->back(); + } + + public function destroy(Timesheet $timesheet): RedirectResponse + { + $this->authorize('delete', $timesheet); + + $timesheet->delete(); + + return redirect()->route('hr.timesheets.index'); + } + +} \ No newline at end of file diff --git a/erp/app/Modules/HR/Http/Controllers/TrainingCourseController.php b/erp/app/Modules/HR/Http/Controllers/TrainingCourseController.php new file mode 100644 index 00000000000..d77eec40044 --- /dev/null +++ b/erp/app/Modules/HR/Http/Controllers/TrainingCourseController.php @@ -0,0 +1,100 @@ +authorize('viewAny', TrainingCourse::class); + + $courses = TrainingCourse::withCount('enrollments') + ->orderBy('title') + ->paginate(20); + + return Inertia::render('HR/TrainingCourses/Index', compact('courses')); + } + + public function create(): Response + { + $this->authorize('create', TrainingCourse::class); + + return Inertia::render('HR/TrainingCourses/Create'); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', TrainingCourse::class); + + $data = $request->validate([ + 'title' => 'required|string|max:255', + 'category' => 'nullable|string|max:255', + 'provider' => 'nullable|string|max:255', + 'type' => 'nullable|in:internal,external,online,certification', + 'duration_hours' => 'nullable|integer|min:0', + 'cost' => 'nullable|numeric|min:0', + 'description' => 'nullable|string', + 'is_mandatory' => 'boolean', + 'is_active' => 'boolean', + ]); + + $course = TrainingCourse::create([ + 'tenant_id' => auth()->user()->tenant_id, + ...$data, + ]); + + return redirect()->route('hr.training-courses.show', $course) + ->with('success', 'Training course created.'); + } + + public function show(TrainingCourse $trainingCourse): Response + { + $this->authorize('view', $trainingCourse); + + $trainingCourse->load(['enrollments.employee']); + + return Inertia::render('HR/TrainingCourses/Show', [ + 'course' => $trainingCourse, + ]); + } + + public function destroy(TrainingCourse $trainingCourse): RedirectResponse + { + $this->authorize('delete', $trainingCourse); + + $trainingCourse->delete(); + + return redirect()->route('hr.training-courses.index') + ->with('success', 'Training course deleted.'); + } + + public function enroll(Request $request, TrainingCourse $trainingCourse): RedirectResponse + { + $this->authorize('create', TrainingCourse::class); + + $data = $request->validate([ + 'employee_id' => 'required|exists:employees,id', + 'scheduled_date' => 'nullable|date', + ]); + + TrainingEnrollment::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'training_course_id' => $trainingCourse->id, + 'employee_id' => $data['employee_id'], + 'enrolled_date' => now()->toDateString(), + 'scheduled_date' => $data['scheduled_date'] ?? null, + 'status' => 'enrolled', + 'enrolled_by' => auth()->id(), + ]); + + return redirect()->back()->with('success', 'Employee enrolled.'); + } +} diff --git a/erp/app/Modules/HR/Http/Controllers/TrainingEnrollmentController.php b/erp/app/Modules/HR/Http/Controllers/TrainingEnrollmentController.php new file mode 100644 index 00000000000..b117ee18210 --- /dev/null +++ b/erp/app/Modules/HR/Http/Controllers/TrainingEnrollmentController.php @@ -0,0 +1,73 @@ +authorize('viewAny', TrainingEnrollment::class); + + $query = TrainingEnrollment::with(['employee', 'course']); + + if ($request->filled('status')) { + $query->where('status', $request->status); + } + + if ($request->filled('employee_id')) { + $query->where('employee_id', $request->employee_id); + } + + $enrollments = $query->latest()->paginate(20); + + return Inertia::render('HR/TrainingEnrollments/Index', compact('enrollments')); + } + + public function show(TrainingEnrollment $trainingEnrollment): Response + { + $this->authorize('view', $trainingEnrollment); + + $trainingEnrollment->load(['employee', 'course']); + + return Inertia::render('HR/TrainingEnrollments/Show', [ + 'enrollment' => $trainingEnrollment, + ]); + } + + public function complete(Request $request, TrainingEnrollment $trainingEnrollment): RedirectResponse + { + $this->authorize('update', $trainingEnrollment); + + $data = $request->validate([ + 'score' => 'nullable|numeric|min:0|max:100', + 'notes' => 'nullable|string', + ]); + + $trainingEnrollment->complete( + isset($data['score']) ? (float) $data['score'] : null, + $data['notes'] ?? null + ); + + return redirect()->back()->with('success', 'Enrollment marked as completed.'); + } + + public function fail(Request $request, TrainingEnrollment $trainingEnrollment): RedirectResponse + { + $this->authorize('update', $trainingEnrollment); + + $data = $request->validate([ + 'notes' => 'nullable|string', + ]); + + $trainingEnrollment->fail($data['notes'] ?? null); + + return redirect()->back()->with('success', 'Enrollment marked as failed.'); + } +} diff --git a/erp/app/Modules/HR/Http/Controllers/TrainingSessionController.php b/erp/app/Modules/HR/Http/Controllers/TrainingSessionController.php new file mode 100644 index 00000000000..02019a52a23 --- /dev/null +++ b/erp/app/Modules/HR/Http/Controllers/TrainingSessionController.php @@ -0,0 +1,101 @@ +orderByDesc('scheduled_at') + ->paginate(20); + + return Inertia::render('HR/TrainingSessions/Index', compact('sessions')); + } + + public function create(): Response + { + return Inertia::render('HR/TrainingSessions/Create'); + } + + public function store(Request $request): RedirectResponse + { + $data = $request->validate([ + 'training_course_id' => 'required|exists:training_courses,id', + 'title' => 'required|string|max:255', + 'scheduled_at' => 'required|date', + 'ends_at' => 'nullable|date|after:scheduled_at', + 'description' => 'nullable|string', + 'location' => 'nullable|string|max:255', + 'delivery_mode' => 'nullable|string|in:in-person,online,hybrid', + 'max_participants' => 'nullable|integer|min:1', + 'facilitator_id' => 'nullable|exists:users,id', + ]); + + $data['tenant_id'] = app('tenant')->id; + $data['created_by'] = auth()->id(); + + TrainingSession::create($data); + + return redirect()->route('hr.training-sessions.index'); + } + + public function show(TrainingSession $trainingSession): Response + { + $trainingSession->load('course', 'facilitator'); + return Inertia::render('HR/TrainingSessions/Show', ['session' => $trainingSession]); + } + + public function edit(TrainingSession $trainingSession): Response + { + return Inertia::render('HR/TrainingSessions/Edit', ['session' => $trainingSession]); + } + + public function update(Request $request, TrainingSession $trainingSession): RedirectResponse + { + $data = $request->validate([ + 'title' => 'required|string|max:255', + 'scheduled_at' => 'required|date', + 'ends_at' => 'nullable|date|after:scheduled_at', + 'description' => 'nullable|string', + 'location' => 'nullable|string|max:255', + 'delivery_mode' => 'nullable|string|in:in-person,online,hybrid', + 'max_participants' => 'nullable|integer|min:1', + 'facilitator_id' => 'nullable|exists:users,id', + ]); + + $trainingSession->update($data); + + return redirect()->route('hr.training-sessions.index'); + } + + public function destroy(TrainingSession $trainingSession): RedirectResponse + { + $trainingSession->delete(); + return redirect()->route('hr.training-sessions.index'); + } + + public function start(TrainingSession $trainingSession): RedirectResponse + { + $trainingSession->start(); + return redirect()->route('hr.training-sessions.index'); + } + + public function complete(TrainingSession $trainingSession): RedirectResponse + { + $trainingSession->complete(); + return redirect()->route('hr.training-sessions.index'); + } + + public function cancel(TrainingSession $trainingSession): RedirectResponse + { + $trainingSession->cancel(); + return redirect()->route('hr.training-sessions.index'); + } +} diff --git a/erp/app/Modules/HR/Http/Controllers/WorkScheduleController.php b/erp/app/Modules/HR/Http/Controllers/WorkScheduleController.php new file mode 100644 index 00000000000..d24bd7d054c --- /dev/null +++ b/erp/app/Modules/HR/Http/Controllers/WorkScheduleController.php @@ -0,0 +1,97 @@ +authorize('viewAny', WorkSchedule::class); + + $schedules = WorkSchedule::query() + ->when($request->has('is_active') && $request->is_active !== null, fn ($q) => $q->where('is_active', $request->boolean('is_active'))) + ->orderBy('name') + ->paginate(20) + ->withQueryString(); + + return Inertia::render('HR/WorkSchedules/Index', [ + 'schedules' => $schedules, + 'filters' => $request->only(['is_active']), + ]); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', WorkSchedule::class); + + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'timezone' => 'nullable|string', + 'hours_per_week' => 'nullable|integer|min:1|max:168', + 'is_active' => 'nullable|boolean', + 'description' => 'nullable|string', + ]); + + WorkSchedule::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'name' => $validated['name'], + 'timezone' => $validated['timezone'] ?? 'UTC', + 'hours_per_week' => $validated['hours_per_week'] ?? 40, + 'is_active' => $validated['is_active'] ?? true, + 'description' => $validated['description'] ?? null, + ]); + + return redirect()->back(); + } + + public function show(WorkSchedule $workSchedule): Response + { + $this->authorize('view', $workSchedule); + + $workSchedule->load(['shifts', 'assignments.employee']); + + return Inertia::render('HR/WorkSchedules/Show', [ + 'workSchedule' => $workSchedule, + ]); + } + + public function addShift(Request $request, WorkSchedule $workSchedule): RedirectResponse + { + $this->authorize('update', $workSchedule); + + $validated = $request->validate([ + 'day_of_week' => 'required|in:monday,tuesday,wednesday,thursday,friday,saturday,sunday', + 'start_time' => 'required|date_format:H:i', + 'end_time' => 'required|date_format:H:i', + 'break_minutes' => 'nullable|numeric|min:0', + ]); + + WorkScheduleShift::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'work_schedule_id' => $workSchedule->id, + 'day_of_week' => $validated['day_of_week'], + 'start_time' => $validated['start_time'], + 'end_time' => $validated['end_time'], + 'break_minutes' => $validated['break_minutes'] ?? 0, + ]); + + return redirect()->back(); + } + + public function destroy(WorkSchedule $workSchedule): RedirectResponse + { + $this->authorize('delete', $workSchedule); + + $workSchedule->delete(); + + return redirect()->back(); + } +} diff --git a/erp/app/Modules/HR/Http/Requests/StoreDepartmentRequest.php b/erp/app/Modules/HR/Http/Requests/StoreDepartmentRequest.php new file mode 100644 index 00000000000..fcdb6076605 --- /dev/null +++ b/erp/app/Modules/HR/Http/Requests/StoreDepartmentRequest.php @@ -0,0 +1,18 @@ + ['required', 'string', 'max:191'], + 'description' => ['nullable', 'string'], + ]; + } +} diff --git a/erp/app/Modules/HR/Http/Requests/StoreEmployeeRequest.php b/erp/app/Modules/HR/Http/Requests/StoreEmployeeRequest.php new file mode 100644 index 00000000000..2890a8b463b --- /dev/null +++ b/erp/app/Modules/HR/Http/Requests/StoreEmployeeRequest.php @@ -0,0 +1,78 @@ + ['required', 'string', 'max:100'], + 'last_name' => ['required', 'string', 'max:100'], + 'email' => ['nullable', 'email'], + 'phone' => ['nullable', 'string', 'max:50'], + 'employee_number' => ['nullable', 'string', 'max:30', + Rule::unique('employees')->where('tenant_id', auth()->user()?->tenant_id) + ->ignore($this->route('employee')), + ], + 'department_id' => ['nullable', 'integer', 'exists:departments,id'], + 'user_id' => ['nullable', 'integer', 'exists:users,id'], + // spec: job_title maps to position + 'job_title' => ['nullable', 'string', 'max:191'], + 'position' => ['nullable', 'string', 'max:150'], + 'employment_type' => ['required', Rule::in(['full_time', 'part_time', 'contract', 'intern'])], + 'status' => ['sometimes', Rule::in(['active', 'on_leave', 'terminated'])], + 'start_date' => ['nullable', 'date'], + 'hire_date' => ['nullable', 'date'], // alias for start_date + 'end_date' => ['nullable', 'date'], + 'termination_date' => ['nullable', 'date'], // alias for end_date + 'salary_type' => ['sometimes', Rule::in(['hourly', 'monthly'])], + 'salary_amount' => ['nullable', 'numeric', 'min:0'], + 'salary' => ['nullable', 'numeric', 'min:0'], // alias + ]; + } + + public function validated($key = null, $default = null): array + { + $data = parent::validated($key, $default); + + // Map spec field names to DB column names + if (isset($data['job_title']) && !isset($data['position'])) { + $data['position'] = $data['job_title']; + } + unset($data['job_title']); + + if (isset($data['hire_date']) && !isset($data['start_date'])) { + $data['start_date'] = $data['hire_date']; + } + unset($data['hire_date']); + + if (isset($data['termination_date']) && !isset($data['end_date'])) { + $data['end_date'] = $data['termination_date']; + } + unset($data['termination_date']); + + if (isset($data['salary']) && !isset($data['salary_amount'])) { + $data['salary_amount'] = $data['salary']; + } + unset($data['salary']); + + // Default values + if (!isset($data['status'])) { + $data['status'] = 'active'; + } + if (!isset($data['salary_type'])) { + $data['salary_type'] = 'monthly'; + } + if (!isset($data['start_date']) && !isset($data['hire_date'])) { + $data['start_date'] = now()->toDateString(); + } + + return $data; + } +} diff --git a/erp/app/Modules/HR/Http/Requests/StoreExpenseClaimRequest.php b/erp/app/Modules/HR/Http/Requests/StoreExpenseClaimRequest.php new file mode 100644 index 00000000000..e16372b4d9b --- /dev/null +++ b/erp/app/Modules/HR/Http/Requests/StoreExpenseClaimRequest.php @@ -0,0 +1,26 @@ + 'required|exists:employees,id', + 'title' => 'required|string|max:191', + 'description' => 'nullable|string', + 'expense_date' => 'required|date', + 'amount' => 'required|numeric|min:0.01', + 'currency_code' => 'nullable|string|size:3', + 'category' => 'required|in:travel,meals,supplies,accommodation,other', + ]; + } +} diff --git a/erp/app/Modules/HR/Http/Requests/StoreLeaveRequestRequest.php b/erp/app/Modules/HR/Http/Requests/StoreLeaveRequestRequest.php new file mode 100644 index 00000000000..5600587dbe6 --- /dev/null +++ b/erp/app/Modules/HR/Http/Requests/StoreLeaveRequestRequest.php @@ -0,0 +1,44 @@ + ['required', 'integer', 'exists:employees,id'], + 'leave_type_id' => ['nullable', 'integer', 'exists:leave_types,id'], + 'type' => ['nullable', 'string', 'in:annual,sick,unpaid,maternity,paternity,other'], + 'start_date' => ['required', 'date'], + 'end_date' => ['required', 'date', 'after_or_equal:start_date'], + 'reason' => ['nullable', 'string'], + 'notes' => ['nullable', 'string'], + ]; + } + + public function validated($key = null, $default = null): array + { + $data = parent::validated($key, $default); + + // Map reason -> notes + if (isset($data['reason']) && !isset($data['notes'])) { + $data['notes'] = $data['reason']; + } + unset($data['reason']); + unset($data['type']); // type is not a DB column, it's derived from leave_type + + // Compute days if not provided + if (!isset($data['days'])) { + $start = \Carbon\Carbon::parse($data['start_date']); + $end = \Carbon\Carbon::parse($data['end_date']); + $data['days'] = max(1, (int) $start->diffInDays($end) + 1); + } + + return $data; + } +} diff --git a/erp/app/Modules/HR/Http/Requests/StorePayrollRunRequest.php b/erp/app/Modules/HR/Http/Requests/StorePayrollRunRequest.php new file mode 100644 index 00000000000..6fd65ef52eb --- /dev/null +++ b/erp/app/Modules/HR/Http/Requests/StorePayrollRunRequest.php @@ -0,0 +1,20 @@ + ['nullable', 'string', 'max:100'], + 'period_start' => ['required', 'date'], + 'period_end' => ['required', 'date', 'after_or_equal:period_start'], + 'notes' => ['nullable', 'string'], + ]; + } +} diff --git a/erp/app/Modules/HR/Http/Resources/DepartmentResource.php b/erp/app/Modules/HR/Http/Resources/DepartmentResource.php new file mode 100644 index 00000000000..64196fd16ee --- /dev/null +++ b/erp/app/Modules/HR/Http/Resources/DepartmentResource.php @@ -0,0 +1,21 @@ + $this->id, + 'name' => $this->name, + 'description' => $this->description, + 'is_active' => $this->is_active, + 'employees_count' => $this->when(isset($this->employees_count), $this->employees_count), + 'created_at' => $this->created_at?->toDateString(), + ]; + } +} diff --git a/erp/app/Modules/HR/Http/Resources/EmployeeResource.php b/erp/app/Modules/HR/Http/Resources/EmployeeResource.php new file mode 100644 index 00000000000..ea3f386d164 --- /dev/null +++ b/erp/app/Modules/HR/Http/Resources/EmployeeResource.php @@ -0,0 +1,51 @@ + $this->id, + 'employee_number' => $this->employee_number, + 'code' => $this->code, + 'first_name' => $this->first_name, + 'last_name' => $this->last_name, + 'full_name' => $this->full_name, + 'email' => $this->email, + 'phone' => $this->phone, + 'position' => $this->position, + 'job_title' => $this->position, + 'employment_type' => $this->employment_type, + 'status' => $this->status, + 'start_date' => $this->start_date?->toDateString(), + 'hire_date' => $this->start_date?->toDateString(), + 'end_date' => $this->end_date?->toDateString(), + 'termination_date' => $this->end_date?->toDateString(), + 'salary_type' => $this->salary_type, + 'salary_amount' => $this->salary_amount, + 'salary' => $this->salary_amount, + 'department_id' => $this->department_id, + 'user_id' => $this->user_id, + 'department' => $this->whenLoaded('department', fn () => $this->department + ? ['id' => $this->department->id, 'name' => $this->department->name] + : null), + 'user' => $this->whenLoaded('user', fn () => $this->user + ? ['id' => $this->user->id, 'name' => $this->user->name] + : null), + 'leave_requests' => $this->whenLoaded('leaveRequests', fn () => $this->leaveRequests->map(fn ($lr) => [ + 'id' => $lr->id, + 'leave_type' => $lr->leaveType?->name, + 'start_date' => $lr->start_date?->toDateString(), + 'end_date' => $lr->end_date?->toDateString(), + 'days' => $lr->days, + 'status' => $lr->status, + ])), + 'created_at' => $this->created_at?->toDateString(), + ]; + } +} diff --git a/erp/app/Modules/HR/Http/Resources/ExpenseClaimResource.php b/erp/app/Modules/HR/Http/Resources/ExpenseClaimResource.php new file mode 100644 index 00000000000..2fca99e96af --- /dev/null +++ b/erp/app/Modules/HR/Http/Resources/ExpenseClaimResource.php @@ -0,0 +1,39 @@ + $this->id, + 'tenant_id' => $this->tenant_id, + 'employee_id' => $this->employee_id, + 'employee_name' => $this->whenLoaded('employee', fn () => $this->employee?->full_name), + 'employee' => $this->whenLoaded('employee', fn () => $this->employee + ? ['id' => $this->employee->id, 'full_name' => $this->employee->full_name] + : null), + 'submitted_by' => $this->submitted_by, + 'submitted_by_name' => $this->whenLoaded('submitter', fn () => $this->submitter?->name), + 'title' => $this->title, + 'description' => $this->description, + 'expense_date' => $this->expense_date?->toDateString(), + 'amount' => $this->amount, + 'currency_code' => $this->currency_code, + 'category' => $this->category, + 'receipt_path' => $this->receipt_path, + 'status' => $this->status, + 'reviewed_by' => $this->reviewed_by, + 'reviewed_by_name' => $this->whenLoaded('reviewer', fn () => $this->reviewer?->name), + 'reviewed_at' => $this->reviewed_at?->toDateTimeString(), + 'review_notes' => $this->review_notes, + 'created_by' => $this->created_by, + 'created_at' => $this->created_at?->toDateTimeString(), + 'updated_at' => $this->updated_at?->toDateTimeString(), + ]; + } +} diff --git a/erp/app/Modules/HR/Http/Resources/LeaveRequestResource.php b/erp/app/Modules/HR/Http/Resources/LeaveRequestResource.php new file mode 100644 index 00000000000..424b1714e4b --- /dev/null +++ b/erp/app/Modules/HR/Http/Resources/LeaveRequestResource.php @@ -0,0 +1,43 @@ + $this->id, + 'employee_id' => $this->employee_id, + 'employee' => $this->whenLoaded('employee', fn () => $this->employee + ? ['id' => $this->employee->id, 'full_name' => $this->employee->full_name] + : null), + 'leave_type_id' => $this->leave_type_id, + 'leave_type' => $this->whenLoaded('leaveType', fn () => $this->leaveType + ? ['id' => $this->leaveType->id, 'name' => $this->leaveType->name] + : null), + 'type' => $this->leaveType?->name ?? 'other', + 'start_date' => $this->start_date?->toDateString(), + 'end_date' => $this->end_date?->toDateString(), + 'days' => $this->days, + 'days_requested' => (float) ($this->attributes['days_requested'] ?? $this->days), + 'reason' => $this->reason ?? $this->notes, + 'notes' => $this->notes, + 'rejection_reason' => $this->rejection_reason, + 'status' => $this->status, + 'approved_by' => $this->approved_by ?? $this->reviewed_by, + 'approved_at' => $this->approved_at?->toDateTimeString() ?? $this->reviewed_at?->toDateTimeString(), + 'reviewed_by' => $this->reviewed_by, + 'reviewed_at' => $this->reviewed_at?->toDateTimeString(), + 'approver' => $this->whenLoaded('approver', fn () => $this->approver + ? ['id' => $this->approver->id, 'name' => $this->approver->name] + : null) ?? $this->whenLoaded('reviewer', fn () => $this->reviewer + ? ['id' => $this->reviewer->id, 'name' => $this->reviewer->name] + : null), + 'created_at' => $this->created_at?->toDateString(), + ]; + } +} diff --git a/erp/app/Modules/HR/Http/Resources/PayrollRunResource.php b/erp/app/Modules/HR/Http/Resources/PayrollRunResource.php new file mode 100644 index 00000000000..a5bd1b2d09c --- /dev/null +++ b/erp/app/Modules/HR/Http/Resources/PayrollRunResource.php @@ -0,0 +1,41 @@ + $this->id, + 'period_label' => $this->period_label ?? $this->period_start?->format('F Y'), + 'period_start' => $this->period_start?->toDateString(), + 'period_end' => $this->period_end?->toDateString(), + 'status' => $this->status, + 'notes' => $this->notes, + 'total_gross' => $this->total_gross, + 'total_deductions' => $this->attributes['total_deductions'] ?? 0, + 'total_net' => $this->total_net, + 'employee_count' => $this->employee_count ?? ($this->relationLoaded('items') ? $this->items->count() : 0), + 'processed_at' => $this->processed_at?->toDateTimeString(), + 'items_count' => $this->whenLoaded('items', fn () => $this->items->count()), + 'items' => $this->whenLoaded('items', fn () => $this->items->map(fn ($item) => [ + 'id' => $item->id, + 'employee' => $item->relationLoaded('employee') ? [ + 'id' => $item->employee->id, + 'full_name' => $item->employee->full_name, + 'position' => $item->employee->position, + ] : null, + 'gross_salary' => $item->gross_salary, + 'deductions' => $item->deductions, + 'net_salary' => $item->net_salary, + 'notes' => $item->notes, + ])), + 'creator' => $this->whenLoaded('creator', fn () => $this->creator?->name), + 'created_at' => $this->created_at?->toDateString(), + ]; + } +} diff --git a/erp/app/Modules/HR/Models/AttendanceRecord.php b/erp/app/Modules/HR/Models/AttendanceRecord.php new file mode 100644 index 00000000000..c6b5338600a --- /dev/null +++ b/erp/app/Modules/HR/Models/AttendanceRecord.php @@ -0,0 +1,54 @@ + 'date', + ]; + + public function employee(): BelongsTo + { + return $this->belongsTo(Employee::class); + } + + public function getWorkedHoursAttribute(): ?float + { + if (empty($this->clock_in) || empty($this->clock_out)) { + return null; + } + + $clockIn = Carbon::createFromFormat('H:i:s', $this->clock_in); + $clockOut = Carbon::createFromFormat('H:i:s', $this->clock_out); + + $minutes = $clockIn->diffInMinutes($clockOut) - ($this->break_minutes ?? 0); + + return round($minutes / 60, 2); + } + + public function getIsLateAttribute(): bool + { + return false; + } +} diff --git a/erp/app/Modules/HR/Models/BenefitPlan.php b/erp/app/Modules/HR/Models/BenefitPlan.php new file mode 100644 index 00000000000..3a11c1be3ea --- /dev/null +++ b/erp/app/Modules/HR/Models/BenefitPlan.php @@ -0,0 +1,40 @@ + 'float', + 'employer_cost' => 'float', + 'is_active' => 'boolean', + ]; + + public function enrollments(): HasMany + { + return $this->hasMany(EmployeeBenefit::class); + } + + public function getTotalCostAttribute(): float + { + return $this->employee_cost + $this->employer_cost; + } +} diff --git a/erp/app/Modules/HR/Models/Competency.php b/erp/app/Modules/HR/Models/Competency.php new file mode 100644 index 00000000000..0844fff7c0e --- /dev/null +++ b/erp/app/Modules/HR/Models/Competency.php @@ -0,0 +1,30 @@ + 'integer', + ]; + + protected $attributes = [ + 'max_level' => 5, + ]; + + public function framework(): BelongsTo + { + return $this->belongsTo(CompetencyFramework::class); + } +} diff --git a/erp/app/Modules/HR/Models/CompetencyFramework.php b/erp/app/Modules/HR/Models/CompetencyFramework.php new file mode 100644 index 00000000000..9ef82df727d --- /dev/null +++ b/erp/app/Modules/HR/Models/CompetencyFramework.php @@ -0,0 +1,59 @@ + 'boolean', + ]; + + protected $attributes = [ + 'status' => 'draft', + 'is_default' => false, + ]; + + public function competencies(): HasMany + { + return $this->hasMany(Competency::class); + } + + public function activate(): void + { + $this->status = 'active'; + $this->save(); + } + + public function archive(): void + { + $this->status = 'archived'; + $this->save(); + } + + public function getIsActiveAttribute(): bool + { + return $this->status === 'active'; + } + + public function getCompetencyCountAttribute(): int + { + return $this->competencies()->count(); + } +} diff --git a/erp/app/Modules/HR/Models/Department.php b/erp/app/Modules/HR/Models/Department.php new file mode 100644 index 00000000000..bd3e297ef0f --- /dev/null +++ b/erp/app/Modules/HR/Models/Department.php @@ -0,0 +1,28 @@ + 'boolean']; + + public function employees(): HasMany + { + return $this->hasMany(Employee::class); + } + + public function scopeActive($query) + { + return $query->where('is_active', true); + } +} diff --git a/erp/app/Modules/HR/Models/DisciplinaryCase.php b/erp/app/Modules/HR/Models/DisciplinaryCase.php new file mode 100644 index 00000000000..064ae1a6991 --- /dev/null +++ b/erp/app/Modules/HR/Models/DisciplinaryCase.php @@ -0,0 +1,65 @@ + 'date', + 'hearing_date' => 'date', + 'resolved_date' => 'date', + ]; + + public function employee(): BelongsTo + { + return $this->belongsTo(Employee::class); + } + + public function handledBy(): BelongsTo + { + return $this->belongsTo(User::class, 'handled_by'); + } + + public function scheduleHearing(Carbon $date): void + { + $this->hearing_date = $date; + $this->status = 'hearing_scheduled'; + $this->save(); + } + + public function resolve(string $outcome, ?string $notes = null): void + { + $this->outcome = $outcome; + $this->outcome_notes = $notes; + $this->status = 'resolved'; + $this->resolved_date = now()->toDateString(); + $this->save(); + } + + public function close(): void + { + $this->status = 'closed'; + $this->save(); + } + + public function getIsOpenAttribute(): bool + { + return !in_array($this->status, ['resolved', 'closed']); + } +} diff --git a/erp/app/Modules/HR/Models/Employee.php b/erp/app/Modules/HR/Models/Employee.php new file mode 100644 index 00000000000..ccf61892043 --- /dev/null +++ b/erp/app/Modules/HR/Models/Employee.php @@ -0,0 +1,130 @@ + 'date', + 'end_date' => 'date', + 'salary_amount' => 'decimal:2', + ]; + + public function department(): BelongsTo + { + return $this->belongsTo(Department::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function salaryGrade(): BelongsTo + { + return $this->belongsTo(SalaryGrade::class); + } + + public function salaryStructure(): BelongsTo + { + return $this->belongsTo(SalaryStructure::class, 'salary_structure_id'); + } + + public function leaveRequests(): HasMany + { + return $this->hasMany(LeaveRequest::class); + } + + public function payrollItems(): HasMany + { + return $this->hasMany(PayrollItem::class); + } + + public function onboardings(): HasMany + { + return $this->hasMany(EmployeeOnboarding::class); + } + + public function getFullNameAttribute(): string + { + return "{$this->first_name} {$this->last_name}"; + } + + /** @return string The employee code (e.g. EMP-00001) */ + public function getCodeAttribute(): string + { + if ($this->employee_number) { + return $this->employee_number; + } + return 'EMP-' . str_pad((string) $this->id, 5, '0', STR_PAD_LEFT); + } + + /** hire_date alias for start_date (getter) */ + public function getHireDateAttribute() + { + return $this->start_date; + } + + /** hire_date alias for start_date (setter) */ + public function setHireDateAttribute($value): void + { + $this->attributes['start_date'] = $value; + } + + /** salary alias for salary_amount */ + public function getSalaryAttribute() + { + return $this->salary_amount; + } + + /** termination_date alias for end_date */ + public function getTerminationDateAttribute() + { + return $this->end_date; + } + + public function scopeActive($query) + { + return $query->where('status', 'active'); + } + + public function scopeSearch($query, string $term) + { + return $query->where(function ($q) use ($term) { + $q->where('first_name', 'like', "%{$term}%") + ->orWhere('last_name', 'like', "%{$term}%") + ->orWhere('email', 'like', "%{$term}%") + ->orWhere('employee_number', 'like', "%{$term}%"); + }); + } + + public function emergencyContacts(): HasMany + { + return $this->hasMany(EmployeeEmergencyContact::class); + } + + public function leaveBalances(): HasMany + { + return $this->hasMany(LeaveBalance::class); + } +} diff --git a/erp/app/Modules/HR/Models/EmployeeBenefit.php b/erp/app/Modules/HR/Models/EmployeeBenefit.php new file mode 100644 index 00000000000..1a2d03435e6 --- /dev/null +++ b/erp/app/Modules/HR/Models/EmployeeBenefit.php @@ -0,0 +1,60 @@ + 'date', + 'ended_at' => 'date', + ]; + + public function employee(): BelongsTo + { + return $this->belongsTo(Employee::class); + } + + public function plan(): BelongsTo + { + return $this->belongsTo(BenefitPlan::class, 'benefit_plan_id'); + } + + public function waive(): void + { + $this->status = 'waived'; + $this->save(); + } + + public function end(): void + { + $this->status = 'ended'; + $this->ended_at = now()->toDateString(); + $this->save(); + } + + public function getIsActiveAttribute(): bool + { + return $this->status === 'active'; + } + + public function getMonthlyCostAttribute(): float + { + return $this->plan?->employee_cost ?? 0.0; + } +} diff --git a/erp/app/Modules/HR/Models/EmployeeCertification.php b/erp/app/Modules/HR/Models/EmployeeCertification.php new file mode 100644 index 00000000000..23bf533b8a4 --- /dev/null +++ b/erp/app/Modules/HR/Models/EmployeeCertification.php @@ -0,0 +1,45 @@ + 'date', + 'expiry_date' => 'date', + 'is_verified' => 'boolean', + ]; + + public function employee(): BelongsTo + { + return $this->belongsTo(Employee::class); + } + + public function enrollment(): BelongsTo + { + return $this->belongsTo(TrainingEnrollment::class, 'training_enrollment_id'); + } + + public function getIsExpiredAttribute(): bool + { + return $this->expiry_date !== null && $this->expiry_date->isPast(); + } + + public function getIsExpiringAttribute(): bool + { + return $this->expiry_date !== null + && $this->expiry_date->isFuture() + && $this->expiry_date->diffInDays(now()) <= 30; + } +} diff --git a/erp/app/Modules/HR/Models/EmployeeDocument.php b/erp/app/Modules/HR/Models/EmployeeDocument.php new file mode 100644 index 00000000000..dd635354f22 --- /dev/null +++ b/erp/app/Modules/HR/Models/EmployeeDocument.php @@ -0,0 +1,57 @@ + 'date', + 'expiry_date' => 'date', + 'is_verified' => 'boolean', + 'verified_at' => 'datetime', + ]; + + public function employee(): BelongsTo + { + return $this->belongsTo(Employee::class); + } + + public function verifiedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'verified_by'); + } + + public function verify(int $userId): void + { + $this->is_verified = true; + $this->verified_by = $userId; + $this->verified_at = now(); + $this->save(); + } + + public function getIsExpiredAttribute(): bool + { + return $this->expiry_date !== null && $this->expiry_date->isPast(); + } + + public function getIsExpiringSoonAttribute(): bool + { + return $this->expiry_date !== null + && ! $this->expiry_date->isPast() + && $this->expiry_date->diffInDays(now()) <= 30; + } +} diff --git a/erp/app/Modules/HR/Models/EmployeeEmergencyContact.php b/erp/app/Modules/HR/Models/EmployeeEmergencyContact.php new file mode 100644 index 00000000000..d9f50d28f5f --- /dev/null +++ b/erp/app/Modules/HR/Models/EmployeeEmergencyContact.php @@ -0,0 +1,53 @@ + 'boolean', + ]; + + protected $attributes = [ + 'is_primary' => false, + ]; + + public function employee(): BelongsTo + { + return $this->belongsTo(Employee::class); + } + + public function markAsPrimary(): void + { + // Clear any existing primary on this employee + static::where('employee_id', $this->employee_id) + ->where('id', '!=', $this->id) + ->update(['is_primary' => false]); + + $this->is_primary = true; + $this->save(); + } + + protected function displayName(): Attribute + { + return Attribute::make( + get: fn () => $this->name . ' (' . $this->relationship . ')', + ); + } +} diff --git a/erp/app/Modules/HR/Models/EmployeeExit.php b/erp/app/Modules/HR/Models/EmployeeExit.php new file mode 100644 index 00000000000..3ba0df3691f --- /dev/null +++ b/erp/app/Modules/HR/Models/EmployeeExit.php @@ -0,0 +1,65 @@ + 'date', + 'equipment_returned' => 'boolean', + 'access_revoked' => 'boolean', + 'processed_at' => 'datetime', + ]; + + public function employee(): BelongsTo + { + return $this->belongsTo(Employee::class); + } + + public function processedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'processed_by'); + } + + public function complete(int $userId): void + { + $this->status = 'completed'; + $this->processed_by = $userId; + $this->processed_at = now(); + $this->save(); + } + + public function markInProgress(): void + { + $this->status = 'in_progress'; + $this->save(); + } + + public function getIsPendingAttribute(): bool + { + return $this->status === 'pending'; + } + + public function getIsCompleteAttribute(): bool + { + return $this->status === 'completed'; + } + + public function getDaysUntilExitAttribute(): int + { + return max(0, (int) now()->diffInDays($this->exit_date, false)); + } +} diff --git a/erp/app/Modules/HR/Models/EmployeeGoal.php b/erp/app/Modules/HR/Models/EmployeeGoal.php new file mode 100644 index 00000000000..102fcf056c8 --- /dev/null +++ b/erp/app/Modules/HR/Models/EmployeeGoal.php @@ -0,0 +1,107 @@ + 'active', + 'goal_type' => 'individual', + 'priority' => 'medium', + 'progress_percent' => 0, + 'current_value' => 0, + ]; + + protected $casts = [ + 'target_value' => 'decimal:2', + 'current_value' => 'decimal:2', + 'start_date' => 'date', + 'due_date' => 'date', + 'completed_at' => 'date', + 'progress_percent' => 'integer', + ]; + + // Relations + + public function employee(): BelongsTo + { + return $this->belongsTo(Employee::class); + } + + // Methods + + public function complete(): void + { + $this->status = 'completed'; + $this->completed_at = now()->toDateString(); + $this->progress_percent = 100; + $this->save(); + } + + public function miss(): void + { + $this->status = 'missed'; + $this->save(); + } + + public function cancel(): void + { + $this->status = 'cancelled'; + $this->save(); + } + + public function updateProgress(int $percent): void + { + $this->progress_percent = min(100, max(0, $percent)); + if ($this->progress_percent >= 100) { + $this->complete(); + } else { + $this->save(); + } + } + + // Accessors + + public function getIsActiveAttribute(): bool + { + return $this->status === 'active'; + } + + public function getIsOverdueAttribute(): bool + { + return $this->is_active && $this->due_date < Carbon::today(); + } + + public function getIsCompletedAttribute(): bool + { + return $this->status === 'completed'; + } +} diff --git a/erp/app/Modules/HR/Models/EmployeeLoan.php b/erp/app/Modules/HR/Models/EmployeeLoan.php new file mode 100644 index 00000000000..dc9a6cfade2 --- /dev/null +++ b/erp/app/Modules/HR/Models/EmployeeLoan.php @@ -0,0 +1,71 @@ + 'decimal:2', + 'outstanding_balance' => 'decimal:2', + 'interest_rate' => 'decimal:2', + 'approved_at' => 'datetime', + 'disbursed_at' => 'datetime', + 'repayment_start_date' => 'date', + ]; + + public function employee(): BelongsTo + { + return $this->belongsTo(Employee::class); + } + + public function approver(): BelongsTo + { + return $this->belongsTo(User::class, 'approved_by'); + } + + public function repayments(): HasMany + { + return $this->hasMany(LoanRepayment::class); + } + + public function approve(User $user): void + { + $this->status = 'active'; + $this->approved_by = $user->id; + $this->approved_at = now(); + $this->disbursed_at = now(); + $this->save(); + } + + public function cancel(): void + { + $this->status = 'cancelled'; + $this->save(); + } + + public function getTotalRepaidAttribute(): float + { + return (float) $this->repayments->sum('amount'); + } + + public function getIsFullyRepaidAttribute(): bool + { + return (float) $this->outstanding_balance <= 0; + } +} diff --git a/erp/app/Modules/HR/Models/EmployeeOnboarding.php b/erp/app/Modules/HR/Models/EmployeeOnboarding.php new file mode 100644 index 00000000000..b30a93a7486 --- /dev/null +++ b/erp/app/Modules/HR/Models/EmployeeOnboarding.php @@ -0,0 +1,133 @@ + 'date', + 'start_date' => 'date', + 'completed_at' => 'datetime', + ]; + + public function employee(): BelongsTo + { + return $this->belongsTo(Employee::class); + } + + public function template(): BelongsTo + { + return $this->belongsTo(OnboardingTemplate::class, 'template_id'); + } + + public function checklist(): BelongsTo + { + return $this->belongsTo(OnboardingChecklist::class, 'onboarding_checklist_id'); + } + + /** Legacy tasks (EmployeeOnboardingTask) */ + public function tasks(): HasMany + { + return $this->hasMany(EmployeeOnboardingTask::class)->orderBy('sort_order'); + } + + /** New progress items (OnboardingProgress) */ + public function progress(): HasMany + { + return $this->hasMany(OnboardingProgress::class, 'employee_onboarding_id'); + } + + public function assignedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'assigned_by'); + } + + /** Legacy progress percentage (0-100 integer) */ + public function getProgressAttribute(): int + { + $total = $this->tasks()->count(); + if ($total === 0) return 0; + return (int) round($this->tasks()->whereNotNull('completed_at')->count() / $total * 100); + } + + /** New completion percentage (0.0-100.0 float) */ + public function getCompletionPercentAttribute(): float + { + $total = $this->progress()->count(); + if ($total === 0) return 0.0; + $done = $this->progress()->whereIn('status', ['completed', 'skipped'])->count(); + return round($done / $total * 100, 1); + } + + /** + * Auto-complete this onboarding if all required tasks are completed or skipped. + */ + public function checkComplete(): void + { + $requiredTotal = $this->progress() + ->whereHas('task', fn($q) => $q->where('is_required', true)) + ->count(); + + if ($requiredTotal === 0) { + return; + } + + $requiredDone = $this->progress() + ->whereHas('task', fn($q) => $q->where('is_required', true)) + ->whereIn('status', ['completed', 'skipped']) + ->count(); + + if ($requiredDone >= $requiredTotal) { + $this->update([ + 'status' => 'completed', + 'completed_at' => now(), + ]); + } + } + + /** + * Instantiate from a template, creating task copies for the employee. + */ + public static function fromTemplate(Employee $employee, OnboardingTemplate $template): self + { + $startDate = $employee->start_date ?? now()->toDateString(); + + $onboarding = self::create([ + 'tenant_id' => $employee->tenant_id, + 'employee_id' => $employee->id, + 'template_id' => $template->id, + 'title' => $template->name, + 'status' => 'in_progress', + 'started_at' => $startDate, + ]); + + foreach ($template->tasks as $task) { + $dueDate = $task->due_days > 0 + ? \Carbon\Carbon::parse($onboarding->started_at)->addDays($task->due_days)->toDateString() + : null; + + $onboarding->tasks()->create([ + 'title' => $task->title, + 'description' => $task->description, + 'due_date' => $dueDate, + 'sort_order' => $task->sort_order, + ]); + } + + return $onboarding; + } +} diff --git a/erp/app/Modules/HR/Models/EmployeeOnboardingTask.php b/erp/app/Modules/HR/Models/EmployeeOnboardingTask.php new file mode 100644 index 00000000000..acc442cabbf --- /dev/null +++ b/erp/app/Modules/HR/Models/EmployeeOnboardingTask.php @@ -0,0 +1,29 @@ + 'datetime', 'due_date' => 'date']; + + public function onboarding(): BelongsTo + { + return $this->belongsTo(EmployeeOnboarding::class, 'employee_onboarding_id'); + } + + public function completer(): BelongsTo + { + return $this->belongsTo(User::class, 'completed_by'); + } + + public function getIsCompletedAttribute(): bool + { + return $this->completed_at !== null; + } +} diff --git a/erp/app/Modules/HR/Models/EmployeePositionChange.php b/erp/app/Modules/HR/Models/EmployeePositionChange.php new file mode 100644 index 00000000000..ac31b4e5dcf --- /dev/null +++ b/erp/app/Modules/HR/Models/EmployeePositionChange.php @@ -0,0 +1,68 @@ + 'date', + 'from_salary' => 'float', + 'to_salary' => 'float', + 'approved_at' => 'datetime', + ]; + + public function employee(): BelongsTo + { + return $this->belongsTo(Employee::class); + } + + public function fromDepartment(): BelongsTo + { + return $this->belongsTo(Department::class, 'from_department_id'); + } + + public function toDepartment(): BelongsTo + { + return $this->belongsTo(Department::class, 'to_department_id'); + } + + public function approvedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'approved_by'); + } + + public function approve(int $userId): void + { + $this->approved_by = $userId; + $this->approved_at = now(); + $this->save(); + } + + public function getSalaryChangeAttribute(): float + { + if ($this->from_salary === null || $this->to_salary === null) { + return 0.0; + } + return $this->to_salary - $this->from_salary; + } + + public function getIsApprovedAttribute(): bool + { + return $this->approved_by !== null; + } +} diff --git a/erp/app/Modules/HR/Models/EmployeeSchedule.php b/erp/app/Modules/HR/Models/EmployeeSchedule.php new file mode 100644 index 00000000000..83e72b54f1c --- /dev/null +++ b/erp/app/Modules/HR/Models/EmployeeSchedule.php @@ -0,0 +1,57 @@ + 'date', + 'effective_to' => 'date', + 'is_active' => 'boolean', + ]; + + public function employee(): BelongsTo + { + return $this->belongsTo(Employee::class); + } + + public function schedule(): BelongsTo + { + return $this->belongsTo(WorkSchedule::class, 'work_schedule_id'); + } + + public function getIsCurrentAttribute(): bool + { + if (! $this->is_active) { + return false; + } + + $today = Carbon::today(); + + if ($this->effective_from->gt($today)) { + return false; + } + + if ($this->effective_to !== null && $this->effective_to->lt($today)) { + return false; + } + + return true; + } +} diff --git a/erp/app/Modules/HR/Models/EmployeeSkill.php b/erp/app/Modules/HR/Models/EmployeeSkill.php new file mode 100644 index 00000000000..61fbbe1785c --- /dev/null +++ b/erp/app/Modules/HR/Models/EmployeeSkill.php @@ -0,0 +1,66 @@ + false, + 'proficiency_level' => 1, + ]; + + protected $casts = [ + 'proficiency_level' => 'integer', + 'is_verified' => 'boolean', + 'verified_at' => 'datetime', + 'acquired_date' => 'date', + ]; + + public function employee(): BelongsTo + { + return $this->belongsTo(Employee::class); + } + + public function definition(): BelongsTo + { + return $this->belongsTo(SkillDefinition::class, 'skill_definition_id'); + } + + public function verifiedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'verified_by'); + } + + public function verify(int $userId): void + { + $this->is_verified = true; + $this->verified_by = $userId; + $this->verified_at = now(); + $this->save(); + } + + public function getProficiencyLabelAttribute(): string + { + return match ($this->proficiency_level) { + 1 => 'Beginner', + 2 => 'Basic', + 3 => 'Intermediate', + 4 => 'Advanced', + 5 => 'Expert', + default => 'Unknown', + }; + } +} diff --git a/erp/app/Modules/HR/Models/EmployeeSurvey.php b/erp/app/Modules/HR/Models/EmployeeSurvey.php new file mode 100644 index 00000000000..f045da5ee2a --- /dev/null +++ b/erp/app/Modules/HR/Models/EmployeeSurvey.php @@ -0,0 +1,60 @@ + 'draft', + 'is_anonymous' => false, + ]; + + protected $fillable = [ + 'tenant_id', 'title', 'description', 'status', + 'start_date', 'end_date', 'is_anonymous', 'created_by', + ]; + + protected $casts = [ + 'start_date' => 'date', + 'end_date' => 'date', + 'is_anonymous' => 'boolean', + ]; + + public function publish(): void + { + $this->update(['status' => 'published']); + } + + public function close(): void + { + $this->update(['status' => 'closed']); + } + + public function getIsActiveAttribute(): bool + { + return $this->status === 'published' + && ($this->end_date === null || $this->end_date->gte(now()->startOfDay())); + } + + public function getResponseCountAttribute(): int + { + return $this->responses()->count(); + } + + public function questions(): HasMany + { + return $this->hasMany(SurveyQuestion::class, 'employee_survey_id'); + } + + public function responses(): HasMany + { + return $this->hasMany(SurveyResponse::class, 'employee_survey_id'); + } +} diff --git a/erp/app/Modules/HR/Models/EmployeeTrainingRecord.php b/erp/app/Modules/HR/Models/EmployeeTrainingRecord.php new file mode 100644 index 00000000000..0a51de987ca --- /dev/null +++ b/erp/app/Modules/HR/Models/EmployeeTrainingRecord.php @@ -0,0 +1,47 @@ + 'date', + 'expiry_date' => 'date', + 'passed' => 'boolean', + 'score' => 'float', + ]; + + public function employee(): BelongsTo + { + return $this->belongsTo(Employee::class); + } + + public function trainingCourse(): BelongsTo + { + return $this->belongsTo(TrainingCourse::class); + } + + public function getIsExpiredAttribute(): bool + { + return $this->expiry_date && $this->expiry_date->isPast(); + } + + public function getIsExpiringAttribute(): bool + { + return $this->expiry_date + && !$this->expiry_date->isPast() + && $this->expiry_date->diffInDays(now()) <= 30; + } +} diff --git a/erp/app/Modules/HR/Models/ExpenseClaim.php b/erp/app/Modules/HR/Models/ExpenseClaim.php new file mode 100644 index 00000000000..2f92f3cafd0 --- /dev/null +++ b/erp/app/Modules/HR/Models/ExpenseClaim.php @@ -0,0 +1,87 @@ + 'decimal:2', + 'submitted_at' => 'datetime', + 'approved_at' => 'datetime', + 'paid_at' => 'datetime', + 'status' => 'string', + ]; + + public function employee(): BelongsTo + { + return $this->belongsTo(Employee::class); + } + + public function approvedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'approved_by'); + } + + public function items(): HasMany + { + return $this->hasMany(ExpenseClaimItem::class); + } + + public function submit(): void + { + $this->status = 'submitted'; + $this->submitted_at = now(); + $this->save(); + } + + public function approve(User $user): void + { + $this->status = 'approved'; + $this->approved_by = $user->id; + $this->approved_at = now(); + $this->save(); + } + + public function reject(string $reason): void + { + $this->status = 'rejected'; + $this->rejection_reason = $reason; + $this->save(); + } + + public function markPaid(): void + { + $this->status = 'paid'; + $this->paid_at = now(); + $this->save(); + } + + public function recalculateTotal(): void + { + $this->total_amount = $this->items()->sum('amount'); + $this->save(); + } + + public function getTotalItemsAttribute(): int + { + return $this->items()->count(); + } +} diff --git a/erp/app/Modules/HR/Models/ExpenseClaimItem.php b/erp/app/Modules/HR/Models/ExpenseClaimItem.php new file mode 100644 index 00000000000..e73c27bc7a7 --- /dev/null +++ b/erp/app/Modules/HR/Models/ExpenseClaimItem.php @@ -0,0 +1,29 @@ + 'decimal:2', + 'expense_date' => 'date', + ]; + + public function expenseClaim(): BelongsTo + { + return $this->belongsTo(ExpenseClaim::class); + } +} diff --git a/erp/app/Modules/HR/Models/FlexibleWorkArrangement.php b/erp/app/Modules/HR/Models/FlexibleWorkArrangement.php new file mode 100644 index 00000000000..609cccbd35d --- /dev/null +++ b/erp/app/Modules/HR/Models/FlexibleWorkArrangement.php @@ -0,0 +1,80 @@ + 'date', + 'end_date' => 'date', + 'approved_at' => 'datetime', + ]; + + protected $attributes = ['status' => 'pending']; + + // ── Relationships ───────────────────────────────────────────────────────── + + public function employee(): BelongsTo + { + return $this->belongsTo(Employee::class); + } + + // ── Actions ─────────────────────────────────────────────────────────────── + + public function approve(int $userId): void + { + $this->update([ + 'status' => 'approved', + 'approved_by' => $userId, + 'approved_at' => now(), + ]); + } + + public function reject(string $reason): void + { + $this->update([ + 'status' => 'rejected', + 'rejection_reason' => $reason, + ]); + } + + public function expire(): void + { + $this->update(['status' => 'expired']); + } + + // ── Accessors ───────────────────────────────────────────────────────────── + + public function getIsPendingAttribute(): bool + { + return $this->status === 'pending'; + } + + public function getIsActiveAttribute(): bool + { + return $this->status === 'approved' + && ($this->end_date === null || $this->end_date->gte(now()->startOfDay())); + } +} diff --git a/erp/app/Modules/HR/Models/Grievance.php b/erp/app/Modules/HR/Models/Grievance.php new file mode 100644 index 00000000000..d7ab4126b00 --- /dev/null +++ b/erp/app/Modules/HR/Models/Grievance.php @@ -0,0 +1,58 @@ + 'date', + 'resolved_date' => 'date', + 'is_anonymous' => 'boolean', + ]; + + public function employee(): BelongsTo + { + return $this->belongsTo(Employee::class); + } + + public function assignedTo(): BelongsTo + { + return $this->belongsTo(User::class, 'assigned_to'); + } + + public function resolve(string $resolution): void + { + $this->resolution = $resolution; + $this->status = 'resolved'; + $this->resolved_date = now()->toDateString(); + $this->save(); + } + + public function close(): void + { + $this->status = 'closed'; + $this->save(); + } + + public function assign(int $userId): void + { + $this->assigned_to = $userId; + $this->status = 'under_review'; + $this->save(); + } +} diff --git a/erp/app/Modules/HR/Models/HrAnnouncement.php b/erp/app/Modules/HR/Models/HrAnnouncement.php new file mode 100644 index 00000000000..c1d59062443 --- /dev/null +++ b/erp/app/Modules/HR/Models/HrAnnouncement.php @@ -0,0 +1,60 @@ + 'boolean', + 'publish_at' => 'datetime', + 'expire_at' => 'datetime', + ]; + + public function department(): BelongsTo + { + return $this->belongsTo(Department::class); + } + + public function createdBy(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function publish(): void + { + $this->is_published = true; + $this->publish_at = $this->publish_at ?? now(); + $this->save(); + } + + public function archive(): void + { + $this->is_published = false; + $this->expire_at = now(); + $this->save(); + } + + public function getIsActiveAttribute(): bool + { + if (! $this->is_published) { + return false; + } + if ($this->expire_at !== null && $this->expire_at->isPast()) { + return false; + } + return true; + } +} diff --git a/erp/app/Modules/HR/Models/InterviewSchedule.php b/erp/app/Modules/HR/Models/InterviewSchedule.php new file mode 100644 index 00000000000..5b9ba9a3065 --- /dev/null +++ b/erp/app/Modules/HR/Models/InterviewSchedule.php @@ -0,0 +1,116 @@ + 'datetime', + 'duration_minutes' => 'integer', + ]; + + protected $attributes = [ + 'status' => 'scheduled', + 'interview_type' => 'in-person', + 'duration_minutes' => 60, + ]; + + // Relations + + public function interviewer(): BelongsTo + { + return $this->belongsTo(User::class, 'interviewer_id'); + } + + public function jobApplication(): BelongsTo + { + return $this->belongsTo(JobApplication::class); + } + + public function createdBy(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + // State transition methods + + public function confirm(): void + { + $this->status = 'confirmed'; + $this->save(); + } + + public function complete(string $outcome = null, string $feedback = null): void + { + $this->status = 'completed'; + if ($outcome !== null) { + $this->outcome = $outcome; + } + if ($feedback !== null) { + $this->feedback = $feedback; + } + $this->save(); + } + + public function cancel(): void + { + $this->status = 'cancelled'; + $this->save(); + } + + public function markNoShow(): void + { + $this->status = 'no-show'; + $this->save(); + } + + public function generateInterviewNumber(): string + { + return 'INT-' . date('Y') . '-' . str_pad((string) $this->id, 5, '0', STR_PAD_LEFT); + } + + // Accessors + + public function getIsScheduledAttribute(): bool + { + return $this->status === 'scheduled'; + } + + public function getIsConfirmedAttribute(): bool + { + return $this->status === 'confirmed'; + } + + public function getIsCompletedAttribute(): bool + { + return $this->status === 'completed'; + } +} diff --git a/erp/app/Modules/HR/Models/JobApplication.php b/erp/app/Modules/HR/Models/JobApplication.php new file mode 100644 index 00000000000..90ee0c2b1a0 --- /dev/null +++ b/erp/app/Modules/HR/Models/JobApplication.php @@ -0,0 +1,95 @@ + 'integer', + 'reviewed_at' => 'datetime', + 'rejected_at' => 'datetime', + 'hired_at' => 'datetime', + ]; + + public function position(): BelongsTo + { + return $this->belongsTo(JobPosition::class, 'job_position_id'); + } + + public function jobPosition(): BelongsTo + { + return $this->belongsTo(JobPosition::class); + } + + public function reviewer(): BelongsTo + { + return $this->belongsTo(User::class, 'reviewed_by'); + } + + public function advance(string $newStatus): void + { + $this->status = $newStatus; + $this->stage = $newStatus; + if ($newStatus === 'hired') { + $this->hired_at = now(); + } + if ($newStatus === 'rejected') { + $this->rejected_at = now(); + } + $this->save(); + } + + public function hire(): void + { + $this->status = 'hired'; + $this->stage = 'hired'; + $this->hired_at = now(); + $this->save(); + } + + public function reject(string $notes = ''): void + { + $this->status = 'rejected'; + $this->stage = 'rejected'; + $this->rejected_at = now(); + if ($notes !== '') { + $this->notes = $notes; + } + $this->save(); + } + + public function getIsActiveAttribute(): bool + { + $terminal = ['hired', 'rejected']; + $s = $this->status ?? $this->stage ?? 'new'; + return !in_array($s, $terminal); + } +} diff --git a/erp/app/Modules/HR/Models/JobOfferLetter.php b/erp/app/Modules/HR/Models/JobOfferLetter.php new file mode 100644 index 00000000000..0380ecf2724 --- /dev/null +++ b/erp/app/Modules/HR/Models/JobOfferLetter.php @@ -0,0 +1,79 @@ + 'float', + 'proposed_start_date' => 'date', + 'offer_expiry_date' => 'date', + 'sent_at' => 'datetime', + 'responded_at' => 'datetime', + ]; + + protected $attributes = ['status' => 'draft']; + + // ── Actions ─────────────────────────────────────────────────────────────── + + public function send(): void + { + $this->update([ + 'status' => 'sent', + 'sent_at' => now(), + ]); + } + + public function accept(): void + { + $this->update([ + 'status' => 'accepted', + 'responded_at' => now(), + ]); + } + + public function decline(): void + { + $this->update([ + 'status' => 'declined', + 'responded_at' => now(), + ]); + } + + // ── Accessors ───────────────────────────────────────────────────────────── + + public function getIsExpiredAttribute(): bool + { + return $this->offer_expiry_date !== null + && $this->offer_expiry_date->lt(today()) + && $this->status !== 'accepted'; + } + + public function getIsPendingAttribute(): bool + { + return $this->status === 'sent'; + } +} diff --git a/erp/app/Modules/HR/Models/JobPosition.php b/erp/app/Modules/HR/Models/JobPosition.php new file mode 100644 index 00000000000..4513e6f8a10 --- /dev/null +++ b/erp/app/Modules/HR/Models/JobPosition.php @@ -0,0 +1,89 @@ + 'float', + 'salary_max' => 'float', + 'openings' => 'integer', + 'is_active' => 'boolean', + 'posted_at' => 'datetime', + 'closed_at' => 'datetime', + 'closes_at' => 'date', + ]; + + public function department(): BelongsTo + { + return $this->belongsTo(Department::class); + } + + public function applications(): HasMany + { + return $this->hasMany(JobApplication::class); + } + + public function getIsOpenAttribute(): bool + { + if (!$this->is_active) { + return false; + } + if ($this->closes_at === null) { + return true; + } + return Carbon::parse($this->closes_at)->gte(Carbon::today()); + } + + public function getApplicationCountAttribute(): int + { + return $this->applications()->count(); + } + + public function getOpenApplicationsCountAttribute(): int + { + return $this->applications()->whereNotIn('stage', ['hired', 'rejected'])->count(); + } + + public function publish(): void + { + $this->status = 'open'; + $this->posted_at = now(); + $this->save(); + } + + public function close(): void + { + $this->status = 'closed'; + $this->closed_at = now(); + $this->save(); + } +} diff --git a/erp/app/Modules/HR/Models/LeaveBalance.php b/erp/app/Modules/HR/Models/LeaveBalance.php new file mode 100644 index 00000000000..0cc937771b7 --- /dev/null +++ b/erp/app/Modules/HR/Models/LeaveBalance.php @@ -0,0 +1,39 @@ + 'float', + 'used_days' => 'float', + 'pending_days' => 'float', + 'year' => 'integer', + ]; + + public function employee(): BelongsTo + { + return $this->belongsTo(Employee::class); + } + + public function leaveType(): BelongsTo + { + return $this->belongsTo(LeaveType::class, 'leave_type_id'); + } + + public function getRemainingDaysAttribute(): float + { + return (float) $this->allocated_days - (float) $this->used_days - (float) $this->pending_days; + } +} diff --git a/erp/app/Modules/HR/Models/LeaveRequest.php b/erp/app/Modules/HR/Models/LeaveRequest.php new file mode 100644 index 00000000000..2193690a8b8 --- /dev/null +++ b/erp/app/Modules/HR/Models/LeaveRequest.php @@ -0,0 +1,167 @@ + 'date', + 'end_date' => 'date', + 'reviewed_at' => 'datetime', + 'approved_at' => 'datetime', + 'days_requested' => 'float', + ]; + + protected $attributes = ['status' => 'pending']; + + // ── Relationships ───────────────────────────────────────────────────────── + + public function employee(): BelongsTo + { + return $this->belongsTo(Employee::class); + } + + public function leaveType(): BelongsTo + { + return $this->belongsTo(LeaveType::class, 'leave_type_id'); + } + + public function reviewer(): BelongsTo + { + return $this->belongsTo(User::class, 'reviewed_by'); + } + + public function approver(): BelongsTo + { + return $this->belongsTo(User::class, 'approved_by'); + } + + // ── Accessors ───────────────────────────────────────────────────────────── + + /** days accessor — reads legacy 'days' column */ + public function getDaysAttribute(): int + { + if (isset($this->attributes['days']) && $this->attributes['days'] !== null) { + return (int) $this->attributes['days']; + } + if ($this->start_date && $this->end_date) { + return $this->start_date->diffInDays($this->end_date) + 1; + } + return 0; + } + + /** Alias for days_requested (phase 83 spec) */ + public function getDaysCountAttribute(): float + { + return (float) ($this->attributes['days_requested'] ?? $this->attributes['days'] ?? 0); + } + + // ── Actions ─────────────────────────────────────────────────────────────── + + public function approve(User $approver): void + { + if ($this->status !== 'pending') { + throw new \DomainException("Leave request is already {$this->status}."); + } + + $daysRequested = (float) ($this->attributes['days_requested'] ?? $this->attributes['days'] ?? 0); + + $this->update([ + 'status' => 'approved', + 'reviewed_by' => $approver->id, + 'reviewed_at' => now(), + 'approved_by' => $approver->id, + 'approved_at' => now(), + ]); + + // Update leave balance: pending → used + $year = $this->start_date ? $this->start_date->year : now()->year; + $balance = LeaveBalance::where('employee_id', $this->employee_id) + ->where('leave_type_id', $this->leave_type_id) + ->where('year', $year) + ->first(); + + if ($balance && $daysRequested > 0) { + LeaveBalance::where('employee_id', $this->employee_id) + ->where('leave_type_id', $this->leave_type_id) + ->where('year', $year) + ->decrement('pending_days', $daysRequested); + + LeaveBalance::where('employee_id', $this->employee_id) + ->where('leave_type_id', $this->leave_type_id) + ->where('year', $year) + ->increment('used_days', $daysRequested); + } + } + + public function reject(User $approver, string $reason = ''): void + { + if ($this->status !== 'pending') { + throw new \DomainException("Leave request is already {$this->status}."); + } + + $daysRequested = (float) ($this->attributes['days_requested'] ?? $this->attributes['days'] ?? 0); + + $this->update([ + 'status' => 'rejected', + 'rejection_reason' => $reason ?: null, + 'reviewed_by' => $approver->id, + 'reviewed_at' => now(), + ]); + + // Decrement pending_days + if ($daysRequested > 0) { + $year = $this->start_date ? $this->start_date->year : now()->year; + LeaveBalance::where('employee_id', $this->employee_id) + ->where('leave_type_id', $this->leave_type_id) + ->where('year', $year) + ->decrement('pending_days', $daysRequested); + } + } + + public function cancel(): void + { + $daysRequested = (float) ($this->attributes['days_requested'] ?? $this->attributes['days'] ?? 0); + $year = $this->start_date ? $this->start_date->year : now()->year; + $wasPending = $this->status === 'pending'; + $wasApproved = $this->status === 'approved'; + + $this->update(['status' => 'cancelled']); + + if ($daysRequested > 0) { + if ($wasPending) { + LeaveBalance::where('employee_id', $this->employee_id) + ->where('leave_type_id', $this->leave_type_id) + ->where('year', $year) + ->decrement('pending_days', $daysRequested); + } elseif ($wasApproved) { + LeaveBalance::where('employee_id', $this->employee_id) + ->where('leave_type_id', $this->leave_type_id) + ->where('year', $year) + ->decrement('used_days', $daysRequested); + } + } + } +} diff --git a/erp/app/Modules/HR/Models/LeaveType.php b/erp/app/Modules/HR/Models/LeaveType.php new file mode 100644 index 00000000000..1275d0f9a2d --- /dev/null +++ b/erp/app/Modules/HR/Models/LeaveType.php @@ -0,0 +1,48 @@ + 'boolean', + 'is_active' => 'boolean', + 'requires_approval' => 'boolean', + 'default_days' => 'integer', + 'days_per_year' => 'integer', + ]; + + public function requests(): HasMany + { + return $this->hasMany(LeaveRequest::class, 'leave_type_id'); + } + + /** Backward-compat alias */ + public function leaveRequests(): HasMany + { + return $this->hasMany(LeaveRequest::class); + } + + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + /** Virtual: read default_days or fall back to days_per_year */ + public function getEffectiveDaysAttribute(): int + { + return $this->default_days ?: ($this->days_per_year ?: 0); + } +} diff --git a/erp/app/Modules/HR/Models/LoanRepayment.php b/erp/app/Modules/HR/Models/LoanRepayment.php new file mode 100644 index 00000000000..c7a113741bb --- /dev/null +++ b/erp/app/Modules/HR/Models/LoanRepayment.php @@ -0,0 +1,26 @@ + 'decimal:2', + 'payment_date' => 'date', + ]; + + public function loan(): BelongsTo + { + return $this->belongsTo(EmployeeLoan::class, 'employee_loan_id'); + } +} diff --git a/erp/app/Modules/HR/Models/MentorshipProgram.php b/erp/app/Modules/HR/Models/MentorshipProgram.php new file mode 100644 index 00000000000..9505a98f53d --- /dev/null +++ b/erp/app/Modules/HR/Models/MentorshipProgram.php @@ -0,0 +1,115 @@ + 'active', + 'meeting_frequency' => 'monthly', + 'sessions_completed' => 0, + 'sessions_planned' => 0, + ]; + + protected $casts = [ + 'start_date' => 'date', + 'end_date' => 'date', + 'sessions_completed' => 'integer', + 'sessions_planned' => 'integer', + ]; + + // Relations + + public function mentor(): BelongsTo + { + return $this->belongsTo(Employee::class, 'mentor_id'); + } + + public function mentee(): BelongsTo + { + return $this->belongsTo(Employee::class, 'mentee_id'); + } + + // Status methods + + public function complete(): void + { + $this->status = 'completed'; + $this->save(); + } + + public function pause(): void + { + $this->status = 'paused'; + $this->save(); + } + + public function resume(): void + { + $this->status = 'active'; + $this->save(); + } + + public function cancel(): void + { + $this->status = 'cancelled'; + $this->save(); + } + + public function logSession(): void + { + $this->sessions_completed++; + $this->save(); + } + + public function generateProgramNumber(): string + { + return 'MP-' . date('Y') . '-' . str_pad((string) $this->id, 5, '0', STR_PAD_LEFT); + } + + // Accessors + + public function getIsActiveAttribute(): bool + { + return $this->status === 'active'; + } + + public function getIsCompletedAttribute(): bool + { + return $this->status === 'completed'; + } + + public function getProgressPercentAttribute(): int + { + if ($this->sessions_planned > 0) { + return (int) min(100, round(($this->sessions_completed / $this->sessions_planned) * 100)); + } + + return 0; + } +} diff --git a/erp/app/Modules/HR/Models/OnboardingChecklist.php b/erp/app/Modules/HR/Models/OnboardingChecklist.php new file mode 100644 index 00000000000..47f0e9dd1f5 --- /dev/null +++ b/erp/app/Modules/HR/Models/OnboardingChecklist.php @@ -0,0 +1,35 @@ + 'boolean', + ]; + + public function tasks(): HasMany + { + return $this->hasMany(OnboardingTask::class)->orderBy('sort_order'); + } + + public function employeeOnboardings(): HasMany + { + return $this->hasMany(EmployeeOnboarding::class, 'onboarding_checklist_id'); + } +} diff --git a/erp/app/Modules/HR/Models/OnboardingProgress.php b/erp/app/Modules/HR/Models/OnboardingProgress.php new file mode 100644 index 00000000000..3fbc7c7ecb3 --- /dev/null +++ b/erp/app/Modules/HR/Models/OnboardingProgress.php @@ -0,0 +1,64 @@ + 'datetime', + ]; + + public function onboarding(): BelongsTo + { + return $this->belongsTo(EmployeeOnboarding::class, 'employee_onboarding_id'); + } + + public function task(): BelongsTo + { + return $this->belongsTo(OnboardingTask::class, 'onboarding_task_id'); + } + + public function completedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'completed_by'); + } + + public function complete(User $user, ?string $notes = null): void + { + $this->status = 'completed'; + $this->completed_by = $user->id; + $this->completed_at = now(); + $this->notes = $notes; + $this->save(); + + $this->onboarding->checkComplete(); + } + + public function skip(?string $notes = null): void + { + $this->status = 'skipped'; + $this->notes = $notes; + $this->save(); + + $this->onboarding->checkComplete(); + } +} diff --git a/erp/app/Modules/HR/Models/OnboardingTask.php b/erp/app/Modules/HR/Models/OnboardingTask.php new file mode 100644 index 00000000000..2991013062d --- /dev/null +++ b/erp/app/Modules/HR/Models/OnboardingTask.php @@ -0,0 +1,34 @@ + 'boolean', + 'due_day_offset' => 'integer', + 'sort_order' => 'integer', + ]; + + public function checklist(): BelongsTo + { + return $this->belongsTo(OnboardingChecklist::class, 'onboarding_checklist_id'); + } +} diff --git a/erp/app/Modules/HR/Models/OnboardingTemplate.php b/erp/app/Modules/HR/Models/OnboardingTemplate.php new file mode 100644 index 00000000000..60607b3f02d --- /dev/null +++ b/erp/app/Modules/HR/Models/OnboardingTemplate.php @@ -0,0 +1,27 @@ + 'boolean']; + + public function tasks(): HasMany + { + return $this->hasMany(OnboardingTemplateTask::class)->orderBy('sort_order'); + } + + public function onboardings(): HasMany + { + return $this->hasMany(EmployeeOnboarding::class, 'template_id'); + } +} diff --git a/erp/app/Modules/HR/Models/OnboardingTemplateTask.php b/erp/app/Modules/HR/Models/OnboardingTemplateTask.php new file mode 100644 index 00000000000..1b21fb5f1fd --- /dev/null +++ b/erp/app/Modules/HR/Models/OnboardingTemplateTask.php @@ -0,0 +1,16 @@ +belongsTo(OnboardingTemplate::class, 'onboarding_template_id'); + } +} diff --git a/erp/app/Modules/HR/Models/OvertimeRequest.php b/erp/app/Modules/HR/Models/OvertimeRequest.php new file mode 100644 index 00000000000..812b4ab5372 --- /dev/null +++ b/erp/app/Modules/HR/Models/OvertimeRequest.php @@ -0,0 +1,77 @@ + 'pending', + 'rate_multiplier' => 1.5, + ]; + + protected $fillable = [ + 'tenant_id', 'employee_id', 'work_date', 'hours', 'rate_multiplier', + 'reason', 'status', 'approved_by', 'approved_at', 'rejection_reason', + ]; + + protected $casts = [ + 'work_date' => 'date', + 'hours' => 'decimal:2', + 'rate_multiplier' => 'decimal:2', + 'approved_at' => 'datetime', + ]; + + public function employee(): BelongsTo + { + return $this->belongsTo(Employee::class); + } + + public function approver(): BelongsTo + { + return $this->belongsTo(\App\Models\User::class, 'approved_by'); + } + + public function approve(int $userId): void + { + $this->update([ + 'status' => 'approved', + 'approved_by' => $userId, + 'approved_at' => now(), + ]); + } + + public function reject(string $reason): void + { + $this->update([ + 'status' => 'rejected', + 'rejection_reason' => $reason, + ]); + } + + public function cancel(): void + { + $this->update(['status' => 'cancelled']); + } + + public function getTotalPayAttribute(): float + { + return (float) $this->hours * (float) $this->rate_multiplier; + } + + public function getIsPendingAttribute(): bool + { + return $this->status === 'pending'; + } + + public function getIsApprovedAttribute(): bool + { + return $this->status === 'approved'; + } +} diff --git a/erp/app/Modules/HR/Models/PayrollItem.php b/erp/app/Modules/HR/Models/PayrollItem.php new file mode 100644 index 00000000000..bcb49f0629a --- /dev/null +++ b/erp/app/Modules/HR/Models/PayrollItem.php @@ -0,0 +1,29 @@ + 'decimal:2', + 'deductions' => 'decimal:2', + 'net_salary' => 'decimal:2', + ]; + + public function payrollRun(): BelongsTo + { + return $this->belongsTo(PayrollRun::class); + } + + public function employee(): BelongsTo + { + return $this->belongsTo(Employee::class); + } +} diff --git a/erp/app/Modules/HR/Models/PayrollRun.php b/erp/app/Modules/HR/Models/PayrollRun.php new file mode 100644 index 00000000000..4354111294a --- /dev/null +++ b/erp/app/Modules/HR/Models/PayrollRun.php @@ -0,0 +1,186 @@ + 'date', + 'period_end' => 'date', + 'run_date' => 'date', + 'processed_at' => 'datetime', + 'approved_at' => 'datetime', + 'total_gross' => 'decimal:2', + 'total_net' => 'decimal:2', + 'total_deductions' => 'decimal:2', + ]; + + protected $attributes = ['status' => 'draft']; + + public function items(): HasMany + { + return $this->hasMany(PayrollItem::class); + } + + public function payslips(): HasMany + { + return $this->hasMany(Payslip::class); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function approvedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'approved_by'); + } + + public function getTotalGrossAttribute(): float + { + // If items are loaded, compute from items; otherwise use stored value + if ($this->relationLoaded('items') && $this->items->isNotEmpty()) { + return (float) $this->items->sum('gross_salary'); + } + return (float) ($this->attributes['total_gross'] ?? 0); + } + + public function getTotalNetAttribute(): float + { + if ($this->relationLoaded('items') && $this->items->isNotEmpty()) { + return (float) $this->items->sum('net_salary'); + } + return (float) ($this->attributes['total_net'] ?? 0); + } + + /** + * Process the payroll run (legacy method). + * Computes totals from active employees' salary_amount for this tenant. + */ + public function process(): void + { + if ($this->status !== 'draft') { + throw new \DomainException('Payroll run is already processed.'); + } + + // Compute totals from active employees for this tenant + $employees = Employee::withoutGlobalScopes() + ->where('tenant_id', $this->tenant_id) + ->where('status', 'active') + ->get(); + + $totalGross = $employees->sum('salary_amount'); + $totalDeductions = 0; + $totalNet = $totalGross - $totalDeductions; + $employeeCount = $employees->count(); + + $this->update([ + 'status' => 'processed', + 'total_gross' => $totalGross, + 'total_deductions' => $totalDeductions, + 'total_net' => $totalNet, + 'employee_count' => $employeeCount, + 'processed_at' => now(), + ]); + } + + /** + * Approve the payroll run. + */ + public function approve(int|User $user): void + { + $this->status = 'approved'; + $this->approved_by = $user instanceof User ? $user->id : $user; + $this->approved_at = now(); + $this->save(); + event(new \App\Events\HR\PayrollRunApproved($this)); + } + + /** + * Mark the payroll run as paid. + */ + public function markPaid(): void + { + $this->status = 'paid'; + $this->save(); + } + + /** + * Recalculate totals from payslips. + */ + public function recalculateTotals(): void + { + $payslips = $this->payslips()->get(); + $this->total_gross = $payslips->sum('gross_amount'); + $this->total_deductions = $payslips->sum('total_deductions'); + $this->total_net = $payslips->sum('net_amount'); + $this->save(); + } + + /** + * Generate payslips for all active employees with salary_amount > 0. + * Returns count of payslips generated. + */ + public function generatePayslips(): int + { + $employees = Employee::withoutGlobalScopes() + ->where('tenant_id', $this->tenant_id) + ->where('status', 'active') + ->where('salary_amount', '>', 0) + ->with('salaryStructure.rules') + ->get(); + + $count = 0; + foreach ($employees as $employee) { + $gross = (float) $employee->salary_amount; + $lines = []; + $deductions = 0.0; + + if ($employee->salaryStructure) { + $lines = $employee->salaryStructure->compute($employee); + $gross = collect($lines)->where('category', 'earnings')->sum('amount'); + $deductions = collect($lines)->where('category', 'deductions')->sum('amount'); + } else { + $tax = round($gross * 0.10, 2); + $deductions = $tax; + $lines = [ + ['salary_rule_id' => null, 'code' => 'BASIC', 'name' => 'Basic Salary', 'category' => 'earnings', 'sequence' => 10, 'amount' => $gross], + ['salary_rule_id' => null, 'code' => 'TAX', 'name' => 'Income Tax', 'category' => 'deductions', 'sequence' => 20, 'amount' => $tax], + ]; + } + + $net = $gross - $deductions; + $taxLine = collect($lines)->firstWhere('code', 'TAX'); + $tax = $taxLine ? (float) $taxLine['amount'] : $deductions; + + $payslip = Payslip::updateOrCreate( + ['payroll_run_id' => $this->id, 'employee_id' => $employee->id], + ['tenant_id' => $this->tenant_id, 'gross_amount' => $gross, 'tax_amount' => $tax, 'total_deductions' => $deductions, 'net_amount' => $net] + ); + + $payslip->lines()->delete(); + foreach ($lines as $line) { + $payslip->lines()->create($line); + } + $count++; + } + return $count; + } +} diff --git a/erp/app/Modules/HR/Models/Payslip.php b/erp/app/Modules/HR/Models/Payslip.php new file mode 100644 index 00000000000..6c782eedcc9 --- /dev/null +++ b/erp/app/Modules/HR/Models/Payslip.php @@ -0,0 +1,57 @@ + 'decimal:2', + 'total_deductions' => 'decimal:2', + 'net_amount' => 'decimal:2', + 'tax_amount' => 'decimal:2', + ]; + + public function payrollRun(): BelongsTo + { + return $this->belongsTo(PayrollRun::class); + } + + public function employee(): BelongsTo + { + return $this->belongsTo(Employee::class); + } + + public function lines(): HasMany + { + return $this->hasMany(PayslipLine::class)->orderBy('sequence'); + } + + public function getEffectiveTaxRateAttribute(): float + { + $gross = (float) $this->gross_amount; + if ($gross <= 0) { + return 0.0; + } + return round((float) $this->tax_amount / $gross * 100, 2); + } +} diff --git a/erp/app/Modules/HR/Models/PayslipLine.php b/erp/app/Modules/HR/Models/PayslipLine.php new file mode 100644 index 00000000000..3603681f551 --- /dev/null +++ b/erp/app/Modules/HR/Models/PayslipLine.php @@ -0,0 +1,11 @@ + 'decimal:2']; + public function payslip(): BelongsTo { return $this->belongsTo(Payslip::class); } +} diff --git a/erp/app/Modules/HR/Models/PerformanceKpi.php b/erp/app/Modules/HR/Models/PerformanceKpi.php new file mode 100644 index 00000000000..efd08b1203a --- /dev/null +++ b/erp/app/Modules/HR/Models/PerformanceKpi.php @@ -0,0 +1,45 @@ + 'decimal:2', + 'actual_score' => 'decimal:2', + 'weight' => 'decimal:2', + ]; + + public function review(): BelongsTo + { + return $this->belongsTo(PerformanceReview::class, 'performance_review_id'); + } + + public function getAchievementPercentAttribute(): ?float + { + if ($this->target_score <= 0) { + return null; + } + + return round($this->actual_score / $this->target_score * 100, 1); + } +} diff --git a/erp/app/Modules/HR/Models/PerformanceReview.php b/erp/app/Modules/HR/Models/PerformanceReview.php new file mode 100644 index 00000000000..34cad34e864 --- /dev/null +++ b/erp/app/Modules/HR/Models/PerformanceReview.php @@ -0,0 +1,106 @@ + 'date', + 'submitted_at' => 'datetime', + 'acknowledged_at' => 'datetime', + 'overall_rating' => 'integer', + 'status' => 'string', + ]; + + public function employee(): BelongsTo + { + return $this->belongsTo(Employee::class); + } + + public function reviewer(): BelongsTo + { + return $this->belongsTo(User::class, 'reviewer_id'); + } + + public function kpis(): HasMany + { + return $this->hasMany(PerformanceKpi::class); + } + + public function ratings(): HasMany + { + return $this->hasMany(ReviewRating::class); + } + + public function submit(): void + { + $this->status = 'submitted'; + $this->submitted_at = now(); + $this->save(); + } + + public function acknowledge(string $comments = ''): void + { + $this->status = 'acknowledged'; + $this->acknowledged_at = now(); + if ($comments !== '') { + $this->employee_comments = $comments; + } + $this->save(); + } + + public function getIsCompleteAttribute(): bool + { + return $this->status === 'acknowledged'; + } + + public function getAverageRatingAttribute(): float + { + if ($this->ratings()->count() > 0) { + return round((float) $this->ratings()->avg('rating'), 1); + } + return (float) ($this->overall_rating ?? 0); + } + + public function getAverageKpiScoreAttribute(): ?float + { + $kpis = $this->kpis->filter(fn ($kpi) => $kpi->target_score > 0); + + if ($kpis->isEmpty()) { + return null; + } + + $total = $kpis->sum(fn ($kpi) => ($kpi->actual_score / $kpi->target_score) * 100); + + return round($total / $kpis->count(), 1); + } +} diff --git a/erp/app/Modules/HR/Models/PerformanceReviewCompetency.php b/erp/app/Modules/HR/Models/PerformanceReviewCompetency.php new file mode 100644 index 00000000000..8f225ae4065 --- /dev/null +++ b/erp/app/Modules/HR/Models/PerformanceReviewCompetency.php @@ -0,0 +1,16 @@ +belongsTo(PerformanceReview::class, 'performance_review_id'); + } +} diff --git a/erp/app/Modules/HR/Models/PerformanceReviewGoal.php b/erp/app/Modules/HR/Models/PerformanceReviewGoal.php new file mode 100644 index 00000000000..69d20487a70 --- /dev/null +++ b/erp/app/Modules/HR/Models/PerformanceReviewGoal.php @@ -0,0 +1,18 @@ + 'boolean']; + + public function review(): BelongsTo + { + return $this->belongsTo(PerformanceReview::class, 'performance_review_id'); + } +} diff --git a/erp/app/Modules/HR/Models/ReviewRating.php b/erp/app/Modules/HR/Models/ReviewRating.php new file mode 100644 index 00000000000..c9b2ff95aca --- /dev/null +++ b/erp/app/Modules/HR/Models/ReviewRating.php @@ -0,0 +1,31 @@ + 'integer', + ]; + + public function review(): BelongsTo + { + return $this->belongsTo(PerformanceReview::class, 'performance_review_id'); + } +} diff --git a/erp/app/Modules/HR/Models/SalaryGrade.php b/erp/app/Modules/HR/Models/SalaryGrade.php new file mode 100644 index 00000000000..a18a99bf2b2 --- /dev/null +++ b/erp/app/Modules/HR/Models/SalaryGrade.php @@ -0,0 +1,46 @@ + 'float', + 'mid_salary' => 'float', + 'max_salary' => 'float', + 'is_active' => 'boolean', + ]; + + public function employees(): HasMany + { + return $this->hasMany(Employee::class, 'salary_grade_id'); + } + + public function isSalaryInRange(float $salary): bool + { + return $salary >= $this->min_salary && $salary <= $this->max_salary; + } + + public function getSalaryRangeAttribute(): string + { + return number_format($this->min_salary, 0) . ' - ' . number_format($this->max_salary, 0) . ' ' . $this->currency; + } + + public function getMidpointAttribute(): float + { + return $this->mid_salary ?? (($this->min_salary + $this->max_salary) / 2); + } +} diff --git a/erp/app/Modules/HR/Models/SalaryRule.php b/erp/app/Modules/HR/Models/SalaryRule.php new file mode 100644 index 00000000000..07b8b1063ff --- /dev/null +++ b/erp/app/Modules/HR/Models/SalaryRule.php @@ -0,0 +1,13 @@ + 'float', 'percentage' => 'float', 'is_active' => 'boolean']; + public function structure(): BelongsTo { return $this->belongsTo(SalaryStructure::class, 'structure_id'); } +} diff --git a/erp/app/Modules/HR/Models/SalaryStructure.php b/erp/app/Modules/HR/Models/SalaryStructure.php new file mode 100644 index 00000000000..43ad737a605 --- /dev/null +++ b/erp/app/Modules/HR/Models/SalaryStructure.php @@ -0,0 +1,52 @@ + 'boolean']; + + public function rules(): HasMany { return $this->hasMany(SalaryRule::class, 'structure_id')->orderBy('sequence'); } + + public function compute(Employee $employee): array + { + $rules = $this->rules()->where('is_active', true)->get(); + $computed = []; + $lines = []; + foreach ($rules as $rule) { + $amount = $this->computeRule($rule, $employee, $computed); + $computed[$rule->code] = $amount; + $lines[] = [ + 'salary_rule_id' => $rule->id, + 'code' => $rule->code, + 'name' => $rule->name, + 'category' => $rule->category, + 'sequence' => $rule->sequence, + 'amount' => round($amount, 2), + ]; + } + return $lines; + } + + private function computeRule(SalaryRule $rule, Employee $employee, array $computed): float + { + return match ($rule->amount_type) { + 'fixed' => (float) $rule->amount, + 'percentage_of_basic' => (float) $employee->salary_amount * (float) $rule->percentage / 100, + 'percentage_of_gross' => $this->sumEarnings($computed) * (float) $rule->percentage / 100, + 'percentage_of_rule' => ($computed[$rule->base_rule_code] ?? 0) * (float) $rule->percentage / 100, + default => 0.0, + }; + } + + private function sumEarnings(array $computed): float + { + $earningCodes = $this->rules()->where('category', 'earnings')->pluck('code'); + return (float) $earningCodes->sum(fn ($code) => $computed[$code] ?? 0); + } +} diff --git a/erp/app/Modules/HR/Models/ShiftAssignment.php b/erp/app/Modules/HR/Models/ShiftAssignment.php new file mode 100644 index 00000000000..9531bfcc226 --- /dev/null +++ b/erp/app/Modules/HR/Models/ShiftAssignment.php @@ -0,0 +1,44 @@ + 'date', + 'status' => 'string', + ]; + + public function shiftTemplate(): BelongsTo + { + return $this->belongsTo(ShiftTemplate::class); + } + + public function employee(): BelongsTo + { + return $this->belongsTo(Employee::class); + } + + public function getIsUpcomingAttribute(): bool + { + return $this->assigned_date->greaterThanOrEqualTo(Carbon::today()); + } +} diff --git a/erp/app/Modules/HR/Models/ShiftTemplate.php b/erp/app/Modules/HR/Models/ShiftTemplate.php new file mode 100644 index 00000000000..f3313889657 --- /dev/null +++ b/erp/app/Modules/HR/Models/ShiftTemplate.php @@ -0,0 +1,50 @@ + 'array', + 'break_minutes' => 'integer', + 'is_active' => 'boolean', + ]; + + public function assignments(): HasMany + { + return $this->hasMany(ShiftAssignment::class); + } + + public function getDurationHoursAttribute(): float + { + $start = Carbon::createFromFormat('H:i', substr($this->start_time, 0, 5)); + $end = Carbon::createFromFormat('H:i', substr($this->end_time, 0, 5)); + + $totalMinutes = $start->diffInMinutes($end); + $workMinutes = $totalMinutes - (int) $this->break_minutes; + + return round($workMinutes / 60, 2); + } +} diff --git a/erp/app/Modules/HR/Models/SkillDefinition.php b/erp/app/Modules/HR/Models/SkillDefinition.php new file mode 100644 index 00000000000..052f2fd7c99 --- /dev/null +++ b/erp/app/Modules/HR/Models/SkillDefinition.php @@ -0,0 +1,21 @@ + 'boolean']; + + public function employeeSkills(): HasMany + { + return $this->hasMany(EmployeeSkill::class); + } +} diff --git a/erp/app/Modules/HR/Models/SuccessionCandidate.php b/erp/app/Modules/HR/Models/SuccessionCandidate.php new file mode 100644 index 00000000000..898e2ba16ee --- /dev/null +++ b/erp/app/Modules/HR/Models/SuccessionCandidate.php @@ -0,0 +1,39 @@ + 'integer', + 'priority' => 'integer', + ]; + + protected $attributes = [ + 'readiness_level' => 'not-ready', + 'priority' => 1, + 'readiness_score' => 0, + ]; + + public function plan(): BelongsTo + { + return $this->belongsTo(SuccessionPlan::class); + } + + public function employee(): BelongsTo + { + return $this->belongsTo(Employee::class); + } +} diff --git a/erp/app/Modules/HR/Models/SuccessionPlan.php b/erp/app/Modules/HR/Models/SuccessionPlan.php new file mode 100644 index 00000000000..d89acd34b3a --- /dev/null +++ b/erp/app/Modules/HR/Models/SuccessionPlan.php @@ -0,0 +1,67 @@ + 'boolean', + ]; + + protected $attributes = [ + 'status' => 'active', + 'is_critical' => false, + ]; + + public function candidates(): HasMany + { + return $this->hasMany(SuccessionCandidate::class); + } + + public function currentHolder(): BelongsTo + { + return $this->belongsTo(Employee::class, 'current_holder_id'); + } + + public function complete(): void + { + $this->status = 'completed'; + $this->save(); + } + + public function deactivate(): void + { + $this->status = 'inactive'; + $this->save(); + } + + public function getIsActiveAttribute(): bool + { + return $this->status === 'active'; + } + + public function getCandidateCountAttribute(): int + { + return $this->candidates()->count(); + } +} diff --git a/erp/app/Modules/HR/Models/SurveyQuestion.php b/erp/app/Modules/HR/Models/SurveyQuestion.php new file mode 100644 index 00000000000..196c255bdc3 --- /dev/null +++ b/erp/app/Modules/HR/Models/SurveyQuestion.php @@ -0,0 +1,27 @@ + 'array', + 'is_required' => 'boolean', + ]; + + public function survey(): BelongsTo + { + return $this->belongsTo(EmployeeSurvey::class, 'employee_survey_id'); + } +} diff --git a/erp/app/Modules/HR/Models/SurveyResponse.php b/erp/app/Modules/HR/Models/SurveyResponse.php new file mode 100644 index 00000000000..c4ea4de5463 --- /dev/null +++ b/erp/app/Modules/HR/Models/SurveyResponse.php @@ -0,0 +1,28 @@ + 'array', + 'submitted_at' => 'datetime', + ]; + + public function survey(): BelongsTo + { + return $this->belongsTo(EmployeeSurvey::class, 'employee_survey_id'); + } +} diff --git a/erp/app/Modules/HR/Models/Timesheet.php b/erp/app/Modules/HR/Models/Timesheet.php new file mode 100644 index 00000000000..4424e31c842 --- /dev/null +++ b/erp/app/Modules/HR/Models/Timesheet.php @@ -0,0 +1,79 @@ + 'date', + 'week_end' => 'date', + 'total_hours' => 'float', + 'approved_at' => 'datetime', + ]; + + public function employee(): BelongsTo + { + return $this->belongsTo(Employee::class); + } + + public function approvedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'approved_by'); + } + + public function entries(): HasMany + { + return $this->hasMany(TimesheetEntry::class); + } + + public function submit(): void + { + $this->status = 'submitted'; + $this->save(); + } + + public function approve(int $userId): void + { + $this->status = 'approved'; + $this->approved_by = $userId; + $this->approved_at = now(); + $this->save(); + } + + public function reject(): void + { + $this->status = 'rejected'; + $this->save(); + } + + public function recalculateHours(): void + { + $this->total_hours = $this->entries()->sum('hours'); + $this->save(); + } + + public function getIsEditableAttribute(): bool + { + return $this->status === 'draft'; + } + + public function getIsApprovedAttribute(): bool + { + return $this->status === 'approved'; + } +} diff --git a/erp/app/Modules/HR/Models/TimesheetEntry.php b/erp/app/Modules/HR/Models/TimesheetEntry.php new file mode 100644 index 00000000000..bc12d46278f --- /dev/null +++ b/erp/app/Modules/HR/Models/TimesheetEntry.php @@ -0,0 +1,26 @@ + 'date', + 'hours' => 'float', + ]; + + public function timesheet(): BelongsTo + { + return $this->belongsTo(Timesheet::class); + } +} diff --git a/erp/app/Modules/HR/Models/TrainingCourse.php b/erp/app/Modules/HR/Models/TrainingCourse.php new file mode 100644 index 00000000000..cbaa54b90dd --- /dev/null +++ b/erp/app/Modules/HR/Models/TrainingCourse.php @@ -0,0 +1,40 @@ + 'boolean', + 'is_mandatory' => 'boolean', + 'duration_hours' => 'integer', + 'cost' => 'float', + ]; + + public function trainingRecords(): HasMany + { + return $this->hasMany(EmployeeTrainingRecord::class); + } + + public function enrollments(): HasMany + { + return $this->hasMany(TrainingEnrollment::class); + } + + public function getEnrolledCountAttribute(): int + { + return $this->enrollments()->count(); + } +} diff --git a/erp/app/Modules/HR/Models/TrainingEnrollment.php b/erp/app/Modules/HR/Models/TrainingEnrollment.php new file mode 100644 index 00000000000..525408b2b66 --- /dev/null +++ b/erp/app/Modules/HR/Models/TrainingEnrollment.php @@ -0,0 +1,68 @@ + 'date', + 'scheduled_date' => 'date', + 'completed_date' => 'date', + 'score' => 'float', + ]; + + public function employee(): BelongsTo + { + return $this->belongsTo(Employee::class); + } + + public function course(): BelongsTo + { + return $this->belongsTo(TrainingCourse::class, 'training_course_id'); + } + + public function enrolledBy(): BelongsTo + { + return $this->belongsTo(User::class, 'enrolled_by'); + } + + public function complete(float $score = null, string $notes = null): void + { + $this->status = 'completed'; + $this->completed_date = now()->toDateString(); + $this->score = $score; + $this->notes = $notes; + $this->save(); + } + + public function fail(string $notes = null): void + { + $this->status = 'failed'; + $this->completed_date = now()->toDateString(); + $this->notes = $notes; + $this->save(); + } + + public function getIsCompletedAttribute(): bool + { + return $this->status === 'completed'; + } + + public function getIsExpiredAttribute(): bool + { + return false; + } +} diff --git a/erp/app/Modules/HR/Models/TrainingSession.php b/erp/app/Modules/HR/Models/TrainingSession.php new file mode 100644 index 00000000000..7798f2dc19e --- /dev/null +++ b/erp/app/Modules/HR/Models/TrainingSession.php @@ -0,0 +1,90 @@ + 'datetime', + 'ends_at' => 'datetime', + 'max_participants'=> 'integer', + 'enrolled_count' => 'integer', + ]; + + protected $attributes = [ + 'status' => 'scheduled', + 'delivery_mode' => 'in-person', + 'max_participants' => 20, + 'enrolled_count' => 0, + ]; + + public function course(): BelongsTo + { + return $this->belongsTo(TrainingCourse::class, 'training_course_id'); + } + + public function facilitator(): BelongsTo + { + return $this->belongsTo(\App\Models\User::class, 'facilitator_id'); + } + + public function start(): void + { + $this->status = 'in-progress'; + $this->save(); + } + + public function complete(): void + { + $this->status = 'completed'; + $this->save(); + } + + public function cancel(): void + { + $this->status = 'cancelled'; + $this->save(); + } + + public function generateSessionNumber(): string + { + return 'TS-' . date('Y') . '-' . str_pad((string) $this->id, 5, '0', STR_PAD_LEFT); + } + + public function getIsScheduledAttribute(): bool + { + return $this->status === 'scheduled'; + } + + public function getIsFullAttribute(): bool + { + return $this->enrolled_count >= $this->max_participants; + } + + public function getSpotsRemainingAttribute(): int + { + return max(0, $this->max_participants - $this->enrolled_count); + } + + public function getDurationMinutesAttribute(): ?int + { + if ($this->scheduled_at && $this->ends_at) { + return (int) $this->scheduled_at->diffInMinutes($this->ends_at); + } + return null; + } +} diff --git a/erp/app/Modules/HR/Models/WorkSchedule.php b/erp/app/Modules/HR/Models/WorkSchedule.php new file mode 100644 index 00000000000..c61f4c8a544 --- /dev/null +++ b/erp/app/Modules/HR/Models/WorkSchedule.php @@ -0,0 +1,77 @@ + 'integer', + 'is_active' => 'boolean', + 'is_default' => 'boolean', + ]; + + public function shifts(): HasMany + { + return $this->hasMany(WorkScheduleShift::class); + } + + public function assignments(): HasMany + { + return $this->hasMany(EmployeeSchedule::class); + } + + public function getShiftCountAttribute(): int + { + return $this->shifts()->count(); + } + + public function scopeDefault($query) + { + return $query->where('is_default', true); + } + + public function getDayHours(string $day): ?array + { + $start = $this->{"{$day}_start"}; + $end = $this->{"{$day}_end"}; + + if (empty($start)) { + return null; + } + + return ['start' => $start, 'end' => $end]; + } +} diff --git a/erp/app/Modules/HR/Models/WorkScheduleShift.php b/erp/app/Modules/HR/Models/WorkScheduleShift.php new file mode 100644 index 00000000000..407b31127a0 --- /dev/null +++ b/erp/app/Modules/HR/Models/WorkScheduleShift.php @@ -0,0 +1,42 @@ + 'float', + ]; + + public function schedule(): BelongsTo + { + return $this->belongsTo(WorkSchedule::class, 'work_schedule_id'); + } + + public function getHoursAttribute(): float + { + $start = Carbon::createFromFormat('H:i', substr($this->start_time, 0, 5)); + $end = Carbon::createFromFormat('H:i', substr($this->end_time, 0, 5)); + + $totalMinutes = $start->diffInMinutes($end); + $workMinutes = $totalMinutes - (float) $this->break_minutes; + + return round($workMinutes / 60, 2); + } +} diff --git a/erp/app/Modules/HR/Policies/AttendancePolicy.php b/erp/app/Modules/HR/Policies/AttendancePolicy.php new file mode 100644 index 00000000000..8d9ec27f6f9 --- /dev/null +++ b/erp/app/Modules/HR/Policies/AttendancePolicy.php @@ -0,0 +1,34 @@ +can('hr.view'); + } + + public function view(User $user, Model $model): bool + { + return $user->can('hr.view'); + } + + public function create(User $user): bool + { + return $user->can('hr.create'); + } + + public function update(User $user, Model $model): bool + { + return $user->can('hr.create'); + } + + public function delete(User $user, Model $model): bool + { + return $user->can('hr.delete'); + } +} diff --git a/erp/app/Modules/HR/Policies/BenefitPolicy.php b/erp/app/Modules/HR/Policies/BenefitPolicy.php new file mode 100644 index 00000000000..854852eea61 --- /dev/null +++ b/erp/app/Modules/HR/Policies/BenefitPolicy.php @@ -0,0 +1,33 @@ +hasPermissionTo('hr.view'); + } + + public function view(User $user, $model): bool + { + return $user->hasPermissionTo('hr.view'); + } + + public function create(User $user): bool + { + return $user->hasPermissionTo('hr.create'); + } + + public function update(User $user, $model): bool + { + return $user->hasPermissionTo('hr.create'); + } + + public function delete(User $user, $model): bool + { + return $user->hasPermissionTo('hr.delete'); + } +} diff --git a/erp/app/Modules/HR/Policies/CompetencyFrameworkPolicy.php b/erp/app/Modules/HR/Policies/CompetencyFrameworkPolicy.php new file mode 100644 index 00000000000..779f8c4992d --- /dev/null +++ b/erp/app/Modules/HR/Policies/CompetencyFrameworkPolicy.php @@ -0,0 +1,44 @@ +can('hr.view'); + } + + public function view(User $user, CompetencyFramework $competencyFramework): bool + { + return $user->can('hr.view'); + } + + public function create(User $user): bool + { + return $user->can('hr.create'); + } + + public function update(User $user, CompetencyFramework $competencyFramework): bool + { + return $user->can('hr.create'); + } + + public function activate(User $user, CompetencyFramework $competencyFramework): bool + { + return $user->can('hr.create'); + } + + public function archive(User $user, CompetencyFramework $competencyFramework): bool + { + return $user->can('hr.create'); + } + + public function delete(User $user, CompetencyFramework $competencyFramework): bool + { + return $user->can('hr.delete'); + } +} diff --git a/erp/app/Modules/HR/Policies/DepartmentPolicy.php b/erp/app/Modules/HR/Policies/DepartmentPolicy.php new file mode 100644 index 00000000000..56da3cbc51e --- /dev/null +++ b/erp/app/Modules/HR/Policies/DepartmentPolicy.php @@ -0,0 +1,15 @@ +can('hr.view'); } + public function view(User $user, Department $department): bool { return $user->can('hr.view'); } + public function create(User $user): bool { return $user->can('hr.create'); } + public function update(User $user, Department $department): bool { return $user->can('hr.update'); } + public function delete(User $user, Department $department): bool { return $user->can('hr.delete'); } +} diff --git a/erp/app/Modules/HR/Policies/DisciplinaryPolicy.php b/erp/app/Modules/HR/Policies/DisciplinaryPolicy.php new file mode 100644 index 00000000000..77b719e2f7a --- /dev/null +++ b/erp/app/Modules/HR/Policies/DisciplinaryPolicy.php @@ -0,0 +1,33 @@ +hasPermissionTo('hr.view'); + } + + public function view(User $user, $model): bool + { + return $user->hasPermissionTo('hr.view'); + } + + public function create(User $user): bool + { + return $user->hasPermissionTo('hr.create'); + } + + public function update(User $user, $model): bool + { + return $user->hasPermissionTo('hr.create'); + } + + public function delete(User $user, $model): bool + { + return $user->hasPermissionTo('hr.delete'); + } +} diff --git a/erp/app/Modules/HR/Policies/EmployeeDocumentPolicy.php b/erp/app/Modules/HR/Policies/EmployeeDocumentPolicy.php new file mode 100644 index 00000000000..8246b2914a1 --- /dev/null +++ b/erp/app/Modules/HR/Policies/EmployeeDocumentPolicy.php @@ -0,0 +1,33 @@ +hasPermissionTo('hr.view'); + } + + public function view(User $user, $model): bool + { + return $user->hasPermissionTo('hr.view'); + } + + public function create(User $user): bool + { + return $user->hasPermissionTo('hr.create'); + } + + public function update(User $user, $model): bool + { + return $user->hasPermissionTo('hr.create'); + } + + public function delete(User $user, $model): bool + { + return $user->hasPermissionTo('hr.delete'); + } +} diff --git a/erp/app/Modules/HR/Policies/EmployeeEmergencyContactPolicy.php b/erp/app/Modules/HR/Policies/EmployeeEmergencyContactPolicy.php new file mode 100644 index 00000000000..dfdbd8c4425 --- /dev/null +++ b/erp/app/Modules/HR/Policies/EmployeeEmergencyContactPolicy.php @@ -0,0 +1,39 @@ +can('hr.view'); + } + + public function view(User $user, EmployeeEmergencyContact $contact): bool + { + return $user->can('hr.view'); + } + + public function create(User $user): bool + { + return $user->can('hr.create'); + } + + public function update(User $user, EmployeeEmergencyContact $contact): bool + { + return $user->can('hr.create'); + } + + public function markPrimary(User $user, EmployeeEmergencyContact $contact): bool + { + return $user->can('hr.create'); + } + + public function delete(User $user, EmployeeEmergencyContact $contact): bool + { + return $user->can('hr.delete'); + } +} diff --git a/erp/app/Modules/HR/Policies/EmployeeExitPolicy.php b/erp/app/Modules/HR/Policies/EmployeeExitPolicy.php new file mode 100644 index 00000000000..6d2aca27043 --- /dev/null +++ b/erp/app/Modules/HR/Policies/EmployeeExitPolicy.php @@ -0,0 +1,33 @@ +hasPermissionTo('hr.view'); + } + + public function view(User $user, $model): bool + { + return $user->hasPermissionTo('hr.view'); + } + + public function create(User $user): bool + { + return $user->hasPermissionTo('hr.create'); + } + + public function update(User $user, $model): bool + { + return $user->hasPermissionTo('hr.create'); + } + + public function delete(User $user, $model): bool + { + return $user->hasPermissionTo('hr.delete'); + } +} diff --git a/erp/app/Modules/HR/Policies/EmployeeGoalPolicy.php b/erp/app/Modules/HR/Policies/EmployeeGoalPolicy.php new file mode 100644 index 00000000000..9a263b9a190 --- /dev/null +++ b/erp/app/Modules/HR/Policies/EmployeeGoalPolicy.php @@ -0,0 +1,49 @@ +can('hr.view'); + } + + public function view(User $user, EmployeeGoal $employeeGoal): bool + { + return $user->can('hr.view'); + } + + public function create(User $user): bool + { + return $user->can('hr.create'); + } + + public function update(User $user, EmployeeGoal $employeeGoal): bool + { + return $user->can('hr.create'); + } + + public function complete(User $user, EmployeeGoal $employeeGoal): bool + { + return $user->can('hr.create'); + } + + public function miss(User $user, EmployeeGoal $employeeGoal): bool + { + return $user->can('hr.create'); + } + + public function cancel(User $user, EmployeeGoal $employeeGoal): bool + { + return $user->can('hr.create'); + } + + public function delete(User $user, EmployeeGoal $employeeGoal): bool + { + return $user->can('hr.delete'); + } +} diff --git a/erp/app/Modules/HR/Policies/EmployeeOnboardingPolicy.php b/erp/app/Modules/HR/Policies/EmployeeOnboardingPolicy.php new file mode 100644 index 00000000000..c2404274f06 --- /dev/null +++ b/erp/app/Modules/HR/Policies/EmployeeOnboardingPolicy.php @@ -0,0 +1,34 @@ +can('hr.view'); + } + + public function view(User $user, EmployeeOnboarding $onboarding): bool + { + return $user->can('hr.view'); + } + + public function create(User $user): bool + { + return $user->can('hr.create'); + } + + public function update(User $user, EmployeeOnboarding $onboarding): bool + { + return $user->can('hr.update'); + } + + public function delete(User $user, EmployeeOnboarding $onboarding): bool + { + return $user->can('hr.delete'); + } +} diff --git a/erp/app/Modules/HR/Policies/EmployeePolicy.php b/erp/app/Modules/HR/Policies/EmployeePolicy.php new file mode 100644 index 00000000000..34f9869dc69 --- /dev/null +++ b/erp/app/Modules/HR/Policies/EmployeePolicy.php @@ -0,0 +1,19 @@ +can('hr.view'); } + public function view(User $user, Employee $employee): bool { return $user->can('hr.view'); } + public function create(User $user): bool { return $user->can('hr.create'); } + public function update(User $user, Employee $employee): bool { return $user->can('hr.update'); } + public function delete(User $user, Employee $employee): bool + { + return $user->can('hr.delete') + && in_array($employee->status, ['active', 'terminated']); + } +} diff --git a/erp/app/Modules/HR/Policies/EmployeeSkillPolicy.php b/erp/app/Modules/HR/Policies/EmployeeSkillPolicy.php new file mode 100644 index 00000000000..dfb251ed3de --- /dev/null +++ b/erp/app/Modules/HR/Policies/EmployeeSkillPolicy.php @@ -0,0 +1,33 @@ +hasPermissionTo('hr.view'); + } + + public function view(User $user, $model): bool + { + return $user->hasPermissionTo('hr.view'); + } + + public function create(User $user): bool + { + return $user->hasPermissionTo('hr.create'); + } + + public function update(User $user, $model): bool + { + return $user->hasPermissionTo('hr.create'); + } + + public function delete(User $user, $model): bool + { + return $user->hasPermissionTo('hr.delete'); + } +} diff --git a/erp/app/Modules/HR/Policies/EmployeeSurveyPolicy.php b/erp/app/Modules/HR/Policies/EmployeeSurveyPolicy.php new file mode 100644 index 00000000000..78a9fdec56b --- /dev/null +++ b/erp/app/Modules/HR/Policies/EmployeeSurveyPolicy.php @@ -0,0 +1,34 @@ +hasPermissionTo('hr.view'); + } + + public function view(User $user, EmployeeSurvey $survey): bool + { + return $user->hasPermissionTo('hr.view'); + } + + public function create(User $user): bool + { + return $user->hasPermissionTo('hr.create'); + } + + public function update(User $user, EmployeeSurvey $survey): bool + { + return $user->hasPermissionTo('hr.create'); + } + + public function delete(User $user, EmployeeSurvey $survey): bool + { + return $user->hasPermissionTo('hr.delete'); + } +} diff --git a/erp/app/Modules/HR/Policies/ExpenseClaimPolicy.php b/erp/app/Modules/HR/Policies/ExpenseClaimPolicy.php new file mode 100644 index 00000000000..5b339bdb17e --- /dev/null +++ b/erp/app/Modules/HR/Policies/ExpenseClaimPolicy.php @@ -0,0 +1,34 @@ +can('hr.view'); + } + + public function view(User $user, ExpenseClaim $expenseClaim): bool + { + return $user->can('hr.view'); + } + + public function create(User $user): bool + { + return $user->can('hr.create'); + } + + public function update(User $user, ExpenseClaim $expenseClaim): bool + { + return $user->can('hr.create'); + } + + public function delete(User $user, ExpenseClaim $expenseClaim): bool + { + return $user->can('hr.delete'); + } +} diff --git a/erp/app/Modules/HR/Policies/FlexibleWorkPolicy.php b/erp/app/Modules/HR/Policies/FlexibleWorkPolicy.php new file mode 100644 index 00000000000..b00e548384f --- /dev/null +++ b/erp/app/Modules/HR/Policies/FlexibleWorkPolicy.php @@ -0,0 +1,33 @@ +hasPermissionTo('hr.view'); + } + + public function view(User $user, $model): bool + { + return $user->hasPermissionTo('hr.view'); + } + + public function create(User $user): bool + { + return $user->hasPermissionTo('hr.create'); + } + + public function update(User $user, $model): bool + { + return $user->hasPermissionTo('hr.create'); + } + + public function delete(User $user, $model): bool + { + return $user->hasPermissionTo('hr.delete'); + } +} diff --git a/erp/app/Modules/HR/Policies/HrAnnouncementPolicy.php b/erp/app/Modules/HR/Policies/HrAnnouncementPolicy.php new file mode 100644 index 00000000000..be076454857 --- /dev/null +++ b/erp/app/Modules/HR/Policies/HrAnnouncementPolicy.php @@ -0,0 +1,33 @@ +hasPermissionTo('hr.view'); + } + + public function view(User $user, $model): bool + { + return $user->hasPermissionTo('hr.view'); + } + + public function create(User $user): bool + { + return $user->hasPermissionTo('hr.create'); + } + + public function update(User $user, $model): bool + { + return $user->hasPermissionTo('hr.create'); + } + + public function delete(User $user, $model): bool + { + return $user->hasPermissionTo('hr.delete'); + } +} diff --git a/erp/app/Modules/HR/Policies/InterviewSchedulePolicy.php b/erp/app/Modules/HR/Policies/InterviewSchedulePolicy.php new file mode 100644 index 00000000000..4e5d00c472d --- /dev/null +++ b/erp/app/Modules/HR/Policies/InterviewSchedulePolicy.php @@ -0,0 +1,54 @@ +can('hr.view'); + } + + public function view(User $user, InterviewSchedule $interviewSchedule): bool + { + return $user->can('hr.view'); + } + + public function create(User $user): bool + { + return $user->can('hr.create'); + } + + public function update(User $user, InterviewSchedule $interviewSchedule): bool + { + return $user->can('hr.create'); + } + + public function confirm(User $user, InterviewSchedule $interviewSchedule): bool + { + return $user->can('hr.create'); + } + + public function complete(User $user, InterviewSchedule $interviewSchedule): bool + { + return $user->can('hr.create'); + } + + public function cancel(User $user, InterviewSchedule $interviewSchedule): bool + { + return $user->can('hr.create'); + } + + public function delete(User $user, InterviewSchedule $interviewSchedule): bool + { + return $user->can('hr.delete'); + } + + public function markNoShow(User $user, InterviewSchedule $interviewSchedule): bool + { + return $user->can('hr.delete'); + } +} diff --git a/erp/app/Modules/HR/Policies/JobOfferPolicy.php b/erp/app/Modules/HR/Policies/JobOfferPolicy.php new file mode 100644 index 00000000000..dc6e1dc9f83 --- /dev/null +++ b/erp/app/Modules/HR/Policies/JobOfferPolicy.php @@ -0,0 +1,33 @@ +hasPermissionTo('hr.view'); + } + + public function view(User $user, $model): bool + { + return $user->hasPermissionTo('hr.view'); + } + + public function create(User $user): bool + { + return $user->hasPermissionTo('hr.create'); + } + + public function update(User $user, $model): bool + { + return $user->hasPermissionTo('hr.create'); + } + + public function delete(User $user, $model): bool + { + return $user->hasPermissionTo('hr.delete'); + } +} diff --git a/erp/app/Modules/HR/Policies/LeavePolicy.php b/erp/app/Modules/HR/Policies/LeavePolicy.php new file mode 100644 index 00000000000..81f9184cf42 --- /dev/null +++ b/erp/app/Modules/HR/Policies/LeavePolicy.php @@ -0,0 +1,33 @@ +hasPermissionTo('hr.view'); + } + + public function view(User $user, $model): bool + { + return $user->hasPermissionTo('hr.view'); + } + + public function create(User $user): bool + { + return $user->hasPermissionTo('hr.create'); + } + + public function update(User $user, $model): bool + { + return $user->hasPermissionTo('hr.create'); + } + + public function delete(User $user, $model): bool + { + return $user->hasPermissionTo('hr.delete'); + } +} diff --git a/erp/app/Modules/HR/Policies/LeaveRequestPolicy.php b/erp/app/Modules/HR/Policies/LeaveRequestPolicy.php new file mode 100644 index 00000000000..794392667d3 --- /dev/null +++ b/erp/app/Modules/HR/Policies/LeaveRequestPolicy.php @@ -0,0 +1,19 @@ +can('hr.view'); } + public function view(User $user, LeaveRequest $leaveRequest): bool { return $user->can('hr.view'); } + public function create(User $user): bool { return $user->can('hr.create'); } + public function update(User $user, LeaveRequest $leaveRequest): bool { return $user->can('hr.update'); } + public function delete(User $user, LeaveRequest $leaveRequest): bool + { + return $user->can('hr.delete') + && in_array($leaveRequest->status, ['pending', 'cancelled']); + } +} diff --git a/erp/app/Modules/HR/Policies/LoanPolicy.php b/erp/app/Modules/HR/Policies/LoanPolicy.php new file mode 100644 index 00000000000..4e56ef58e77 --- /dev/null +++ b/erp/app/Modules/HR/Policies/LoanPolicy.php @@ -0,0 +1,34 @@ +can('hr.view'); + } + + public function view(User $user, EmployeeLoan $loan): bool + { + return $user->can('hr.view'); + } + + public function create(User $user): bool + { + return $user->can('hr.create'); + } + + public function update(User $user, EmployeeLoan $loan): bool + { + return $user->can('hr.create'); + } + + public function delete(User $user, EmployeeLoan $loan): bool + { + return $user->can('hr.delete'); + } +} diff --git a/erp/app/Modules/HR/Policies/MentorshipProgramPolicy.php b/erp/app/Modules/HR/Policies/MentorshipProgramPolicy.php new file mode 100644 index 00000000000..58e75dc330b --- /dev/null +++ b/erp/app/Modules/HR/Policies/MentorshipProgramPolicy.php @@ -0,0 +1,59 @@ +can('hr.view'); + } + + public function view(User $user, MentorshipProgram $mentorshipProgram): bool + { + return $user->can('hr.view'); + } + + public function create(User $user): bool + { + return $user->can('hr.create'); + } + + public function update(User $user, MentorshipProgram $mentorshipProgram): bool + { + return $user->can('hr.create'); + } + + public function complete(User $user, MentorshipProgram $mentorshipProgram): bool + { + return $user->can('hr.create'); + } + + public function pause(User $user, MentorshipProgram $mentorshipProgram): bool + { + return $user->can('hr.create'); + } + + public function resume(User $user, MentorshipProgram $mentorshipProgram): bool + { + return $user->can('hr.create'); + } + + public function cancel(User $user, MentorshipProgram $mentorshipProgram): bool + { + return $user->can('hr.create'); + } + + public function logSession(User $user, MentorshipProgram $mentorshipProgram): bool + { + return $user->can('hr.create'); + } + + public function delete(User $user, MentorshipProgram $mentorshipProgram): bool + { + return $user->can('hr.delete'); + } +} diff --git a/erp/app/Modules/HR/Policies/OnboardingPolicy.php b/erp/app/Modules/HR/Policies/OnboardingPolicy.php new file mode 100644 index 00000000000..afc43fe9809 --- /dev/null +++ b/erp/app/Modules/HR/Policies/OnboardingPolicy.php @@ -0,0 +1,33 @@ +hasPermissionTo('hr.view'); + } + + public function view(User $user, $model): bool + { + return $user->hasPermissionTo('hr.view'); + } + + public function create(User $user): bool + { + return $user->hasPermissionTo('hr.create'); + } + + public function update(User $user, $model): bool + { + return $user->hasPermissionTo('hr.create'); + } + + public function delete(User $user, $model): bool + { + return $user->hasPermissionTo('hr.delete'); + } +} diff --git a/erp/app/Modules/HR/Policies/OnboardingTemplatePolicy.php b/erp/app/Modules/HR/Policies/OnboardingTemplatePolicy.php new file mode 100644 index 00000000000..941cb1f71a5 --- /dev/null +++ b/erp/app/Modules/HR/Policies/OnboardingTemplatePolicy.php @@ -0,0 +1,34 @@ +can('hr.view'); + } + + public function view(User $user, OnboardingTemplate $template): bool + { + return $user->can('hr.view'); + } + + public function create(User $user): bool + { + return $user->can('hr.create'); + } + + public function update(User $user, OnboardingTemplate $template): bool + { + return $user->can('hr.update'); + } + + public function delete(User $user, OnboardingTemplate $template): bool + { + return $user->can('hr.delete'); + } +} diff --git a/erp/app/Modules/HR/Policies/OvertimeRequestPolicy.php b/erp/app/Modules/HR/Policies/OvertimeRequestPolicy.php new file mode 100644 index 00000000000..301a89d0b77 --- /dev/null +++ b/erp/app/Modules/HR/Policies/OvertimeRequestPolicy.php @@ -0,0 +1,34 @@ +hasPermissionTo('hr.view'); + } + + public function view(User $user, OvertimeRequest $overtimeRequest): bool + { + return $user->hasPermissionTo('hr.view'); + } + + public function create(User $user): bool + { + return $user->hasPermissionTo('hr.create'); + } + + public function update(User $user, OvertimeRequest $overtimeRequest): bool + { + return $user->hasPermissionTo('hr.create'); + } + + public function delete(User $user, OvertimeRequest $overtimeRequest): bool + { + return $user->hasPermissionTo('hr.delete'); + } +} diff --git a/erp/app/Modules/HR/Policies/PayrollPolicy.php b/erp/app/Modules/HR/Policies/PayrollPolicy.php new file mode 100644 index 00000000000..7f09408ac4e --- /dev/null +++ b/erp/app/Modules/HR/Policies/PayrollPolicy.php @@ -0,0 +1,34 @@ +can('hr.view'); + } + + public function view(User $user, PayrollRun $payrollRun): bool + { + return $user->can('hr.view'); + } + + public function create(User $user): bool + { + return $user->can('hr.create'); + } + + public function update(User $user, PayrollRun $payrollRun): bool + { + return $user->can('hr.create'); + } + + public function delete(User $user, PayrollRun $payrollRun): bool + { + return $user->can('hr.delete'); + } +} diff --git a/erp/app/Modules/HR/Policies/PayrollRunPolicy.php b/erp/app/Modules/HR/Policies/PayrollRunPolicy.php new file mode 100644 index 00000000000..0ae458d547f --- /dev/null +++ b/erp/app/Modules/HR/Policies/PayrollRunPolicy.php @@ -0,0 +1,18 @@ +can('hr.view'); } + public function view(User $user, PayrollRun $payrollRun): bool { return $user->can('hr.view'); } + public function create(User $user): bool { return $user->can('hr.create'); } + public function update(User $user, PayrollRun $payrollRun): bool { return $user->can('hr.update'); } + public function delete(User $user, PayrollRun $payrollRun): bool + { + return $user->can('hr.delete') && $payrollRun->status === 'draft'; + } +} diff --git a/erp/app/Modules/HR/Policies/PerformanceReviewPolicy.php b/erp/app/Modules/HR/Policies/PerformanceReviewPolicy.php new file mode 100644 index 00000000000..3251da86a20 --- /dev/null +++ b/erp/app/Modules/HR/Policies/PerformanceReviewPolicy.php @@ -0,0 +1,34 @@ +can('hr.view'); + } + + public function view(User $user, PerformanceReview $performanceReview): bool + { + return $user->can('hr.view'); + } + + public function create(User $user): bool + { + return $user->can('hr.create'); + } + + public function update(User $user, PerformanceReview $performanceReview): bool + { + return $user->can('hr.create'); + } + + public function delete(User $user, PerformanceReview $performanceReview): bool + { + return $user->can('hr.delete'); + } +} diff --git a/erp/app/Modules/HR/Policies/PositionChangePolicy.php b/erp/app/Modules/HR/Policies/PositionChangePolicy.php new file mode 100644 index 00000000000..c1618c76a08 --- /dev/null +++ b/erp/app/Modules/HR/Policies/PositionChangePolicy.php @@ -0,0 +1,33 @@ +hasPermissionTo('hr.view'); + } + + public function view(User $user, $model): bool + { + return $user->hasPermissionTo('hr.view'); + } + + public function create(User $user): bool + { + return $user->hasPermissionTo('hr.create'); + } + + public function update(User $user, $model): bool + { + return $user->hasPermissionTo('hr.create'); + } + + public function delete(User $user, $model): bool + { + return $user->hasPermissionTo('hr.delete'); + } +} diff --git a/erp/app/Modules/HR/Policies/RecruitmentPolicy.php b/erp/app/Modules/HR/Policies/RecruitmentPolicy.php new file mode 100644 index 00000000000..9a152b21998 --- /dev/null +++ b/erp/app/Modules/HR/Policies/RecruitmentPolicy.php @@ -0,0 +1,33 @@ +can('hr.view'); + } + + public function view(User $user): bool + { + return $user->can('hr.view'); + } + + public function create(User $user): bool + { + return $user->can('hr.create'); + } + + public function update(User $user): bool + { + return $user->can('hr.create'); + } + + public function delete(User $user): bool + { + return $user->can('hr.delete'); + } +} diff --git a/erp/app/Modules/HR/Policies/SalaryGradePolicy.php b/erp/app/Modules/HR/Policies/SalaryGradePolicy.php new file mode 100644 index 00000000000..95027990643 --- /dev/null +++ b/erp/app/Modules/HR/Policies/SalaryGradePolicy.php @@ -0,0 +1,15 @@ +can('hr.view'); } + public function view(User $user, SalaryGrade $salaryGrade): bool { return $user->can('hr.view'); } + public function create(User $user): bool { return $user->can('hr.create'); } + public function update(User $user, SalaryGrade $salaryGrade): bool { return $user->can('hr.create'); } + public function delete(User $user, SalaryGrade $salaryGrade): bool { return $user->can('hr.delete'); } +} diff --git a/erp/app/Modules/HR/Policies/ShiftPolicy.php b/erp/app/Modules/HR/Policies/ShiftPolicy.php new file mode 100644 index 00000000000..2464955b2b1 --- /dev/null +++ b/erp/app/Modules/HR/Policies/ShiftPolicy.php @@ -0,0 +1,33 @@ +can('hr.view'); + } + + public function view(User $user): bool + { + return $user->can('hr.view'); + } + + public function create(User $user): bool + { + return $user->can('hr.create'); + } + + public function update(User $user): bool + { + return $user->can('hr.create'); + } + + public function delete(User $user): bool + { + return $user->can('hr.delete'); + } +} diff --git a/erp/app/Modules/HR/Policies/SuccessionPlanPolicy.php b/erp/app/Modules/HR/Policies/SuccessionPlanPolicy.php new file mode 100644 index 00000000000..e225ece2a00 --- /dev/null +++ b/erp/app/Modules/HR/Policies/SuccessionPlanPolicy.php @@ -0,0 +1,44 @@ +can('hr.view'); + } + + public function view(User $user, SuccessionPlan $successionPlan): bool + { + return $user->can('hr.view'); + } + + public function create(User $user): bool + { + return $user->can('hr.create'); + } + + public function update(User $user, SuccessionPlan $successionPlan): bool + { + return $user->can('hr.create'); + } + + public function complete(User $user, SuccessionPlan $successionPlan): bool + { + return $user->can('hr.create'); + } + + public function deactivate(User $user, SuccessionPlan $successionPlan): bool + { + return $user->can('hr.create'); + } + + public function delete(User $user, SuccessionPlan $successionPlan): bool + { + return $user->can('hr.delete'); + } +} diff --git a/erp/app/Modules/HR/Policies/TimesheetPolicy.php b/erp/app/Modules/HR/Policies/TimesheetPolicy.php new file mode 100644 index 00000000000..bdbfbc4480a --- /dev/null +++ b/erp/app/Modules/HR/Policies/TimesheetPolicy.php @@ -0,0 +1,33 @@ +hasPermissionTo('hr.view'); + } + + public function view(User $user, $model): bool + { + return $user->hasPermissionTo('hr.view'); + } + + public function create(User $user): bool + { + return $user->hasPermissionTo('hr.create'); + } + + public function update(User $user, $model): bool + { + return $user->hasPermissionTo('hr.create'); + } + + public function delete(User $user, $model): bool + { + return $user->hasPermissionTo('hr.delete'); + } +} diff --git a/erp/app/Modules/HR/Policies/TrainingPolicy.php b/erp/app/Modules/HR/Policies/TrainingPolicy.php new file mode 100644 index 00000000000..08cce5e3a14 --- /dev/null +++ b/erp/app/Modules/HR/Policies/TrainingPolicy.php @@ -0,0 +1,33 @@ +hasPermissionTo('hr.view'); + } + + public function view(User $user, $model = null): bool + { + return $user->hasPermissionTo('hr.view'); + } + + public function create(User $user): bool + { + return $user->hasPermissionTo('hr.create'); + } + + public function update(User $user, $model = null): bool + { + return $user->hasPermissionTo('hr.create'); + } + + public function delete(User $user, $model = null): bool + { + return $user->hasPermissionTo('hr.delete'); + } +} diff --git a/erp/app/Modules/HR/Policies/TrainingSessionPolicy.php b/erp/app/Modules/HR/Policies/TrainingSessionPolicy.php new file mode 100644 index 00000000000..0199bd69c71 --- /dev/null +++ b/erp/app/Modules/HR/Policies/TrainingSessionPolicy.php @@ -0,0 +1,18 @@ +hasPermissionTo('hr.view'); } + public function view(User $user, TrainingSession $session): bool { return $user->hasPermissionTo('hr.view'); } + public function create(User $user): bool { return $user->hasPermissionTo('hr.create'); } + public function update(User $user, TrainingSession $session): bool { return $user->hasPermissionTo('hr.create'); } + public function delete(User $user, TrainingSession $session): bool { return $user->hasPermissionTo('hr.delete'); } + public function start(User $user, TrainingSession $session): bool { return $user->hasPermissionTo('hr.create'); } + public function complete(User $user, TrainingSession $session): bool { return $user->hasPermissionTo('hr.create'); } + public function cancel(User $user, TrainingSession $session): bool { return $user->hasPermissionTo('hr.create'); } +} diff --git a/erp/app/Modules/HR/Policies/WorkSchedulePolicy.php b/erp/app/Modules/HR/Policies/WorkSchedulePolicy.php new file mode 100644 index 00000000000..4b0a0103e3c --- /dev/null +++ b/erp/app/Modules/HR/Policies/WorkSchedulePolicy.php @@ -0,0 +1,34 @@ +can('hr.view'); + } + + public function view(User $user, Model $model): bool + { + return $user->can('hr.view'); + } + + public function create(User $user): bool + { + return $user->can('hr.create'); + } + + public function update(User $user, Model $model): bool + { + return $user->can('hr.create'); + } + + public function delete(User $user, Model $model): bool + { + return $user->can('hr.delete'); + } +} diff --git a/erp/app/Modules/HR/Providers/HRServiceProvider.php b/erp/app/Modules/HR/Providers/HRServiceProvider.php new file mode 100644 index 00000000000..5376646bb8a --- /dev/null +++ b/erp/app/Modules/HR/Providers/HRServiceProvider.php @@ -0,0 +1,168 @@ +loadRoutesFrom(__DIR__ . '/../routes/hr.php'); + + Gate::policy(AttendanceRecord::class, AttendancePolicy::class); + Gate::policy(Department::class, DepartmentPolicy::class); + Gate::policy(Employee::class, EmployeePolicy::class); + Gate::policy(EmployeeLoan::class, LoanPolicy::class); + Gate::policy(EmployeeOnboarding::class, EmployeeOnboardingPolicy::class); + Gate::policy(ExpenseClaim::class, ExpenseClaimPolicy::class); + Gate::policy(ExpenseClaimItem::class, ExpenseClaimPolicy::class); + Gate::policy(JobApplication::class, RecruitmentPolicy::class); + Gate::policy(JobPosition::class, RecruitmentPolicy::class); + Gate::policy(LeaveBalance::class, LeavePolicy::class); + Gate::policy(LeaveRequest::class, LeaveRequestPolicy::class); + Gate::policy(LeaveType::class, LeavePolicy::class); + Gate::policy(LoanRepayment::class, LoanPolicy::class); + Gate::policy(OnboardingChecklist::class, OnboardingPolicy::class); + Gate::policy(OnboardingTask::class, OnboardingPolicy::class); + Gate::policy(OnboardingProgress::class, OnboardingPolicy::class); + Gate::policy(OnboardingTemplate::class, OnboardingTemplatePolicy::class); + Gate::policy(PayrollRun::class, PayrollRunPolicy::class); + Gate::policy(Payslip::class, PayrollPolicy::class); + Gate::policy(PerformanceKpi::class, PerformanceReviewPolicy::class); + Gate::policy(ReviewRating::class, PerformanceReviewPolicy::class); + Gate::policy(PerformanceReview::class, PerformanceReviewPolicy::class); + Gate::policy(TrainingCourse::class, TrainingPolicy::class); + Gate::policy(TrainingEnrollment::class, TrainingPolicy::class); + Gate::policy(EmployeeCertification::class, TrainingPolicy::class); + Gate::policy(EmployeeTrainingRecord::class, TrainingPolicy::class); + Gate::policy(ShiftTemplate::class, ShiftPolicy::class); + Gate::policy(ShiftAssignment::class, ShiftPolicy::class); + Gate::policy(DisciplinaryCase::class, DisciplinaryPolicy::class); + Gate::policy(Grievance::class, DisciplinaryPolicy::class); + Gate::policy(Timesheet::class, TimesheetPolicy::class); + Gate::policy(TimesheetEntry::class, TimesheetPolicy::class); + Gate::policy(BenefitPlan::class, BenefitPolicy::class); + Gate::policy(EmployeeBenefit::class, BenefitPolicy::class); + Gate::policy(WorkSchedule::class, WorkSchedulePolicy::class); + Gate::policy(WorkScheduleShift::class, WorkSchedulePolicy::class); + Gate::policy(EmployeeSchedule::class, WorkSchedulePolicy::class); + Gate::policy(EmployeeDocument::class, EmployeeDocumentPolicy::class); + Gate::policy(SkillDefinition::class, EmployeeSkillPolicy::class); + Gate::policy(EmployeeSkill::class, EmployeeSkillPolicy::class); + Gate::policy(HrAnnouncement::class, HrAnnouncementPolicy::class); + Gate::policy(EmployeeExit::class, EmployeeExitPolicy::class); + Gate::policy(EmployeePositionChange::class, PositionChangePolicy::class); + Gate::policy(SalaryGrade::class, SalaryGradePolicy::class); + Gate::policy(OvertimeRequest::class, OvertimeRequestPolicy::class); + Gate::policy(EmployeeSurvey::class, EmployeeSurveyPolicy::class); + Gate::policy(FlexibleWorkArrangement::class, FlexibleWorkPolicy::class); + Gate::policy(JobOfferLetter::class, JobOfferPolicy::class); + Gate::policy(TrainingSession::class, TrainingSessionPolicy::class); + Gate::policy(CompetencyFramework::class, CompetencyFrameworkPolicy::class); + Gate::policy(Competency::class, CompetencyFrameworkPolicy::class); + Gate::policy(EmployeeGoal::class, EmployeeGoalPolicy::class); + Gate::policy(SuccessionPlan::class, SuccessionPlanPolicy::class); + Gate::policy(SuccessionCandidate::class, SuccessionPlanPolicy::class); + Gate::policy(MentorshipProgram::class, MentorshipProgramPolicy::class); + Gate::policy(InterviewSchedule::class, InterviewSchedulePolicy::class); + Gate::policy(EmployeeEmergencyContact::class, EmployeeEmergencyContactPolicy::class); + } +} \ No newline at end of file diff --git a/erp/app/Modules/HR/routes/hr.php b/erp/app/Modules/HR/routes/hr.php new file mode 100644 index 00000000000..bde215586e8 --- /dev/null +++ b/erp/app/Modules/HR/routes/hr.php @@ -0,0 +1,383 @@ +prefix('hr')->name('hr.')->group(function() { + Route::get('dashboard', [HRDashboardController::class, 'index'])->name('dashboard'); +}); + +Route::middleware(['web', 'auth', 'verified'])->prefix('hr')->name('hr.')->group(function () { + + // Departments — full CRUD + Route::resource('departments', DepartmentController::class); + + // Employees + Route::post('employees/{employee}/terminate', [EmployeeController::class, 'terminate']) + ->name('employees.terminate'); + Route::resource('employees', EmployeeController::class)->names([ + 'index' => 'employees.index', + 'create' => 'employees.create', + 'store' => 'employees.store', + 'show' => 'employees.show', + 'edit' => 'employees.edit', + 'update' => 'employees.update', + 'destroy' => 'employees.destroy', + ]); + + // Leave Types + Route::resource('leave-types', LeaveTypeController::class)->except(['show', 'create', 'edit']); + + // Leave Requests (new spec-compliant routes) + Route::post('leave-requests/{leaveRequest}/approve', [LeaveRequestController::class, 'approve']) + ->name('leave-requests.approve'); + Route::post('leave-requests/{leaveRequest}/reject', [LeaveRequestController::class, 'reject']) + ->name('leave-requests.reject'); + Route::post('leave-requests/{leaveRequest}/cancel', [LeaveRequestController::class, 'cancel']) + ->name('leave-requests.cancel'); + Route::resource('leave-requests', LeaveRequestController::class)->except(['edit', 'update']); + + // Leave Balances + Route::resource('leave-balances', LeaveBalanceController::class)->only(['index', 'update']); + + // Legacy leave routes (for backward compat with existing tests) + Route::get('leave', [LeaveRequestController::class, 'legacyIndex'])->name('leave.index'); + Route::post('leave', [LeaveRequestController::class, 'legacyStore'])->name('leave.store'); + Route::patch('leave/{leaveRequest}/approve', [LeaveRequestController::class, 'approve']) + ->name('leave.approve'); + Route::patch('leave/{leaveRequest}/reject', [LeaveRequestController::class, 'reject']) + ->name('leave.reject'); + + // Payroll Runs (new spec-compliant routes) + Route::post('payroll-runs/{payrollRun}/process', [PayrollRunController::class, 'process']) + ->name('payroll-runs.process'); + Route::resource('payroll-runs', PayrollRunController::class)->except(['edit', 'update']); + + // Payslips — PDF download + Route::get('payslips/{payslip}/pdf', [PayslipController::class, 'pdf'])->name('payslips.pdf'); + Route::get('payroll-runs/{payrollRun}/payslips', [PayslipController::class, 'index'])->name('payroll-runs.payslips.index'); + Route::get('payslips/{payslip}', [PayslipController::class, 'show'])->name('payslips.show'); + + // Payroll — custom actions BEFORE resource + Route::post('payroll/{payrollRun}/generate', [PayrollController::class, 'generate'])->name('payroll.generate'); + Route::post('payroll/{payrollRun}/approve', [PayrollController::class, 'approve'])->name('payroll.approve'); + Route::post('payroll/{payrollRun}/mark-paid', [PayrollController::class, 'markPaid'])->name('payroll.mark-paid'); + Route::patch('payroll/{payrollRun}/process', [PayrollController::class, 'process'])->name('payroll.process'); + Route::resource('payroll', PayrollController::class)->except(['edit', 'update']); + + // Onboarding Templates + Route::resource('onboarding-templates', OnboardingTemplateController::class)->except(['edit', 'update']); + + // Employee Onboardings (nested under employees) + Route::prefix('employees/{employee}')->name('employees.')->group(function () { + Route::resource('onboardings', EmployeeOnboardingController::class)->except(['edit', 'update']); + Route::post('onboardings/{onboarding}/complete', [EmployeeOnboardingController::class, 'complete'])->name('onboardings.complete'); + Route::post('onboardings/{onboarding}/tasks/{task}/complete', [EmployeeOnboardingController::class, 'completeTask'])->name('onboardings.tasks.complete'); + Route::post('onboardings/{onboarding}/tasks/{task}/uncomplete', [EmployeeOnboardingController::class, 'uncompleteTask'])->name('onboardings.tasks.uncomplete'); + }); + + // Performance Reviews + Route::post('performance-reviews/{performanceReview}/submit', [PerformanceReviewController::class, 'submit'])->name('performance-reviews.submit'); + Route::post('performance-reviews/{performanceReview}/acknowledge', [PerformanceReviewController::class, 'acknowledge'])->name('performance-reviews.acknowledge'); + Route::post('performance-reviews/{performanceReview}/kpis', [PerformanceReviewController::class, 'addKpi'])->name('performance-reviews.kpis.add'); + Route::patch('performance-reviews/{performanceReview}/kpis/{kpi}', [PerformanceReviewController::class, 'updateKpi'])->name('performance-reviews.kpis.update'); + Route::delete('performance-reviews/{performanceReview}/kpis/{kpi}', [PerformanceReviewController::class, 'removeKpi'])->name('performance-reviews.kpis.remove'); + Route::resource('performance-reviews', PerformanceReviewController::class)->except(['edit', 'update']); + + // Expense Claims — custom actions BEFORE resource + Route::post('expense-claims/{expenseClaim}/submit', [ExpenseClaimController::class, 'submit'])->name('expense-claims.submit'); + Route::post('expense-claims/{expenseClaim}/approve', [ExpenseClaimController::class, 'approve'])->name('expense-claims.approve'); + Route::post('expense-claims/{expenseClaim}/reject', [ExpenseClaimController::class, 'reject'])->name('expense-claims.reject'); + Route::post('expense-claims/{expenseClaim}/mark-paid', [ExpenseClaimController::class, 'markPaid'])->name('expense-claims.mark-paid'); + Route::post('expense-claims/{expenseClaim}/items', [ExpenseClaimController::class, 'addItem'])->name('expense-claims.items.add'); + Route::delete('expense-claims/{expenseClaim}/items/{item}', [ExpenseClaimController::class, 'removeItem'])->name('expense-claims.items.remove'); + Route::resource('expense-claims', ExpenseClaimController::class)->except(['edit', 'update']); + + // Training Courses — enroll BEFORE resource + Route::post('training-courses/{trainingCourse}/enroll', [TrainingCourseController::class, 'enroll'])->name('training-courses.enroll'); + Route::resource('training-courses', TrainingCourseController::class)->except(['edit', 'update']); + Route::resource('training-records', EmployeeTrainingRecordController::class)->except(['edit', 'update']); + + // Training Enrollments — complete/fail BEFORE resource + Route::post('training-enrollments/{trainingEnrollment}/complete', [TrainingEnrollmentController::class, 'complete'])->name('training-enrollments.complete'); + Route::post('training-enrollments/{trainingEnrollment}/fail', [TrainingEnrollmentController::class, 'fail'])->name('training-enrollments.fail'); + Route::resource('training-enrollments', TrainingEnrollmentController::class)->only(['index', 'show']); + + // Employee Certifications + Route::resource('employee-certifications', EmployeeCertificationController::class)->only(['index', 'store', 'destroy']); + + // Job Positions + Route::post('job-positions/{jobPosition}/publish', [JobPositionController::class, 'publish'])->name('job-positions.publish'); + Route::post('job-positions/{jobPosition}/close', [JobPositionController::class, 'close'])->name('job-positions.close'); + Route::resource('job-positions', JobPositionController::class)->except(['edit', 'update']); + + // Job Applications + Route::patch('job-applications/{jobApplication}/advance', [JobApplicationController::class, 'advance'])->name('job-applications.advance'); + Route::post('job-applications/{jobApplication}/advance', [JobApplicationController::class, 'advance'])->name('job-applications.advance.post'); + Route::post('job-applications/{jobApplication}/hire', [JobApplicationController::class, 'hire'])->name('job-applications.hire'); + Route::post('job-applications/{jobApplication}/reject', [JobApplicationController::class, 'reject'])->name('job-applications.reject'); + Route::resource('job-applications', JobApplicationController::class)->except(['edit', 'update']); + + // Attendance + Route::resource('attendance', AttendanceController::class)->except(['edit']); + + // Work Schedules + Route::resource('work-schedules', WorkScheduleController::class)->except(['edit', 'update']); + + // Shift Templates + Route::resource('shift-templates', ShiftTemplateController::class)->except(['edit', 'update']); + + // Shift Assignments — markStatus BEFORE resource + Route::patch('shift-assignments/{shiftAssignment}/status', [ShiftAssignmentController::class, 'markStatus'])->name('shift-assignments.status'); + Route::resource('shift-assignments', ShiftAssignmentController::class)->except(['edit', 'update', 'show']); + + // Onboarding Checklists + Route::resource('onboarding-checklists', OnboardingChecklistController::class)->except(['edit', 'update']); + + // Employee Onboardings (checklist-based) + Route::post('employee-onboardings/{employeeOnboarding}/tasks/{progress}/complete', [EmployeeOnboardingTrackingController::class, 'completeTask'])->name('employee-onboardings.tasks.complete'); + Route::post('employee-onboardings/{employeeOnboarding}/tasks/{progress}/skip', [EmployeeOnboardingTrackingController::class, 'skipTask'])->name('employee-onboardings.tasks.skip'); + Route::resource('employee-onboardings', EmployeeOnboardingTrackingController::class)->except(['edit', 'update']); + + // Employee Loans + Route::post('employee-loans/{employeeLoan}/approve', [EmployeeLoanController::class, 'approve'])->name('employee-loans.approve'); + Route::post('employee-loans/{employeeLoan}/cancel', [EmployeeLoanController::class, 'cancel'])->name('employee-loans.cancel'); + Route::post('employee-loans/{employeeLoan}/repayments', [EmployeeLoanController::class, 'addRepayment'])->name('employee-loans.repayments.add'); + Route::resource('employee-loans', EmployeeLoanController::class)->except(['edit', 'update']); + // Disciplinary Cases + Route::post('disciplinary-cases/{disciplinaryCase}/schedule-hearing', [DisciplinaryCaseController::class, 'scheduleHearing'])->name('disciplinary-cases.schedule-hearing'); + Route::post('disciplinary-cases/{disciplinaryCase}/resolve', [DisciplinaryCaseController::class, 'resolve'])->name('disciplinary-cases.resolve'); + Route::post('disciplinary-cases/{disciplinaryCase}/close', [DisciplinaryCaseController::class, 'close'])->name('disciplinary-cases.close'); + Route::resource('disciplinary-cases', DisciplinaryCaseController::class)->except(['edit', 'update']); + + // Grievances + Route::patch('grievances/{grievance}/assign', [GrievanceController::class, 'assign'])->name('grievances.assign'); + Route::post('grievances/{grievance}/resolve', [GrievanceController::class, 'resolve'])->name('grievances.resolve'); + Route::post('grievances/{grievance}/close', [GrievanceController::class, 'close'])->name('grievances.close'); + Route::resource('grievances', GrievanceController::class)->except(['edit', 'update']); +}); + + +// Timesheet Management — custom actions BEFORE resource +use App\Modules\HR\Http\Controllers\TimesheetController; +Route::middleware(['web', 'auth', 'verified'])->prefix('hr')->name('hr.')->group(function () { + Route::post('timesheets/{timesheet}/submit', [TimesheetController::class, 'submit'])->name('timesheets.submit'); + Route::post('timesheets/{timesheet}/approve', [TimesheetController::class, 'approve'])->name('timesheets.approve'); + Route::post('timesheets/{timesheet}/reject', [TimesheetController::class, 'reject'])->name('timesheets.reject'); + Route::post('timesheets/{timesheet}/entries', [TimesheetController::class, 'addEntry'])->name('timesheets.entries.store'); + Route::resource('timesheets', TimesheetController::class)->only(['index', 'create', 'store', 'show', 'destroy']); +}); + + +// Employee Benefits Administration — custom actions BEFORE resource +use App\Modules\HR\Http\Controllers\BenefitPlanController; +use App\Modules\HR\Http\Controllers\EmployeeBenefitController; +Route::middleware(['web', 'auth', 'verified'])->prefix('hr')->name('hr.')->group(function () { + Route::resource('benefit-plans', BenefitPlanController::class)->only(['index', 'store', 'show', 'destroy']); + + Route::post('employee-benefits/{employeeBenefit}/waive', [EmployeeBenefitController::class, 'waive'])->name('employee-benefits.waive'); + Route::post('employee-benefits/{employeeBenefit}/end', [EmployeeBenefitController::class, 'end'])->name('employee-benefits.end'); + Route::resource('employee-benefits', EmployeeBenefitController::class)->only(['index', 'store', 'destroy']); +}); + + +// Work Schedules & Shifts — custom actions BEFORE resource +use App\Modules\HR\Http\Controllers\EmployeeScheduleController; +Route::middleware(['web', 'auth', 'verified'])->prefix('hr')->name('hr.')->group(function () { + Route::post('work-schedules/{workSchedule}/shifts', [WorkScheduleController::class, 'addShift'])->name('work-schedules.shifts.store'); + Route::resource('employee-schedules', EmployeeScheduleController::class)->only(['index', 'store', 'destroy']); +}); + +// Employee Documents +use App\Modules\HR\Http\Controllers\EmployeeDocumentController; +Route::middleware(['web', 'auth', 'verified'])->prefix('hr')->name('hr.')->group(function () { + Route::post('employee-documents/{employeeDocument}/verify', [EmployeeDocumentController::class, 'verify'])->name('employee-documents.verify'); + Route::resource('employee-documents', EmployeeDocumentController::class)->only(['index', 'store', 'show', 'destroy']); +}); + +// Employee Skills +use App\Modules\HR\Http\Controllers\EmployeeSkillController; +use App\Modules\HR\Http\Controllers\SkillDefinitionController; +Route::middleware(['web', 'auth', 'verified'])->prefix('hr')->name('hr.')->group(function () { + Route::post('employee-skills/{employeeSkill}/verify', [EmployeeSkillController::class, 'verify'])->name('employee-skills.verify'); + Route::resource('employee-skills', EmployeeSkillController::class)->only(['index', 'store', 'show', 'destroy']); + Route::resource('skill-definitions', SkillDefinitionController::class)->only(['index', 'store', 'destroy']); +}); + +// HR Announcements +use App\Modules\HR\Http\Controllers\HrAnnouncementController; +Route::middleware(['web', 'auth', 'verified'])->prefix('hr')->name('hr.')->group(function () { + Route::post('announcements/{announcement}/publish', [HrAnnouncementController::class, 'publish'])->name('announcements.publish'); + Route::post('announcements/{announcement}/archive', [HrAnnouncementController::class, 'archive'])->name('announcements.archive'); + Route::resource('announcements', HrAnnouncementController::class)->only(['index', 'store', 'show', 'destroy']); +}); + +// Employee Exits +use App\Modules\HR\Http\Controllers\EmployeeExitController; +Route::middleware(['web', 'auth', 'verified'])->prefix('hr')->name('hr.')->group(function () { + Route::post('employee-exits/{employeeExit}/complete', [EmployeeExitController::class, 'complete'])->name('employee-exits.complete'); + Route::post('employee-exits/{employeeExit}/in-progress', [EmployeeExitController::class, 'markInProgress'])->name('employee-exits.in-progress'); + Route::resource('employee-exits', EmployeeExitController::class)->only(['index', 'store', 'show', 'destroy']); +}); + +// Employee Position Changes +use App\Modules\HR\Http\Controllers\EmployeePositionChangeController; +Route::middleware(['web', 'auth', 'verified'])->prefix('hr')->name('hr.')->group(function () { + Route::post('position-changes/{positionChange}/approve', [EmployeePositionChangeController::class, 'approve'])->name('position-changes.approve'); + Route::resource('position-changes', EmployeePositionChangeController::class)->only(['index', 'store', 'show', 'destroy']); +}); + +// Salary Structures +use App\Modules\HR\Http\Controllers\SalaryStructureController; +Route::middleware(['web', 'auth', 'verified'])->prefix('hr')->name('hr.')->group(function () { + Route::post('salary-structures/{salaryStructure}/rules', [SalaryStructureController::class, 'storeRule'])->name('salary-structures.rules.store'); + Route::delete('salary-structures/{salaryStructure}/rules/{rule}', [SalaryStructureController::class, 'destroyRule'])->name('salary-structures.rules.destroy'); + Route::resource('salary-structures', SalaryStructureController::class)->except(['create', 'edit']); +}); + +// Salary Grades +use App\Modules\HR\Http\Controllers\SalaryGradeController; +Route::middleware(['web', 'auth', 'verified'])->prefix('hr')->name('hr.')->group(function () { + Route::resource('salary-grades', SalaryGradeController::class)->except(['create', 'edit']); +}); + +// Overtime Requests +use App\Modules\HR\Http\Controllers\OvertimeRequestController; +Route::middleware(['web', 'auth', 'verified'])->prefix('hr')->name('hr.')->group(function () { + Route::post('overtime-requests/{overtimeRequest}/approve', [OvertimeRequestController::class, 'approve'])->name('overtime-requests.approve'); + Route::post('overtime-requests/{overtimeRequest}/reject', [OvertimeRequestController::class, 'reject'])->name('overtime-requests.reject'); + Route::post('overtime-requests/{overtimeRequest}/cancel', [OvertimeRequestController::class, 'cancel'])->name('overtime-requests.cancel'); + Route::resource('overtime-requests', OvertimeRequestController::class)->only(['index', 'store', 'show', 'destroy']); +}); + +// Employee Surveys +use App\Modules\HR\Http\Controllers\EmployeeSurveyController; +Route::middleware(['web', 'auth', 'verified'])->prefix('hr')->name('hr.')->group(function () { + Route::post('surveys/{survey}/publish', [EmployeeSurveyController::class, 'publish'])->name('surveys.publish'); + Route::post('surveys/{survey}/close', [EmployeeSurveyController::class, 'close'])->name('surveys.close'); + Route::post('surveys/{survey}/respond', [EmployeeSurveyController::class, 'respond'])->name('surveys.respond'); + Route::resource('surveys', EmployeeSurveyController::class)->only(['index', 'store', 'show', 'destroy']); +}); + + +// Flexible Work Arrangements +use App\Modules\HR\Http\Controllers\FlexibleWorkController; +Route::middleware(['web', 'auth', 'verified'])->prefix('hr')->name('hr.')->group(function () { + Route::post('flexible-work/{flexibleWork}/approve', [FlexibleWorkController::class, 'approve'])->name('flexible-work.approve'); + Route::post('flexible-work/{flexibleWork}/reject', [FlexibleWorkController::class, 'reject'])->name('flexible-work.reject'); + Route::resource('flexible-work', FlexibleWorkController::class)->only(['index', 'store', 'show', 'destroy']); +}); + +// Job Offer Letters +use App\Modules\HR\Http\Controllers\JobOfferController; +Route::middleware(['web', 'auth', 'verified'])->prefix('hr')->name('hr.')->group(function () { + Route::post('job-offers/{jobOffer}/send', [JobOfferController::class, 'send'])->name('job-offers.send'); + Route::post('job-offers/{jobOffer}/accept', [JobOfferController::class, 'accept'])->name('job-offers.accept'); + Route::post('job-offers/{jobOffer}/decline', [JobOfferController::class, 'decline'])->name('job-offers.decline'); + Route::resource('job-offers', JobOfferController::class)->only(['index', 'store', 'show', 'destroy']); +}); + +// Training Sessions +use App\Modules\HR\Http\Controllers\TrainingSessionController; +Route::middleware(['web', 'auth', 'verified'])->prefix('hr')->name('hr.')->group(function () { + Route::post('training-sessions/{training_session}/start', [TrainingSessionController::class, 'start'])->name('training-sessions.start'); + Route::post('training-sessions/{training_session}/complete', [TrainingSessionController::class, 'complete'])->name('training-sessions.complete'); + Route::post('training-sessions/{training_session}/cancel', [TrainingSessionController::class, 'cancel'])->name('training-sessions.cancel'); + Route::resource('training-sessions', TrainingSessionController::class); +}); + +// Competency Frameworks +use App\Modules\HR\Http\Controllers\CompetencyFrameworkController; +Route::middleware(['web', 'auth', 'verified'])->prefix('hr')->name('hr.')->group(function () { + Route::post('competency-frameworks/{competency_framework}/activate', [CompetencyFrameworkController::class, 'activate'])->name('competency-frameworks.activate'); + Route::post('competency-frameworks/{competency_framework}/archive', [CompetencyFrameworkController::class, 'archive'])->name('competency-frameworks.archive'); + Route::resource('competency-frameworks', CompetencyFrameworkController::class); +}); + +// Employee Goals +use App\Modules\HR\Http\Controllers\EmployeeGoalController; +Route::middleware(['web', 'auth', 'verified'])->prefix('hr')->name('hr.')->group(function () { + Route::post('employee-goals/{employee_goal}/complete', [EmployeeGoalController::class, 'complete'])->name('employee-goals.complete'); + Route::post('employee-goals/{employee_goal}/miss', [EmployeeGoalController::class, 'miss'])->name('employee-goals.miss'); + Route::post('employee-goals/{employee_goal}/cancel', [EmployeeGoalController::class, 'cancel'])->name('employee-goals.cancel'); + Route::post('employee-goals/{employee_goal}/update-progress', [EmployeeGoalController::class, 'updateProgress'])->name('employee-goals.update-progress'); + Route::resource('employee-goals', EmployeeGoalController::class); +}); + +// Succession Plans +use App\Modules\HR\Http\Controllers\SuccessionPlanController; +Route::middleware(['web', 'auth', 'verified'])->prefix('hr')->name('hr.')->group(function () { + Route::post('succession-plans/{succession_plan}/complete', [SuccessionPlanController::class, 'complete'])->name('succession-plans.complete'); + Route::post('succession-plans/{succession_plan}/deactivate', [SuccessionPlanController::class, 'deactivate'])->name('succession-plans.deactivate'); + Route::resource('succession-plans', SuccessionPlanController::class); +}); + +// Mentorship Programs +use App\Modules\HR\Http\Controllers\MentorshipProgramController; +Route::middleware(['web', 'auth', 'verified'])->prefix('hr')->name('hr.')->group(function () { + Route::post('mentorship-programs/{mentorship_program}/complete', [MentorshipProgramController::class, 'complete'])->name('mentorship-programs.complete'); + Route::post('mentorship-programs/{mentorship_program}/pause', [MentorshipProgramController::class, 'pause'])->name('mentorship-programs.pause'); + Route::post('mentorship-programs/{mentorship_program}/resume', [MentorshipProgramController::class, 'resume'])->name('mentorship-programs.resume'); + Route::post('mentorship-programs/{mentorship_program}/cancel', [MentorshipProgramController::class, 'cancel'])->name('mentorship-programs.cancel'); + Route::post('mentorship-programs/{mentorship_program}/log-session',[MentorshipProgramController::class, 'logSession'])->name('mentorship-programs.log-session'); + Route::resource('mentorship-programs', MentorshipProgramController::class); +}); + +// Interview Schedules +use App\Modules\HR\Http\Controllers\InterviewScheduleController; +Route::middleware(['web', 'auth', 'verified'])->prefix('hr')->name('hr.')->group(function () { + Route::post('interview-schedules/{interview_schedule}/confirm', [InterviewScheduleController::class, 'confirm'])->name('interview-schedules.confirm'); + Route::post('interview-schedules/{interview_schedule}/complete', [InterviewScheduleController::class, 'complete'])->name('interview-schedules.complete'); + Route::post('interview-schedules/{interview_schedule}/cancel', [InterviewScheduleController::class, 'cancel'])->name('interview-schedules.cancel'); + Route::post('interview-schedules/{interview_schedule}/no-show', [InterviewScheduleController::class, 'noShow'])->name('interview-schedules.no-show'); + Route::resource('interview-schedules', InterviewScheduleController::class); +}); + +// Employee Emergency Contacts +use App\Modules\HR\Http\Controllers\EmployeeEmergencyContactController; +Route::middleware(['web', 'auth', 'verified'])->prefix('hr')->name('hr.')->group(function () { + Route::post('employees/{employee}/emergency-contacts/{emergency_contact}/mark-primary', + [EmployeeEmergencyContactController::class, 'markPrimary'] + )->name('employees.emergency-contacts.mark-primary'); + Route::resource('employees.emergency-contacts', EmployeeEmergencyContactController::class) + ->shallow(); +}); + +// HR Reports +use App\Modules\HR\Http\Controllers\HRReportController; +Route::middleware(['web', 'auth', 'verified'])->prefix('hr/reports')->name('hr.reports.')->group(function () { + Route::get('headcount', [HRReportController::class, 'headcount'])->name('headcount'); + Route::get('leave-summary', [HRReportController::class, 'leaveSummary'])->name('leave-summary'); + Route::get('department-summary', [HRReportController::class, 'departmentSummary'])->name('department-summary'); + Route::get('employee-tenure', [HRReportController::class, 'employeeTenure'])->name('employee-tenure'); +}); diff --git a/erp/app/Modules/Helpdesk/Http/Controllers/HelpdeskDashboardController.php b/erp/app/Modules/Helpdesk/Http/Controllers/HelpdeskDashboardController.php new file mode 100644 index 00000000000..b32e804c1bc --- /dev/null +++ b/erp/app/Modules/Helpdesk/Http/Controllers/HelpdeskDashboardController.php @@ -0,0 +1,57 @@ +count(); + + $overdueCount = HelpdeskTicket::whereNotNull('sla_deadline') + ->where('sla_deadline', '<', now()) + ->whereNotIn('status', ['resolved', 'closed']) + ->count(); + + $avgResolutionHours = HelpdeskTicket::where('status', 'resolved') + ->whereNotNull('resolved_at') + ->select(DB::raw('AVG((julianday(resolved_at) - julianday(created_at)) * 24) as avg_hours')) + ->value('avg_hours'); + + $resolvedToday = HelpdeskTicket::where('status', 'resolved') + ->whereDate('resolved_at', today()) + ->count(); + + $byPriority = HelpdeskTicket::whereNotIn('status', ['resolved', 'closed']) + ->select('priority', DB::raw('count(*) as count')) + ->groupBy('priority') + ->pluck('count', 'priority'); + + $byStatus = HelpdeskTicket::select('status', DB::raw('count(*) as count')) + ->groupBy('status') + ->pluck('count', 'status'); + + $recentTickets = HelpdeskTicket::with(['team', 'assignee']) + ->orderByDesc('created_at') + ->limit(10) + ->get(); + + return Inertia::render('Helpdesk/Dashboard', [ + 'stats' => [ + 'openTickets' => $openTickets, + 'overdueCount' => $overdueCount, + 'avgResolutionHours' => round((float) $avgResolutionHours, 1), + 'resolvedToday' => $resolvedToday, + ], + 'byPriority' => $byPriority, + 'byStatus' => $byStatus, + 'recentTickets' => $recentTickets, + ]); + } +} diff --git a/erp/app/Modules/Helpdesk/Http/Controllers/HelpdeskTeamController.php b/erp/app/Modules/Helpdesk/Http/Controllers/HelpdeskTeamController.php new file mode 100644 index 00000000000..474d55d7908 --- /dev/null +++ b/erp/app/Modules/Helpdesk/Http/Controllers/HelpdeskTeamController.php @@ -0,0 +1,62 @@ +orderBy('name') + ->get(); + + return Inertia::render('Helpdesk/Teams/Index', [ + 'teams' => $teams, + ]); + } + + public function store(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'description' => 'nullable|string', + 'auto_assign' => 'boolean', + 'is_active' => 'boolean', + ]); + + HelpdeskTeam::create([ + ...$validated, + 'tenant_id' => auth()->user()->tenant_id, + ]); + + return redirect()->back()->with('success', 'Team created.'); + } + + public function update(Request $request, HelpdeskTeam $team): RedirectResponse + { + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'description' => 'nullable|string', + 'auto_assign' => 'boolean', + 'is_active' => 'boolean', + ]); + + $team->update($validated); + + return redirect()->back()->with('success', 'Team updated.'); + } + + public function destroy(HelpdeskTeam $team): RedirectResponse + { + $team->delete(); + + return redirect()->back()->with('success', 'Team deleted.'); + } +} diff --git a/erp/app/Modules/Helpdesk/Http/Controllers/HelpdeskTicketController.php b/erp/app/Modules/Helpdesk/Http/Controllers/HelpdeskTicketController.php new file mode 100644 index 00000000000..c2d236249a0 --- /dev/null +++ b/erp/app/Modules/Helpdesk/Http/Controllers/HelpdeskTicketController.php @@ -0,0 +1,166 @@ +when($request->status, fn ($q) => $q->where('status', $request->status)) + ->when($request->priority, fn ($q) => $q->where('priority', $request->priority)) + ->when($request->team_id, fn ($q) => $q->where('team_id', $request->team_id)) + ->when($request->assigned_to, fn ($q) => $q->where('assigned_to', $request->assigned_to)) + ->when($request->search, fn ($q) => $q->where(function ($q2) use ($request) { + $q2->where('subject', 'like', "%{$request->search}%") + ->orWhere('customer_name', 'like', "%{$request->search}%"); + })) + ->orderByDesc('created_at') + ->paginate(25) + ->withQueryString(); + + return Inertia::render('Helpdesk/Tickets/Index', [ + 'tickets' => $tickets, + 'filters' => $request->only(['status', 'priority', 'team_id', 'assigned_to', 'search']), + ]); + } + + public function create(): Response + { + return Inertia::render('Helpdesk/Tickets/Create', [ + 'teams' => HelpdeskTeam::where('is_active', true)->orderBy('name')->get(['id', 'name']), + 'users' => User::orderBy('name')->get(['id', 'name']), + ]); + } + + public function store(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'subject' => 'required|string|max:255', + 'description' => 'nullable|string', + 'type' => 'required|in:question,issue,feature_request,other', + 'priority' => 'required|in:low,medium,high,urgent', + 'team_id' => 'nullable|exists:helpdesk_teams,id', + 'assigned_to' => 'nullable|exists:users,id', + 'customer_name' => 'nullable|string|max:255', + 'customer_email' => 'nullable|email|max:255', + ]); + + $ticket = HelpdeskTicket::create([ + ...$validated, + 'tenant_id' => auth()->user()->tenant_id, + 'created_by' => auth()->id(), + 'status' => 'open', + ]); + + $ticket->ticket_number = $ticket->generateTicketNumber(); + $ticket->saveQuietly(); + + // Set SLA deadline + $slaPolicy = HelpdeskSlaPolicy::where('priority', $ticket->priority) + ->where('is_active', true) + ->first(); + + if ($slaPolicy) { + $ticket->sla_deadline = now()->addHours($slaPolicy->resolution_hours); + $ticket->saveQuietly(); + } + + return redirect()->route('helpdesk.tickets.show', $ticket)->with('success', 'Ticket created.'); + } + + public function show(HelpdeskTicket $ticket): Response + { + $ticket->load(['team', 'assignee', 'messages.author']); + + return Inertia::render('Helpdesk/Tickets/Show', [ + 'ticket' => $ticket, + 'teams' => HelpdeskTeam::where('is_active', true)->orderBy('name')->get(['id', 'name']), + 'users' => User::orderBy('name')->get(['id', 'name']), + ]); + } + + public function edit(HelpdeskTicket $ticket): Response + { + return Inertia::render('Helpdesk/Tickets/Edit', [ + 'ticket' => $ticket, + 'teams' => HelpdeskTeam::where('is_active', true)->orderBy('name')->get(['id', 'name']), + 'users' => User::orderBy('name')->get(['id', 'name']), + ]); + } + + public function update(Request $request, HelpdeskTicket $ticket): RedirectResponse + { + $validated = $request->validate([ + 'subject' => 'required|string|max:255', + 'description' => 'nullable|string', + 'type' => 'required|in:question,issue,feature_request,other', + 'priority' => 'required|in:low,medium,high,urgent', + 'status' => 'required|in:open,in_progress,pending,resolved,closed', + 'team_id' => 'nullable|exists:helpdesk_teams,id', + 'assigned_to' => 'nullable|exists:users,id', + 'customer_name' => 'nullable|string|max:255', + 'customer_email' => 'nullable|email|max:255', + ]); + + $ticket->update($validated); + + return redirect()->route('helpdesk.tickets.show', $ticket)->with('success', 'Ticket updated.'); + } + + public function destroy(HelpdeskTicket $ticket): RedirectResponse + { + $ticket->delete(); + + return redirect()->route('helpdesk.tickets.index')->with('success', 'Ticket deleted.'); + } + + public function resolve(HelpdeskTicket $ticket): RedirectResponse + { + $ticket->resolve(); + + return redirect()->back()->with('success', 'Ticket resolved.'); + } + + public function close(HelpdeskTicket $ticket): RedirectResponse + { + $ticket->close(); + + return redirect()->back()->with('success', 'Ticket closed.'); + } + + public function reopen(HelpdeskTicket $ticket): RedirectResponse + { + $ticket->reopen(); + + return redirect()->back()->with('success', 'Ticket reopened.'); + } + + public function reply(Request $request, HelpdeskTicket $ticket): RedirectResponse + { + $validated = $request->validate([ + 'body' => 'required|string', + 'is_internal' => 'boolean', + ]); + + $ticket->messages()->create([ + 'body' => $validated['body'], + 'is_internal' => $validated['is_internal'] ?? false, + 'author_id' => auth()->id(), + ]); + + $ticket->recordFirstResponse(); + + return redirect()->back()->with('success', 'Reply sent.'); + } +} diff --git a/erp/app/Modules/Helpdesk/Http/Controllers/SlaController.php b/erp/app/Modules/Helpdesk/Http/Controllers/SlaController.php new file mode 100644 index 00000000000..76e30abd5c9 --- /dev/null +++ b/erp/app/Modules/Helpdesk/Http/Controllers/SlaController.php @@ -0,0 +1,124 @@ +get(); + + return Inertia::render('Helpdesk/Sla/Policies', [ + 'policies' => $policies, + ]); + } + + public function storePolicy(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'priority' => 'required|in:low,medium,high,urgent', + 'response_hours' => 'required|numeric|min:0', + 'resolution_hours' => 'required|numeric|min:0', + ]); + + HelpdeskSlaPolicy::create([ + ...$validated, + 'tenant_id' => auth()->user()->tenant_id, + ]); + + return back()->with('success', 'SLA policy created.'); + } + + public function updatePolicy(Request $request, HelpdeskSlaPolicy $policy): RedirectResponse + { + $validated = $request->validate([ + 'name' => 'sometimes|string|max:255', + 'response_hours' => 'sometimes|numeric|min:0', + 'resolution_hours' => 'sometimes|numeric|min:0', + 'is_active' => 'sometimes|boolean', + ]); + + $policy->update($validated); + + return back()->with('success', 'SLA policy updated.'); + } + + public function escalations(Request $request): Response + { + $escalations = TicketEscalation::with(['ticket', 'escalatedTo']) + ->when(!$request->boolean('show_resolved'), fn ($q) => $q->whereNull('resolved_at')) + ->orderByDesc('escalated_at') + ->paginate(30) + ->withQueryString(); + + return Inertia::render('Helpdesk/Sla/Escalations', [ + 'escalations' => $escalations, + 'show_resolved' => $request->boolean('show_resolved'), + ]); + } + + public function checkBreaches(): RedirectResponse + { + $now = now(); + + $breachedTickets = HelpdeskTicket::whereNotNull('sla_deadline') + ->where('sla_deadline', '<', $now) + ->whereNotIn('status', ['resolved', 'closed']) + ->get(); + + $created = 0; + foreach ($breachedTickets as $ticket) { + $alreadyEscalated = TicketEscalation::where('ticket_id', $ticket->id) + ->where('escalation_type', 'resolution_breach') + ->exists(); + + if (!$alreadyEscalated) { + TicketEscalation::create([ + 'tenant_id' => $ticket->tenant_id, + 'ticket_id' => $ticket->id, + 'escalation_type' => 'resolution_breach', + 'escalated_at' => $now, + ]); + $created++; + } + } + + return back()->with('success', "SLA check complete. {$created} new escalations created."); + } + + public function escalate(Request $request, HelpdeskTicket $ticket): RedirectResponse + { + $validated = $request->validate([ + 'escalation_type' => 'required|in:response_breach,resolution_breach', + 'escalated_to_id' => 'nullable|exists:users,id', + 'notes' => 'nullable|string', + ]); + + TicketEscalation::create([ + ...$validated, + 'tenant_id' => auth()->user()->tenant_id, + 'ticket_id' => $ticket->id, + 'escalated_at' => now(), + ]); + + return back()->with('success', 'Ticket escalated.'); + } + + public function resolveEscalation(Request $request, TicketEscalation $escalation): RedirectResponse + { + $escalation->resolve($request->input('notes', '')); + + return back()->with('success', 'Escalation resolved.'); + } +} diff --git a/erp/app/Modules/Helpdesk/Models/HelpdeskMessage.php b/erp/app/Modules/Helpdesk/Models/HelpdeskMessage.php new file mode 100644 index 00000000000..13928bb310f --- /dev/null +++ b/erp/app/Modules/Helpdesk/Models/HelpdeskMessage.php @@ -0,0 +1,33 @@ + 'boolean', + ]; + + public function ticket(): BelongsTo + { + return $this->belongsTo(HelpdeskTicket::class, 'ticket_id'); + } + + public function author(): BelongsTo + { + return $this->belongsTo(User::class, 'author_id'); + } +} diff --git a/erp/app/Modules/Helpdesk/Models/HelpdeskSlaPolicy.php b/erp/app/Modules/Helpdesk/Models/HelpdeskSlaPolicy.php new file mode 100644 index 00000000000..e4166ae5c7d --- /dev/null +++ b/erp/app/Modules/Helpdesk/Models/HelpdeskSlaPolicy.php @@ -0,0 +1,28 @@ + 'integer', + 'resolution_hours' => 'integer', + 'is_active' => 'boolean', + ]; +} diff --git a/erp/app/Modules/Helpdesk/Models/HelpdeskTeam.php b/erp/app/Modules/Helpdesk/Models/HelpdeskTeam.php new file mode 100644 index 00000000000..a773709fd16 --- /dev/null +++ b/erp/app/Modules/Helpdesk/Models/HelpdeskTeam.php @@ -0,0 +1,39 @@ + 'boolean', + 'is_active' => 'boolean', + ]; + + public function tickets(): HasMany + { + return $this->hasMany(HelpdeskTicket::class, 'team_id'); + } + + public function members(): BelongsToMany + { + return $this->belongsToMany(User::class, 'helpdesk_team_user', 'helpdesk_team_id', 'user_id'); + } +} diff --git a/erp/app/Modules/Helpdesk/Models/HelpdeskTicket.php b/erp/app/Modules/Helpdesk/Models/HelpdeskTicket.php new file mode 100644 index 00000000000..194384b2bf9 --- /dev/null +++ b/erp/app/Modules/Helpdesk/Models/HelpdeskTicket.php @@ -0,0 +1,116 @@ + 'datetime', + 'first_response_at' => 'datetime', + 'resolved_at' => 'datetime', + 'closed_at' => 'datetime', + ]; + + public function team(): BelongsTo + { + return $this->belongsTo(HelpdeskTeam::class, 'team_id'); + } + + public function assignee(): BelongsTo + { + return $this->belongsTo(User::class, 'assigned_to'); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function messages(): HasMany + { + return $this->hasMany(HelpdeskMessage::class, 'ticket_id')->orderBy('created_at'); + } + + public function generateTicketNumber(): string + { + return 'HD-' . date('Y') . '-' . str_pad($this->id, 5, '0', STR_PAD_LEFT); + } + + public function resolve(): void + { + $this->status = 'resolved'; + $this->resolved_at = now(); + $this->save(); + } + + public function close(): void + { + $this->status = 'closed'; + $this->closed_at = now(); + $this->save(); + } + + public function reopen(): void + { + $this->status = 'open'; + $this->resolved_at = null; + $this->closed_at = null; + $this->save(); + } + + public function recordFirstResponse(): void + { + if ($this->first_response_at === null) { + $this->first_response_at = now(); + $this->save(); + } + } + + public function getIsOverdueAttribute(): bool + { + return $this->sla_deadline !== null + && now()->gt($this->sla_deadline) + && !in_array($this->status, ['resolved', 'closed']); + } + + public function getPriorityColorAttribute(): string + { + return match ($this->priority) { + 'low' => 'blue', + 'medium' => 'yellow', + 'high' => 'orange', + 'urgent' => 'red', + default => 'gray', + }; + } +} diff --git a/erp/app/Modules/Helpdesk/Models/TicketEscalation.php b/erp/app/Modules/Helpdesk/Models/TicketEscalation.php new file mode 100644 index 00000000000..2a5b28b7ae7 --- /dev/null +++ b/erp/app/Modules/Helpdesk/Models/TicketEscalation.php @@ -0,0 +1,54 @@ + 'datetime', + 'resolved_at' => 'datetime', + ]; + + public function ticket(): BelongsTo + { + return $this->belongsTo(HelpdeskTicket::class, 'ticket_id'); + } + + public function escalatedTo(): BelongsTo + { + return $this->belongsTo(User::class, 'escalated_to_id'); + } + + public function resolve(string $notes = ''): void + { + $this->resolved_at = now(); + if ($notes) { + $this->notes = $notes; + } + $this->save(); + } + + public function isResolved(): bool + { + return $this->resolved_at !== null; + } +} diff --git a/erp/app/Modules/Helpdesk/Policies/HelpdeskPolicy.php b/erp/app/Modules/Helpdesk/Policies/HelpdeskPolicy.php new file mode 100644 index 00000000000..f48869555c7 --- /dev/null +++ b/erp/app/Modules/Helpdesk/Policies/HelpdeskPolicy.php @@ -0,0 +1,33 @@ +can('finance.create'); + } + + public function update(User $user): bool + { + return $user->can('finance.create'); + } + + public function delete(User $user): bool + { + return $user->can('finance.delete'); + } +} diff --git a/erp/app/Modules/Helpdesk/Providers/HelpdeskServiceProvider.php b/erp/app/Modules/Helpdesk/Providers/HelpdeskServiceProvider.php new file mode 100644 index 00000000000..49d09e0dcf2 --- /dev/null +++ b/erp/app/Modules/Helpdesk/Providers/HelpdeskServiceProvider.php @@ -0,0 +1,23 @@ +loadRoutesFrom(__DIR__ . '/../routes/helpdesk.php'); + Gate::policy(HelpdeskTicket::class, HelpdeskPolicy::class); + Gate::policy(HelpdeskTeam::class, HelpdeskPolicy::class); + Gate::policy(HelpdeskSlaPolicy::class, HelpdeskPolicy::class); + } +} diff --git a/erp/app/Modules/Helpdesk/routes/helpdesk.php b/erp/app/Modules/Helpdesk/routes/helpdesk.php new file mode 100644 index 00000000000..fe8d21e8868 --- /dev/null +++ b/erp/app/Modules/Helpdesk/routes/helpdesk.php @@ -0,0 +1,29 @@ +prefix('helpdesk')->name('helpdesk.')->group(function () { + Route::get('dashboard', [HelpdeskDashboardController::class, 'index'])->name('dashboard'); + + // Ticket actions BEFORE resource + Route::post('tickets/{ticket}/resolve', [HelpdeskTicketController::class, 'resolve'])->name('tickets.resolve'); + Route::post('tickets/{ticket}/close', [HelpdeskTicketController::class, 'close'])->name('tickets.close'); + Route::post('tickets/{ticket}/reopen', [HelpdeskTicketController::class, 'reopen'])->name('tickets.reopen'); + Route::post('tickets/{ticket}/reply', [HelpdeskTicketController::class, 'reply'])->name('tickets.reply'); + Route::resource('tickets', HelpdeskTicketController::class); + + Route::resource('teams', HelpdeskTeamController::class)->except(['show', 'create', 'edit']); + + // SLA + Route::get('sla/policies', [SlaController::class, 'policies'])->name('sla.policies'); + Route::post('sla/policies', [SlaController::class, 'storePolicy'])->name('sla.policies.store'); + Route::patch('sla/policies/{policy}', [SlaController::class, 'updatePolicy'])->name('sla.policies.update'); + Route::get('sla/escalations', [SlaController::class, 'escalations'])->name('sla.escalations'); + Route::post('sla/check-breaches', [SlaController::class, 'checkBreaches'])->name('sla.check-breaches'); + Route::post('tickets/{ticket}/escalate', [SlaController::class, 'escalate'])->name('tickets.escalate'); + Route::post('escalations/{escalation}/resolve', [SlaController::class, 'resolveEscalation'])->name('escalations.resolve'); +}); diff --git a/erp/app/Modules/Inventory/Http/Controllers/AssetController.php b/erp/app/Modules/Inventory/Http/Controllers/AssetController.php new file mode 100644 index 00000000000..601bd101c04 --- /dev/null +++ b/erp/app/Modules/Inventory/Http/Controllers/AssetController.php @@ -0,0 +1,119 @@ +authorize('viewAny', Asset::class); + + $assets = Asset::withCount('maintenances') + ->with('assignedEmployee') + ->when($request->status, fn ($q) => $q->where('status', $request->status)) + ->orderBy('name') + ->paginate(15) + ->withQueryString(); + + return Inertia::render('Inventory/Assets/Index', [ + 'assets' => $assets, + 'filters' => $request->only(['status']), + ]); + } + + public function create(): Response + { + $this->authorize('create', Asset::class); + + $tenantId = auth()->user()->tenant_id; + + return Inertia::render('Inventory/Assets/Create', [ + 'employees' => Employee::where('tenant_id', $tenantId) + ->where('status', 'active') + ->orderBy('first_name') + ->get(['id', 'first_name', 'last_name']), + ]); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', Asset::class); + + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'asset_code' => ['nullable', 'string', 'max:100'], + 'category' => ['nullable', 'string', 'max:100'], + 'location' => ['nullable', 'string', 'max:255'], + 'purchase_date' => ['nullable', 'date'], + 'purchase_cost' => ['nullable', 'numeric', 'min:0'], + 'current_value' => ['nullable', 'numeric', 'min:0'], + 'serial_number' => ['nullable', 'string', 'max:100'], + 'status' => ['required', Rule::in(['active', 'inactive', 'disposed', 'under_maintenance'])], + 'notes' => ['nullable', 'string'], + ]); + + $asset = Asset::create([...$validated, 'tenant_id' => auth()->user()->tenant_id]); + + return redirect()->route('inventory.assets.show', $asset) + ->with('success', 'Asset created successfully.'); + } + + public function show(Asset $asset): Response + { + $this->authorize('view', $asset); + + $asset->load('assignedEmployee'); + $asset->setRelation('maintenances', $asset->maintenances()->latest('scheduled_date')->get()); + + return Inertia::render('Inventory/Assets/Show', [ + 'asset' => $asset->append('depreciation'), + 'employees' => Employee::where('tenant_id', auth()->user()->tenant_id) + ->where('status', 'active') + ->orderBy('first_name') + ->get(['id', 'first_name', 'last_name']), + ]); + } + + public function destroy(Asset $asset): RedirectResponse + { + $this->authorize('delete', $asset); + + $asset->delete(); + + return redirect()->route('inventory.assets.index') + ->with('success', 'Asset deleted.'); + } + + public function dispose(Asset $asset): RedirectResponse + { + $this->authorize('delete', $asset); + + $asset->dispose(); + + return redirect()->back() + ->with('success', 'Asset disposed.'); + } + + public function assign(Request $request, Asset $asset): RedirectResponse + { + $this->authorize('update', $asset); + + $validated = $request->validate([ + 'employee_id' => ['required', 'integer', Rule::exists('employees', 'id')], + ]); + + $asset->assignTo($validated['employee_id']); + + return redirect()->back() + ->with('success', 'Asset assigned.'); + } +} diff --git a/erp/app/Modules/Inventory/Http/Controllers/AssetMaintenanceController.php b/erp/app/Modules/Inventory/Http/Controllers/AssetMaintenanceController.php new file mode 100644 index 00000000000..4d05153f2fc --- /dev/null +++ b/erp/app/Modules/Inventory/Http/Controllers/AssetMaintenanceController.php @@ -0,0 +1,98 @@ +authorize('viewAny', AssetMaintenance::class); + + $maintenances = AssetMaintenance::with('asset') + ->when($request->asset_id, fn ($q) => $q->where('asset_id', $request->asset_id)) + ->orderBy('scheduled_date', 'desc') + ->paginate(15) + ->withQueryString(); + + return Inertia::render('Inventory/AssetMaintenances/Index', [ + 'maintenances' => $maintenances, + 'filters' => $request->only(['asset_id']), + ]); + } + + public function create(): Response + { + $this->authorize('create', AssetMaintenance::class); + + return Inertia::render('Inventory/AssetMaintenances/Create', [ + 'assets' => Asset::orderBy('name')->get(['id', 'name']), + ]); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', AssetMaintenance::class); + + $validated = $request->validate([ + 'asset_id' => ['required', Rule::exists('assets', 'id')], + 'scheduled_date' => ['required', 'date'], + 'type' => ['required', Rule::in(['routine', 'repair', 'inspection', 'calibration'])], + 'description' => ['nullable', 'string'], + 'cost' => ['nullable', 'numeric', 'min:0'], + 'performed_by' => ['nullable', 'string', 'max:255'], + ]); + + $maintenance = AssetMaintenance::create([...$validated, 'tenant_id' => auth()->user()->tenant_id]); + + return redirect()->route('inventory.asset-maintenances.show', $maintenance) + ->with('success', 'Maintenance scheduled successfully.'); + } + + public function show(AssetMaintenance $assetMaintenance): Response + { + $this->authorize('view', $assetMaintenance); + + $assetMaintenance->load('asset'); + + return Inertia::render('Inventory/AssetMaintenances/Show', [ + 'maintenance' => $assetMaintenance, + ]); + } + + public function destroy(AssetMaintenance $assetMaintenance): RedirectResponse + { + $this->authorize('delete', $assetMaintenance); + + $assetMaintenance->delete(); + + return redirect()->route('inventory.asset-maintenances.index') + ->with('success', 'Maintenance record deleted.'); + } + + public function complete(Request $request, AssetMaintenance $assetMaintenance): RedirectResponse + { + $this->authorize('update', $assetMaintenance); + + $validated = $request->validate([ + 'completed_date' => ['required', 'date'], + 'cost' => ['nullable', 'numeric', 'min:0'], + ]); + + $assetMaintenance->complete( + $validated['completed_date'], + isset($validated['cost']) ? (float) $validated['cost'] : null + ); + + return redirect()->back() + ->with('success', 'Maintenance completed.'); + } +} diff --git a/erp/app/Modules/Inventory/Http/Controllers/BackorderController.php b/erp/app/Modules/Inventory/Http/Controllers/BackorderController.php new file mode 100644 index 00000000000..cc6e46f6994 --- /dev/null +++ b/erp/app/Modules/Inventory/Http/Controllers/BackorderController.php @@ -0,0 +1,87 @@ +authorize('viewAny', Backorder::class); + + $backorders = Backorder::latest() + ->paginate(20) + ->withQueryString(); + + return Inertia::render('Inventory/Backorders/Index', [ + 'backorders' => $backorders, + ]); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', Backorder::class); + + $validated = $request->validate([ + 'product_id' => 'required|exists:products,id', + 'warehouse_id' => 'required|exists:warehouses,id', + 'customer_id' => 'nullable|exists:contacts,id', + 'quantity_ordered' => 'required|numeric|min:0.01', + 'expected_date' => 'nullable|date', + 'notes' => 'nullable|string', + ]); + + $backorder = Backorder::create([ + 'tenant_id' => auth()->user()->tenant_id, + ...$validated, + ]); + + return redirect()->route('inventory.backorders.show', $backorder); + } + + public function show(Backorder $backorder): Response + { + $this->authorize('view', $backorder); + + return Inertia::render('Inventory/Backorders/Show', [ + 'backorder' => $backorder, + ]); + } + + public function fulfill(Request $request, Backorder $backorder): RedirectResponse + { + $this->authorize('update', $backorder); + + $validated = $request->validate([ + 'quantity' => 'required|numeric|min:0.01', + ]); + + $backorder->fulfill((float) $validated['quantity']); + + return back()->with('success', 'Backorder fulfilled.'); + } + + public function cancel(Backorder $backorder): RedirectResponse + { + $this->authorize('update', $backorder); + + $backorder->cancel(); + + return back()->with('success', 'Backorder cancelled.'); + } + + public function destroy(Backorder $backorder): RedirectResponse + { + $this->authorize('delete', $backorder); + + $backorder->delete(); + + return redirect()->route('inventory.backorders.index'); + } +} diff --git a/erp/app/Modules/Inventory/Http/Controllers/CategoryController.php b/erp/app/Modules/Inventory/Http/Controllers/CategoryController.php new file mode 100644 index 00000000000..feac2de64dd --- /dev/null +++ b/erp/app/Modules/Inventory/Http/Controllers/CategoryController.php @@ -0,0 +1,63 @@ +whereNull('parent_id') + ->withCount('products') + ->orderBy('name') + ->get(); + + return Inertia::render('Inventory/Categories/Index', [ + 'categories' => $categories, + 'breadcrumbs' => [ + ['label' => 'Inventory'], + ['label' => 'Categories', 'href' => route('inventory.categories.index')], + ], + ]); + } + + public function store(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'parent_id' => ['nullable', 'integer', 'exists:categories,id'], + 'description' => ['nullable', 'string'], + ]); + + Category::create([...$validated, 'tenant_id' => auth()->user()->tenant_id]); + + return back()->with('success', 'Category created.'); + } + + public function update(Request $request, Category $category): RedirectResponse + { + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'parent_id' => ['nullable', 'integer', 'exists:categories,id'], + 'description' => ['nullable', 'string'], + ]); + + $category->update($validated); + + return back()->with('success', 'Category updated.'); + } + + public function destroy(Category $category): RedirectResponse + { + $category->delete(); + + return back()->with('success', 'Category deleted.'); + } +} diff --git a/erp/app/Modules/Inventory/Http/Controllers/CostingController.php b/erp/app/Modules/Inventory/Http/Controllers/CostingController.php new file mode 100644 index 00000000000..2dfa34bac3c --- /dev/null +++ b/erp/app/Modules/Inventory/Http/Controllers/CostingController.php @@ -0,0 +1,101 @@ +authorize('viewAny', CostingLayer::class); + + $products = Product::withCount(['costingLayers' => fn ($q) => $q->where('quantity_remaining', '>', 0)]) + ->orderBy('name') + ->paginate(20); + + $filters = $request->only(['product_id']); + + return Inertia::render('Inventory/Costing/Index', compact('products', 'filters')); + } + + public function layers(Request $request, Product $product): Response + { + $this->authorize('view', CostingLayer::class); + + $layers = CostingLayer::forProduct($product->id) + ->withRemaining() + ->fifo() + ->paginate(20); + + return Inertia::render('Inventory/Costing/Layers', compact('product', 'layers')); + } + + public function addLayer(Request $request): RedirectResponse + { + $this->authorize('create', CostingLayer::class); + + $data = $request->validate([ + 'product_id' => 'required|exists:products,id', + 'costing_method' => 'required|in:fifo,avco', + 'quantity' => 'required|numeric|min:0.0001', + 'unit_cost' => 'required|numeric|min:0', + 'received_at' => 'nullable|date', + 'reference_type' => 'nullable|string|max:50', + ]); + + CostingLayer::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'product_id' => $data['product_id'], + 'costing_method' => $data['costing_method'], + 'quantity_received' => $data['quantity'], + 'quantity_remaining' => $data['quantity'], + 'unit_cost' => $data['unit_cost'], + 'received_at' => $data['received_at'] ?? now(), + 'reference_type' => $data['reference_type'] ?? null, + ]); + + return back()->with('success', 'Costing layer added.'); + } + + public function snapshot(Request $request): RedirectResponse + { + $this->authorize('create', CostingLayer::class); + + $data = $request->validate([ + 'product_id' => 'required|exists:products,id', + ]); + + ProductCostSnapshot::takeSnapshot(auth()->user()->tenant_id, $data['product_id']); + + return back()->with('success', 'Snapshot taken.'); + } + + public function report(Request $request): Response + { + $this->authorize('viewAny', CostingLayer::class); + + /** @var \App\Models\User $user */ + $user = auth()->user(); + + $products = Product::withCount(['costingLayers' => fn ($q) => $q->where('quantity_remaining', '>', 0)]) + ->orderBy('name') + ->limit(50) + ->get(); + + $rows = $products->map(fn ($product) => [ + 'product' => $product, + 'average_cost' => CostingLayer::getAverageCost($user->tenant_id, $product->id), + 'layers_count' => $product->costing_layers_count, + ])->all(); + + return Inertia::render('Inventory/Costing/Report', compact('rows')); + } +} diff --git a/erp/app/Modules/Inventory/Http/Controllers/CustomerDiscountController.php b/erp/app/Modules/Inventory/Http/Controllers/CustomerDiscountController.php new file mode 100644 index 00000000000..954e3223f9c --- /dev/null +++ b/erp/app/Modules/Inventory/Http/Controllers/CustomerDiscountController.php @@ -0,0 +1,57 @@ +authorize('viewAny', CustomerDiscount::class); + + $discounts = CustomerDiscount::latest() + ->paginate(20) + ->withQueryString(); + + return Inertia::render('Inventory/CustomerDiscounts/Index', [ + 'discounts' => $discounts, + ]); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', CustomerDiscount::class); + + $validated = $request->validate([ + 'discount_type' => 'required|in:percentage,fixed', + 'discount_value' => 'required|numeric|min:0', + 'applies_to' => 'nullable|in:all,category,product', + 'applies_to_id' => 'nullable|integer', + 'valid_from' => 'nullable|date', + 'valid_to' => 'nullable|date', + 'customer_id' => 'nullable|integer', + ]); + + $validated['tenant_id'] = app('tenant')->id; + $validated['applies_to'] = $validated['applies_to'] ?? 'all'; + + CustomerDiscount::create($validated); + + return back(); + } + + public function destroy(CustomerDiscount $customerDiscount): RedirectResponse + { + $this->authorize('delete', $customerDiscount); + + $customerDiscount->delete(); + + return back(); + } +} diff --git a/erp/app/Modules/Inventory/Http/Controllers/CycleCountController.php b/erp/app/Modules/Inventory/Http/Controllers/CycleCountController.php new file mode 100644 index 00000000000..75f5766f644 --- /dev/null +++ b/erp/app/Modules/Inventory/Http/Controllers/CycleCountController.php @@ -0,0 +1,147 @@ +authorize('viewAny', CycleCount::class); + + $query = CycleCount::with('warehouse')->orderByDesc('created_at'); + + if ($request->filled('status')) { + $query->where('status', $request->status); + } + + $cycleCounts = $query->paginate(20); + + return Inertia::render('Inventory/CycleCounts/Index', compact('cycleCounts')); + } + + public function create(): Response + { + $this->authorize('create', CycleCount::class); + + $warehouses = Warehouse::orderBy('name')->get(['id', 'name']); + $products = Product::orderBy('name')->get(['id', 'name', 'sku']); + + return Inertia::render('Inventory/CycleCounts/Create', compact('warehouses', 'products')); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', CycleCount::class); + + $validated = $request->validate([ + 'warehouse_id' => 'required|exists:warehouses,id', + 'count_date' => 'required|date', + 'notes' => 'nullable|string', + 'products' => 'required|array|min:1', + 'products.*' => 'exists:products,id', + ]); + + $cc = CycleCount::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'warehouse_id' => $validated['warehouse_id'], + 'count_number' => CycleCount::generateCountNumber(), + 'count_date' => $validated['count_date'], + 'notes' => $validated['notes'] ?? null, + 'created_by' => auth()->id(), + ]); + + foreach ($validated['products'] as $productId) { + $stockLevel = StockLevel::where('product_id', $productId) + ->where('warehouse_id', $validated['warehouse_id']) + ->first(); + + CycleCountItem::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'cycle_count_id' => $cc->id, + 'product_id' => $productId, + 'system_qty' => $stockLevel?->quantity ?? 0, + ]); + } + + return redirect()->route('inventory.cycle-counts.show', $cc) + ->with('success', 'Cycle count created.'); + } + + public function show(CycleCount $cycleCount): Response + { + $this->authorize('view', $cycleCount); + + $cycleCount->load(['warehouse', 'items.product']); + + return Inertia::render('Inventory/CycleCounts/Show', compact('cycleCount')); + } + + public function updateCounts(Request $request, CycleCount $cycleCount): RedirectResponse + { + $this->authorize('update', $cycleCount); + + $validated = $request->validate([ + 'items' => 'required|array', + 'items.*.id' => 'required|exists:cycle_count_items,id', + 'items.*.counted_qty' => 'required|numeric|min:0', + ]); + + foreach ($validated['items'] as $itemData) { + $item = CycleCountItem::find($itemData['id']); + if ($item && $item->cycle_count_id === $cycleCount->id) { + $item->counted_qty = $itemData['counted_qty']; + $item->save(); + } + } + + return back()->with('success', 'Counts updated.'); + } + + public function start(CycleCount $cycleCount): RedirectResponse + { + $this->authorize('update', $cycleCount); + + $cycleCount->start(); + + return back()->with('success', 'Cycle count started.'); + } + + public function complete(CycleCount $cycleCount): RedirectResponse + { + $this->authorize('update', $cycleCount); + + $cycleCount->complete(); + + return back()->with('success', 'Cycle count completed.'); + } + + public function cancel(CycleCount $cycleCount): RedirectResponse + { + $this->authorize('update', $cycleCount); + + $cycleCount->cancel(); + + return back()->with('success', 'Cycle count cancelled.'); + } + + public function destroy(CycleCount $cycleCount): RedirectResponse + { + $this->authorize('delete', $cycleCount); + + $cycleCount->delete(); + + return redirect()->route('inventory.cycle-counts.index') + ->with('success', 'Cycle count deleted.'); + } +} diff --git a/erp/app/Modules/Inventory/Http/Controllers/DemandForecastController.php b/erp/app/Modules/Inventory/Http/Controllers/DemandForecastController.php new file mode 100644 index 00000000000..96419aa7fcc --- /dev/null +++ b/erp/app/Modules/Inventory/Http/Controllers/DemandForecastController.php @@ -0,0 +1,147 @@ +authorize('viewAny', DemandForecast::class); + + $query = DemandForecast::with('product') + ->where('tenant_id', auth()->user()->tenant_id); + + if ($request->filled('product_id')) { + $query->where('product_id', $request->product_id); + } + + $forecasts = $query->orderByDesc('forecast_date')->paginate(20); + $filters = $request->only('product_id'); + + return Inertia::render('Inventory/DemandForecasts/Index', compact('forecasts', 'filters')); + } + + public function create(): Response + { + $this->authorize('create', DemandForecast::class); + + $products = Product::where('tenant_id', auth()->user()->tenant_id) + ->orderBy('name') + ->get(['id', 'name', 'sku']); + + return Inertia::render('Inventory/DemandForecasts/Create', compact('products')); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', DemandForecast::class); + + $data = $request->validate([ + 'product_id' => ['required', 'exists:products,id'], + 'forecast_date' => ['required', 'date'], + 'forecasted_quantity' => ['required', 'numeric', 'min:0'], + 'method' => ['required', 'in:moving_avg,weighted_avg,manual'], + 'confidence_score' => ['nullable', 'numeric', 'min:0', 'max:100'], + 'notes' => ['nullable', 'string'], + 'warehouse_id' => ['nullable', 'exists:warehouses,id'], + ]); + + DemandForecast::create([ + 'tenant_id' => auth()->user()->tenant_id, + ...$data, + ]); + + return redirect()->route('inventory.demand-forecasts.index') + ->with('success', 'Demand forecast created.'); + } + + public function show(DemandForecast $demandForecast): Response + { + $this->authorize('view', $demandForecast); + $demandForecast->load('product'); + + return Inertia::render('Inventory/DemandForecasts/Show', [ + 'forecast' => $demandForecast->append('accuracy'), + ]); + } + + public function update(Request $request, DemandForecast $demandForecast): RedirectResponse + { + $this->authorize('create', DemandForecast::class); + + $data = $request->validate([ + 'actual_quantity' => ['required', 'numeric', 'min:0'], + 'notes' => ['nullable', 'string'], + ]); + + $demandForecast->update($data); + + return back()->with('success', 'Forecast updated.'); + } + + public function destroy(DemandForecast $demandForecast): RedirectResponse + { + $this->authorize('delete', $demandForecast); + $demandForecast->delete(); + + return redirect()->route('inventory.demand-forecasts.index') + ->with('success', 'Forecast deleted.'); + } + + public function generateForecast(Request $request): RedirectResponse + { + $this->authorize('create', DemandForecast::class); + + $data = $request->validate([ + 'product_id' => ['required', 'exists:products,id'], + 'periods' => ['nullable', 'integer', 'min:1', 'max:12'], + 'forecast_date' => ['required', 'date'], + ]); + + $tenantId = auth()->user()->tenant_id; + $productId = (int) $data['product_id']; + $periods = isset($data['periods']) ? (int) $data['periods'] : 3; + + $quantity = DemandForecast::generateMovingAvg($tenantId, $productId, $periods); + + DemandForecast::create([ + 'tenant_id' => $tenantId, + 'product_id' => $productId, + 'forecast_date' => $data['forecast_date'], + 'forecasted_quantity' => $quantity, + 'method' => 'moving_avg', + ]); + + return back()->with('success', 'Forecast generated using moving average.'); + } + + public function alerts(Request $request): Response + { + $this->authorize('viewAny', DemandForecast::class); + + $alerts = ForecastAlert::unresolved() + ->where('tenant_id', auth()->user()->tenant_id) + ->with('product') + ->orderByDesc('created_at') + ->paginate(20); + + return Inertia::render('Inventory/DemandForecasts/Alerts', compact('alerts')); + } + + public function resolveAlert(Request $request, ForecastAlert $alert): RedirectResponse + { + $this->authorize('create', DemandForecast::class); + $alert->resolve(); + + return back()->with('success', 'Alert resolved.'); + } +} diff --git a/erp/app/Modules/Inventory/Http/Controllers/GoodsReceiptController.php b/erp/app/Modules/Inventory/Http/Controllers/GoodsReceiptController.php new file mode 100644 index 00000000000..5dbffa4ea13 --- /dev/null +++ b/erp/app/Modules/Inventory/Http/Controllers/GoodsReceiptController.php @@ -0,0 +1,119 @@ +authorize('viewAny', GoodsReceipt::class); + + $receipts = GoodsReceipt::with('items') + ->orderByDesc('receipt_date') + ->paginate(20); + + return Inertia::render('Inventory/GoodsReceipts/Index', compact('receipts')); + } + + public function create(): Response + { + $this->authorize('create', GoodsReceipt::class); + + return Inertia::render('Inventory/GoodsReceipts/Create'); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', GoodsReceipt::class); + + $data = $request->validate([ + 'supplier_name' => 'required|string|max:255', + 'receipt_date' => 'required|date', + 'supplier_reference' => 'nullable|string|max:255', + 'notes' => 'nullable|string', + 'warehouse_id' => 'nullable|exists:warehouses,id', + ]); + + $data['tenant_id'] = app('tenant')->id; + $data['created_by'] = auth()->id(); + + GoodsReceipt::create($data); + + return redirect()->route('inventory.goods-receipts.index'); + } + + public function show(GoodsReceipt $goodsReceipt): Response + { + $this->authorize('view', $goodsReceipt); + + $goodsReceipt->load('items.product'); + + return Inertia::render('Inventory/GoodsReceipts/Show', compact('goodsReceipt')); + } + + public function edit(GoodsReceipt $goodsReceipt): Response + { + $this->authorize('update', $goodsReceipt); + + return Inertia::render('Inventory/GoodsReceipts/Edit', compact('goodsReceipt')); + } + + public function update(Request $request, GoodsReceipt $goodsReceipt): RedirectResponse + { + $this->authorize('update', $goodsReceipt); + + $data = $request->validate([ + 'supplier_name' => 'required|string|max:255', + 'receipt_date' => 'required|date', + 'supplier_reference' => 'nullable|string|max:255', + 'notes' => 'nullable|string', + ]); + + $goodsReceipt->update($data); + + return redirect()->route('inventory.goods-receipts.index'); + } + + public function destroy(GoodsReceipt $goodsReceipt): RedirectResponse + { + $this->authorize('delete', $goodsReceipt); + + $goodsReceipt->delete(); + + return redirect()->route('inventory.goods-receipts.index'); + } + + public function confirm(GoodsReceipt $goodsReceipt): RedirectResponse + { + $this->authorize('confirm', $goodsReceipt); + + $goodsReceipt->confirm(auth()->id()); + + return redirect()->route('inventory.goods-receipts.index'); + } + + public function post(GoodsReceipt $goodsReceipt): RedirectResponse + { + $this->authorize('post', $goodsReceipt); + + $goodsReceipt->post(); + + return redirect()->route('inventory.goods-receipts.index'); + } + + public function reject(GoodsReceipt $goodsReceipt): RedirectResponse + { + $this->authorize('reject', $goodsReceipt); + + $goodsReceipt->reject(); + + return redirect()->route('inventory.goods-receipts.index'); + } +} diff --git a/erp/app/Modules/Inventory/Http/Controllers/InventoryDashboardController.php b/erp/app/Modules/Inventory/Http/Controllers/InventoryDashboardController.php new file mode 100644 index 00000000000..dd7be114093 --- /dev/null +++ b/erp/app/Modules/Inventory/Http/Controllers/InventoryDashboardController.php @@ -0,0 +1,69 @@ +user()->tenant_id; + + $totalProducts = Product::where('tenant_id', $tenantId)->count(); + $lowStockCount = Product::where('tenant_id', $tenantId)->with('stockLevels') + ->get()->filter(fn($p) => $p->stockLevels->sum('quantity') < $p->reorder_point)->count(); + $outOfStockCount = Product::where('tenant_id', $tenantId)->with('stockLevels') + ->get()->filter(fn($p) => $p->stockLevels->sum('quantity') <= 0)->count(); + $pendingPoCount = PurchaseOrder::where('tenant_id', $tenantId) + ->whereIn('status', ['draft', 'submitted', 'approved'])->count(); + $totalWarehouses = Warehouse::where('tenant_id', $tenantId)->count(); + $activeSuppliers = Supplier::where('tenant_id', $tenantId)->where('is_active', true)->count(); + + $lowStockItems = Product::where('tenant_id', $tenantId) + ->with('stockLevels') + ->get() + ->map(fn($p) => [ + 'id' => $p->id, + 'name' => $p->name, + 'sku' => $p->sku, + 'quantity' => round($p->stockLevels->sum('quantity'), 2), + 'reorder_point' => $p->reorder_point, + ]) + ->filter(fn($p) => $p['quantity'] < $p['reorder_point']) + ->sortBy('quantity') + ->take(10) + ->values(); + + $recentPos = PurchaseOrder::where('tenant_id', $tenantId) + ->with('supplier') + ->latest() + ->take(5) + ->get() + ->map(fn($po) => [ + 'id' => $po->id, + 'status' => $po->status, + 'supplier' => $po->supplier?->name, + 'created_at' => $po->created_at?->format('M d, Y'), + ]); + + $movements7d = StockMovement::where('tenant_id', $tenantId) + ->where('created_at', '>=', now()->subDays(7)) + ->selectRaw('DATE(created_at) as date, type, SUM(quantity) as total') + ->groupBy('date', 'type') + ->orderBy('date') + ->get(); + + return Inertia::render('Inventory/Dashboard', compact( + 'totalProducts', 'lowStockCount', 'outOfStockCount', 'pendingPoCount', + 'totalWarehouses', 'activeSuppliers', 'lowStockItems', 'recentPos', 'movements7d' + )); + } +} diff --git a/erp/app/Modules/Inventory/Http/Controllers/InventoryReportController.php b/erp/app/Modules/Inventory/Http/Controllers/InventoryReportController.php new file mode 100644 index 00000000000..ff1b5911083 --- /dev/null +++ b/erp/app/Modules/Inventory/Http/Controllers/InventoryReportController.php @@ -0,0 +1,239 @@ +user()->tenant_id; + $warehouseId = $request->get('warehouse_id'); + + $query = WarehouseStock::with(['product', 'warehouse']) + ->where('tenant_id', $tenantId) + ->where('quantity', '>', 0); + + if ($warehouseId) { + $query->where('warehouse_id', $warehouseId); + } + + $stocks = $query->get(); + + $rows = $stocks->map(function ($ws) { + $unitCost = (float) ($ws->product->cost_price ?? 0); + $qty = (float) $ws->quantity; + $totalValue = $qty * $unitCost; + return [ + 'product_id' => $ws->product_id, + 'product_name' => $ws->product->name ?? '', + 'sku' => $ws->product->sku ?? '', + 'warehouse_id' => $ws->warehouse_id, + 'warehouse_name' => $ws->warehouse->name ?? '', + 'quantity' => $qty, + 'unit_cost' => $unitCost, + 'total_value' => $totalValue, + ]; + })->sortByDesc('total_value')->values(); + + $byWarehouse = $rows->groupBy('warehouse_name')->map(function ($group, $wName) { + return [ + 'warehouse_name' => $wName, + 'product_count' => $group->count(), + 'total_qty' => $group->sum('quantity'), + 'total_value' => $group->sum('total_value'), + ]; + })->values(); + + $summary = [ + 'total_products' => $rows->pluck('product_id')->unique()->count(), + 'total_qty' => $rows->sum('quantity'), + 'total_value' => $rows->sum('total_value'), + 'by_warehouse' => $byWarehouse, + ]; + + $warehouses = Warehouse::where('tenant_id', $tenantId)->where('is_active', true)->orderBy('name')->get(['id', 'name']); + + return Inertia::render('Inventory/Reports/StockValuation', [ + 'rows' => $rows, + 'summary' => $summary, + 'warehouses' => $warehouses, + 'filters' => ['warehouse_id' => $warehouseId], + ]); + } + + public function stockMovement(Request $request): Response + { + $tenantId = auth()->user()->tenant_id; + $dateFrom = $request->get('date_from', Carbon::now()->startOfMonth()->toDateString()); + $dateTo = $request->get('date_to', Carbon::today()->toDateString()); + $productId = $request->get('product_id'); + $warehouseId = $request->get('warehouse_id'); + + $query = StockMovement::with(['product', 'warehouse']) + ->where('tenant_id', $tenantId) + ->whereBetween(DB::raw('DATE(created_at)'), [$dateFrom, $dateTo]); + + if ($productId) { + $query->where('product_id', $productId); + } + if ($warehouseId) { + $query->where('warehouse_id', $warehouseId); + } + + $movements = $query->orderBy('created_at', 'desc')->get(); + + $rows = $movements->map(fn($m) => [ + 'id' => $m->id, + 'product_id' => $m->product_id, + 'product_name' => $m->product->name ?? '', + 'sku' => $m->product->sku ?? '', + 'warehouse_id' => $m->warehouse_id, + 'warehouse_name' => $m->warehouse->name ?? '', + 'type' => $m->type, + 'quantity' => (float) $m->quantity, + 'reference' => $m->reference ?? '', + 'notes' => $m->notes ?? '', + 'created_at' => $m->created_at?->toDateTimeString() ?? '', + ]); + + $inTypes = ['in', 'purchase', 'return', 'adjustment_in', 'transfer_in', 'receipt']; + $outTypes = ['out', 'sale', 'adjustment_out', 'transfer_out', 'issue']; + + $totalIn = $rows->filter(fn($r) => in_array($r['type'], $inTypes))->sum('quantity'); + $totalOut = $rows->filter(fn($r) => in_array($r['type'], $outTypes))->sum('quantity'); + + $summary = [ + 'total_movements' => $rows->count(), + 'total_in' => $totalIn, + 'total_out' => $totalOut, + 'net_change' => $totalIn - $totalOut, + ]; + + $products = Product::where('tenant_id', $tenantId)->where('is_active', true)->orderBy('name')->get(['id', 'name', 'sku']); + $warehouses = Warehouse::where('tenant_id', $tenantId)->where('is_active', true)->orderBy('name')->get(['id', 'name']); + + return Inertia::render('Inventory/Reports/StockMovement', [ + 'rows' => $rows->values(), + 'summary' => $summary, + 'products' => $products, + 'warehouses' => $warehouses, + 'filters' => ['date_from' => $dateFrom, 'date_to' => $dateTo, 'product_id' => $productId, 'warehouse_id' => $warehouseId], + ]); + } + + public function lowStock(Request $request): Response + { + $tenantId = auth()->user()->tenant_id; + + // Get products with their reorder_point and actual stock via WarehouseStock + $stocks = WarehouseStock::with(['product']) + ->where('tenant_id', $tenantId) + ->whereNotNull('reorder_point') + ->get(); + + // Also check product-level reorder_point for products without warehouse-level setting + $productReorderMap = Product::where('tenant_id', $tenantId) + ->where('is_active', true) + ->whereNotNull('reorder_point') + ->get(['id', 'name', 'sku', 'reorder_point']) + ->keyBy('id'); + + // Aggregate stock by product across warehouses + $stockByProduct = $stocks->groupBy('product_id')->map(fn($group) => [ + 'total_qty' => $group->sum('quantity'), + 'reorder_point' => $group->max('reorder_point'), // warehouse-level + 'product' => $group->first()->product, + ]); + + $rows = collect(); + + foreach ($productReorderMap as $productId => $product) { + $stockInfo = $stockByProduct->get($productId); + $currentQty = $stockInfo ? (float) $stockInfo['total_qty'] : 0; + $minLevel = $stockInfo ? (float) $stockInfo['reorder_point'] : (float) $product->reorder_point; + + if ($minLevel <= 0) { + $minLevel = (float) $product->reorder_point; + } + + if ($currentQty <= $minLevel) { + $rows->push([ + 'product_id' => $product->id, + 'product_name' => $product->name, + 'sku' => $product->sku, + 'current_stock' => $currentQty, + 'min_level' => $minLevel, + 'shortage' => max(0, $minLevel - $currentQty), + ]); + } + } + + $rows = $rows->sortByDesc('shortage')->values(); + + return Inertia::render('Inventory/Reports/LowStock', [ + 'rows' => $rows, + 'summary' => ['products_at_risk' => $rows->count()], + ]); + } + + public function abcAnalysis(Request $request): Response + { + $tenantId = auth()->user()->tenant_id; + $since = Carbon::now()->subDays(90)->startOfDay(); + $outTypes = ['out', 'sale', 'adjustment_out', 'transfer_out', 'issue']; + + $movements = StockMovement::with(['product']) + ->where('tenant_id', $tenantId) + ->where('created_at', '>=', $since) + ->whereIn('type', $outTypes) + ->get(); + + // Group by product, sum qty * cost + $grouped = $movements->groupBy('product_id')->map(function ($group) { + $product = $group->first()->product; + $value = $group->sum(fn($m) => abs((float) $m->quantity) * (float) ($product->cost_price ?? 0)); + return [ + 'product_id' => $group->first()->product_id, + 'product_name' => $product->name ?? '', + 'sku' => $product->sku ?? '', + 'movement_value' => $value, + ]; + })->sortByDesc('movement_value')->values(); + + $totalValue = $grouped->sum('movement_value'); + $cumulative = 0; + $rows = $grouped->map(function ($row, $index) use ($totalValue, &$cumulative) { + $pct = $totalValue > 0 ? ($row['movement_value'] / $totalValue * 100) : 0; + $cumulative += $pct; + $bucket = $cumulative <= 80 ? 'A' : ($cumulative <= 95 ? 'B' : 'C'); + return array_merge($row, [ + 'rank' => $index + 1, + 'percentage' => round($pct, 2), + 'cumulative' => round($cumulative, 2), + 'bucket' => $bucket, + ]); + }); + + $bucketSummary = [ + 'A' => $rows->where('bucket', 'A')->count(), + 'B' => $rows->where('bucket', 'B')->count(), + 'C' => $rows->where('bucket', 'C')->count(), + ]; + + return Inertia::render('Inventory/Reports/AbcAnalysis', [ + 'rows' => $rows->values(), + 'total_value' => $totalValue, + 'bucket_summary' => $bucketSummary, + ]); + } +} diff --git a/erp/app/Modules/Inventory/Http/Controllers/LotNumberController.php b/erp/app/Modules/Inventory/Http/Controllers/LotNumberController.php new file mode 100644 index 00000000000..958ee68e9b3 --- /dev/null +++ b/erp/app/Modules/Inventory/Http/Controllers/LotNumberController.php @@ -0,0 +1,85 @@ +when($request->product_id, fn ($q) => $q->where('product_id', $request->product_id)) + ->when($request->warehouse_id, fn ($q) => $q->where('warehouse_id', $request->warehouse_id)) + ->when($request->status, fn ($q) => $q->where('status', $request->status)) + ->latest() + ->paginate(20) + ->withQueryString(); + + return Inertia::render('Inventory/LotNumbers/Index', [ + 'lots' => $lots, + 'products' => Product::orderBy('name')->get(['id', 'name', 'sku']), + 'warehouses' => Warehouse::orderBy('name')->get(['id', 'name']), + 'filters' => $request->only(['product_id', 'warehouse_id', 'status']), + ]); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', LotNumber::class); + + $validated = $request->validate([ + 'product_id' => 'required|exists:products,id', + 'warehouse_id' => 'required|exists:warehouses,id', + 'lot_number' => 'required|string|max:255', + 'manufacture_date' => 'nullable|date', + 'expiry_date' => 'nullable|date|after:manufacture_date', + 'quantity_received' => 'required|integer|min:1', + ]); + + $validated['tenant_id'] = auth()->user()->tenant_id; + $validated['quantity_remaining'] = $validated['quantity_received']; + + LotNumber::create($validated); + + return back()->with('success', 'Lot number created successfully.'); + } + + public function show(LotNumber $lotNumber): Response + { + $lotNumber->load(['product', 'warehouse', 'serialNumbers']); + + return Inertia::render('Inventory/LotNumbers/Show', [ + 'lot' => $lotNumber, + ]); + } + + public function quarantine(Request $request, LotNumber $lotNumber): RedirectResponse + { + $request->validate([ + 'notes' => 'nullable|string', + ]); + + $lotNumber->quarantine($request->notes); + + return back()->with('success', 'Lot quarantined.'); + } + + public function consume(Request $request, LotNumber $lotNumber): RedirectResponse + { + $request->validate([ + 'qty' => 'required|integer|min:1', + ]); + + $lotNumber->consume((int) $request->qty); + + return back()->with('success', 'Lot consumption recorded.'); + } +} diff --git a/erp/app/Modules/Inventory/Http/Controllers/MultiWarehouseController.php b/erp/app/Modules/Inventory/Http/Controllers/MultiWarehouseController.php new file mode 100644 index 00000000000..6477c1d449f --- /dev/null +++ b/erp/app/Modules/Inventory/Http/Controllers/MultiWarehouseController.php @@ -0,0 +1,44 @@ +user()->tenant_id; + + $warehouses = Warehouse::where('tenant_id', $tenantId) + ->withCount([ + 'warehouseStock as product_count' => fn($q) => $q->where('quantity', '>', 0), + ]) + ->orderBy('name') + ->get() + ->map(fn($w) => [ + 'id' => $w->id, + 'name' => $w->name, + 'address' => $w->address ?? null, + 'city' => $w->city ?? null, + 'country' => $w->country ?? null, + 'costing_method' => $w->costing_method ?? 'average', + 'is_active' => $w->is_active ?? true, + 'product_count' => $w->product_count, + ]); + + $summary = [ + 'total_warehouses' => $warehouses->count(), + 'active_warehouses' => $warehouses->where('is_active', true)->count(), + 'total_products' => WarehouseStock::where('tenant_id', $tenantId)->where('quantity', '>', 0)->distinct('product_id')->count('product_id'), + ]; + + return Inertia::render('Inventory/MultiWarehouse/Index', [ + 'warehouses' => $warehouses->values(), + 'summary' => $summary, + ]); + } +} diff --git a/erp/app/Modules/Inventory/Http/Controllers/PriceListController.php b/erp/app/Modules/Inventory/Http/Controllers/PriceListController.php new file mode 100644 index 00000000000..62094148485 --- /dev/null +++ b/erp/app/Modules/Inventory/Http/Controllers/PriceListController.php @@ -0,0 +1,99 @@ +authorize('viewAny', PriceList::class); + + $priceLists = PriceList::latest() + ->paginate(20) + ->withQueryString(); + + return Inertia::render('Inventory/PriceLists/Index', [ + 'priceLists' => $priceLists, + ]); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', PriceList::class); + + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'currency' => 'nullable|string|max:3', + 'is_active' => 'nullable|boolean', + 'is_default' => 'nullable|boolean', + 'valid_from' => 'nullable|date', + 'valid_to' => 'nullable|date', + 'notes' => 'nullable|string', + ]); + + $validated['tenant_id'] = app('tenant')->id; + $validated['currency'] = $validated['currency'] ?? 'USD'; + + PriceList::create($validated); + + return back(); + } + + public function show(PriceList $priceList): Response + { + $this->authorize('view', $priceList); + + $priceList->load('items.product'); + + return Inertia::render('Inventory/PriceLists/Show', [ + 'priceList' => $priceList, + 'products' => Product::where('is_active', true)->orderBy('name')->get(['id', 'name', 'sku']), + ]); + } + + public function addItem(Request $request, PriceList $priceList): RedirectResponse + { + $this->authorize('update', $priceList); + + $validated = $request->validate([ + 'product_id' => 'required|exists:products,id', + 'price' => 'required|numeric|min:0', + 'min_quantity' => 'nullable|numeric|min:1', + ]); + + $validated['tenant_id'] = app('tenant')->id; + $validated['price_list_id'] = $priceList->id; + $validated['min_quantity'] = $validated['min_quantity'] ?? 1; + + PriceListItem::create($validated); + + return back(); + } + + public function removeItem(PriceList $priceList, PriceListItem $priceListItem): RedirectResponse + { + $this->authorize('update', $priceList); + + $priceListItem->delete(); + + return back(); + } + + public function destroy(PriceList $priceList): RedirectResponse + { + $this->authorize('delete', $priceList); + + $priceList->delete(); + + return back(); + } +} diff --git a/erp/app/Modules/Inventory/Http/Controllers/ProductAttributeController.php b/erp/app/Modules/Inventory/Http/Controllers/ProductAttributeController.php new file mode 100644 index 00000000000..8b60ea05e39 --- /dev/null +++ b/erp/app/Modules/Inventory/Http/Controllers/ProductAttributeController.php @@ -0,0 +1,58 @@ +authorize('viewAny', ProductAttribute::class); + $attributes = ProductAttribute::paginate(20); + return Inertia::render('Inventory/ProductAttributes/Index', ['attributes' => $attributes]); + } + + public function store(Request $request) + { + $this->authorize('create', ProductAttribute::class); + $data = $request->validate([ + 'name' => 'required|string|max:100', + 'type' => 'required|in:text,select,color,number', + 'options' => 'nullable|array', + 'options.*' => 'string', + ]); + $data['tenant_id'] = app('tenant')->id; + ProductAttribute::create($data); + return back()->with('success', 'Attribute created.'); + } + + public function update(Request $request, ProductAttribute $productAttribute) + { + $this->authorize('update', $productAttribute); + $data = $request->validate([ + 'name' => 'required|string|max:100', + 'type' => 'required|in:text,select,color,number', + 'options' => 'nullable|array', + 'options.*' => 'string', + ]); + $productAttribute->update($data); + return back()->with('success', 'Attribute updated.'); + } + + public function destroy(ProductAttribute $productAttribute) + { + $this->authorize('delete', $productAttribute); + $productAttribute->delete(); + return back()->with('success', 'Attribute deleted.'); + } + + private function authorize(string $ability, $model): void + { + if (auth()->user()->cannot($ability, $model)) { + abort(403); + } + } +} diff --git a/erp/app/Modules/Inventory/Http/Controllers/ProductBundleController.php b/erp/app/Modules/Inventory/Http/Controllers/ProductBundleController.php new file mode 100644 index 00000000000..46a8ae7c7ed --- /dev/null +++ b/erp/app/Modules/Inventory/Http/Controllers/ProductBundleController.php @@ -0,0 +1,94 @@ +authorize('viewAny', ProductBundle::class); + + $bundles = ProductBundle::where('tenant_id', app('tenant')->id) + ->latest() + ->paginate(20); + + return Inertia::render('Inventory/ProductBundles/Index', compact('bundles')); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', ProductBundle::class); + + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'sku' => 'nullable|string|max:100', + 'description' => 'nullable|string', + 'bundle_price' => 'nullable|numeric|min:0', + 'is_active' => 'nullable|boolean', + ]); + + $validated['tenant_id'] = app('tenant')->id; + + ProductBundle::create($validated); + + return back()->with('success', 'Product bundle created.'); + } + + public function show(ProductBundle $productBundle): Response + { + $this->authorize('view', $productBundle); + + $productBundle->load('items.product'); + + return Inertia::render('Inventory/ProductBundles/Show', compact('productBundle')); + } + + public function addItem(Request $request, ProductBundle $productBundle): RedirectResponse + { + $this->authorize('update', $productBundle); + + $validated = $request->validate([ + 'product_id' => 'required|exists:products,id', + 'quantity' => 'required|numeric|min:0.01', + ]); + + ProductBundleItem::firstOrCreate( + [ + 'product_bundle_id' => $productBundle->id, + 'product_id' => $validated['product_id'], + ], + [ + 'tenant_id' => app('tenant')->id, + 'quantity' => $validated['quantity'], + ] + ); + + return back()->with('success', 'Item added to bundle.'); + } + + public function removeItem(ProductBundle $productBundle, ProductBundleItem $item): RedirectResponse + { + $this->authorize('update', $productBundle); + + $item->delete(); + + return back()->with('success', 'Item removed from bundle.'); + } + + public function destroy(ProductBundle $productBundle): RedirectResponse + { + $this->authorize('delete', $productBundle); + + $productBundle->delete(); + + return back()->with('success', 'Product bundle deleted.'); + } +} diff --git a/erp/app/Modules/Inventory/Http/Controllers/ProductCategoryController.php b/erp/app/Modules/Inventory/Http/Controllers/ProductCategoryController.php new file mode 100644 index 00000000000..7bbad9dcc32 --- /dev/null +++ b/erp/app/Modules/Inventory/Http/Controllers/ProductCategoryController.php @@ -0,0 +1,82 @@ +authorize('viewAny', ProductCategory::class); + $tenantId = $request->user()->tenant_id; + $categories = ProductCategory::where('tenant_id', $tenantId) + ->withCount('products') + ->orderBy('name') + ->get() + ->map(fn ($c) => [ + 'id' => $c->id, + 'name' => $c->name, + 'slug' => $c->slug, + 'description' => $c->description, + 'colour' => $c->colour, + 'products_count' => $c->products_count, + ]); + return Inertia::render('Inventory/ProductCategories/Index', compact('categories')); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', ProductCategory::class); + $data = $request->validate([ + 'name' => 'required|string|max:100', + 'description' => 'nullable|string', + 'colour' => 'nullable|string|regex:/^#[0-9A-Fa-f]{6}$/', + ]); + $data['tenant_id'] = $request->user()->tenant_id; + $data['slug'] = $this->uniqueSlug($data['tenant_id'], \Str::slug($data['name'])); + $data['colour'] = $data['colour'] ?? '#6366f1'; + ProductCategory::create($data); + return back()->with('success', 'Category created.'); + } + + public function update(Request $request, ProductCategory $productCategory): RedirectResponse + { + $this->authorize('update', $productCategory); + $data = $request->validate([ + 'name' => 'required|string|max:100', + 'description' => 'nullable|string', + 'colour' => 'nullable|string|regex:/^#[0-9A-Fa-f]{6}$/', + ]); + $data['slug'] = $this->uniqueSlug($productCategory->tenant_id, \Str::slug($data['name']), $productCategory->id); + $productCategory->update($data); + return back()->with('success', 'Category updated.'); + } + + public function destroy(ProductCategory $productCategory): RedirectResponse + { + $this->authorize('delete', $productCategory); + $productCategory->delete(); // products become uncategorised (nullOnDelete) + return back()->with('success', 'Category deleted.'); + } + + private function uniqueSlug(int $tenantId, string $base, ?int $excludeId = null): string + { + $slug = $base; + $count = 2; + while ( + ProductCategory::where('tenant_id', $tenantId) + ->where('slug', $slug) + ->when($excludeId, fn ($q) => $q->where('id', '!=', $excludeId)) + ->exists() + ) { + $slug = $base . '-' . $count++; + } + return $slug; + } +} diff --git a/erp/app/Modules/Inventory/Http/Controllers/ProductController.php b/erp/app/Modules/Inventory/Http/Controllers/ProductController.php new file mode 100644 index 00000000000..310d1ecec24 --- /dev/null +++ b/erp/app/Modules/Inventory/Http/Controllers/ProductController.php @@ -0,0 +1,133 @@ +authorize('viewAny', Product::class); + + $products = Product::with(['category', 'uom', 'stockLevels']) + ->when($request->search, fn ($q) => $q->search($request->search)) + ->when($request->category_id, fn ($q) => $q->where('category_id', $request->category_id)) + ->when($request->status !== null, fn ($q) => match ($request->status) { + 'active' => $q->active(), + 'inactive' => $q->where('is_active', false), + default => $q, + }) + ->orderBy($request->sort ?? 'name', $request->direction ?? 'asc') + ->paginate(25) + ->withQueryString(); + + return Inertia::render('Inventory/Products/Index', [ + 'products' => ProductResource::collection($products), + 'categories' => ProductCategory::orderBy('name')->get(['id', 'name', 'colour']), + 'filters' => $request->only(['search', 'category_id', 'status', 'sort', 'direction']), + 'breadcrumbs' => [ + ['label' => 'Inventory'], + ['label' => 'Products', 'href' => route('inventory.products.index')], + ], + ]); + } + + public function create(): Response + { + $this->authorize('create', Product::class); + + return Inertia::render('Inventory/Products/Create', [ + 'categories' => ProductCategory::orderBy('name')->get(['id', 'name', 'colour']), + 'uoms' => UnitOfMeasure::orderBy('name')->get(['id', 'name', 'abbreviation']), + 'breadcrumbs' => [ + ['label' => 'Inventory'], + ['label' => 'Products', 'href' => route('inventory.products.index')], + ['label' => 'New Product'], + ], + ]); + } + + public function store(StoreProductRequest $request): RedirectResponse + { + $this->authorize('create', Product::class); + + Product::create([...$request->validated(), 'tenant_id' => auth()->user()->tenant_id]); + + return redirect()->route('inventory.products.index') + ->with('success', 'Product created successfully.'); + } + + public function show(Product $product): Response + { + $this->authorize('view', $product); + + $product->load(['category', 'uom', 'stockLevels.warehouse', 'preferredSupplier']); + + $movements = $product->stockMovements() + ->with('warehouse', 'creator') + ->latest('created_at') + ->paginate(20); + + $tenantId = auth()->user()->tenant_id; + + return Inertia::render('Inventory/Products/Show', [ + 'product' => new ProductResource($product), + 'movements' => $movements, + 'suppliers' => Supplier::where('tenant_id', $tenantId)->orderBy('name')->get(['id', 'name']), + 'breadcrumbs' => [ + ['label' => 'Inventory'], + ['label' => 'Products', 'href' => route('inventory.products.index')], + ['label' => $product->name], + ], + ]); + } + + public function edit(Product $product): Response + { + $this->authorize('update', $product); + + return Inertia::render('Inventory/Products/Edit', [ + 'product' => new ProductResource($product), + 'categories' => ProductCategory::orderBy('name')->get(['id', 'name', 'colour']), + 'uoms' => UnitOfMeasure::orderBy('name')->get(['id', 'name', 'abbreviation']), + 'breadcrumbs' => [ + ['label' => 'Inventory'], + ['label' => 'Products', 'href' => route('inventory.products.index')], + ['label' => $product->name, 'href' => route('inventory.products.show', $product)], + ['label' => 'Edit'], + ], + ]); + } + + public function update(UpdateProductRequest $request, Product $product): RedirectResponse + { + $this->authorize('update', $product); + + $product->update($request->validated()); + + return redirect()->route('inventory.products.show', $product) + ->with('success', 'Product updated successfully.'); + } + + public function destroy(Product $product): RedirectResponse + { + $this->authorize('delete', $product); + + $product->delete(); + + return redirect()->route('inventory.products.index') + ->with('success', 'Product deleted.'); + } +} diff --git a/erp/app/Modules/Inventory/Http/Controllers/ProductSubstituteController.php b/erp/app/Modules/Inventory/Http/Controllers/ProductSubstituteController.php new file mode 100644 index 00000000000..6577ee206a7 --- /dev/null +++ b/erp/app/Modules/Inventory/Http/Controllers/ProductSubstituteController.php @@ -0,0 +1,100 @@ +authorize('viewAny', ProductSubstitute::class); + + $substitutes = $product->substitutes() + ->with('substituteProduct') + ->get(); + + return Inertia::render('Inventory/ProductSubstitutes/Index', [ + 'product' => $product, + 'substitutes' => $substitutes, + ]); + } + + public function store(Request $request, Product $product): RedirectResponse + { + $this->authorize('create', ProductSubstitute::class); + + $validated = $request->validate([ + 'substitute_product_id' => [ + 'required', + 'exists:products,id', + Rule::notIn([$product->id]), + ], + 'priority' => 'nullable|integer|min:1|max:10', + 'is_bidirectional' => 'boolean', + 'notes' => 'nullable|string', + ]); + + $tenantId = auth()->user()->tenant_id; + $isBidirectional = $validated['is_bidirectional'] ?? false; + + ProductSubstitute::create([ + 'tenant_id' => $tenantId, + 'product_id' => $product->id, + 'substitute_product_id' => $validated['substitute_product_id'], + 'priority' => $validated['priority'] ?? 1, + 'is_bidirectional' => $isBidirectional, + 'is_active' => true, + 'notes' => $validated['notes'] ?? null, + ]); + + if ($isBidirectional) { + ProductSubstitute::firstOrCreate( + [ + 'product_id' => $validated['substitute_product_id'], + 'substitute_product_id' => $product->id, + ], + [ + 'tenant_id' => $tenantId, + 'priority' => $validated['priority'] ?? 1, + 'is_bidirectional' => true, + 'is_active' => true, + 'notes' => $validated['notes'] ?? null, + ] + ); + } + + return back()->with('success', 'Product substitute added.'); + } + + public function update(Request $request, Product $product, ProductSubstitute $productSubstitute): RedirectResponse + { + $this->authorize('update', $productSubstitute); + + $validated = $request->validate([ + 'priority' => 'nullable|integer|min:1|max:10', + 'is_active' => 'boolean', + 'notes' => 'nullable|string', + ]); + + $productSubstitute->update($validated); + + return back()->with('success', 'Product substitute updated.'); + } + + public function destroy(Product $product, ProductSubstitute $productSubstitute): RedirectResponse + { + $this->authorize('delete', $productSubstitute); + + $productSubstitute->delete(); + + return back()->with('success', 'Product substitute removed.'); + } +} diff --git a/erp/app/Modules/Inventory/Http/Controllers/ProductTagAssignmentController.php b/erp/app/Modules/Inventory/Http/Controllers/ProductTagAssignmentController.php new file mode 100644 index 00000000000..29b47a54400 --- /dev/null +++ b/erp/app/Modules/Inventory/Http/Controllers/ProductTagAssignmentController.php @@ -0,0 +1,34 @@ +authorize('update', $product); + + $validated = $request->validate([ + 'tag_id' => 'required|exists:product_tags,id', + ]); + + $product->tags()->syncWithoutDetaching([$validated['tag_id']]); + + return back()->with('success', 'Tag attached to product.'); + } + + public function detach(Request $request, Product $product, ProductTag $productTag): RedirectResponse + { + $this->authorize('update', $product); + + $product->tags()->detach($productTag->id); + + return back()->with('success', 'Tag removed from product.'); + } +} diff --git a/erp/app/Modules/Inventory/Http/Controllers/ProductTagController.php b/erp/app/Modules/Inventory/Http/Controllers/ProductTagController.php new file mode 100644 index 00000000000..072e096aee8 --- /dev/null +++ b/erp/app/Modules/Inventory/Http/Controllers/ProductTagController.php @@ -0,0 +1,68 @@ +authorize('viewAny', ProductTag::class); + + $tags = ProductTag::where('tenant_id', auth()->user()->tenant_id) + ->where('is_active', true) + ->orderBy('name') + ->paginate(20); + + return Inertia::render('Inventory/ProductTags/Index', compact('tags')); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', ProductTag::class); + + $validated = $request->validate([ + 'name' => 'required|string|max:100', + 'color' => 'nullable|string|max:20', + 'description' => 'nullable|string', + 'is_active' => 'boolean', + ]); + + $validated['tenant_id'] = auth()->user()->tenant_id; + + ProductTag::create($validated); + + return back()->with('success', 'Product tag created.'); + } + + public function update(Request $request, ProductTag $productTag): RedirectResponse + { + $this->authorize('update', $productTag); + + $validated = $request->validate([ + 'name' => 'required|string|max:100', + 'color' => 'nullable|string|max:20', + 'description' => 'nullable|string', + 'is_active' => 'boolean', + ]); + + $productTag->update($validated); + + return back()->with('success', 'Product tag updated.'); + } + + public function destroy(ProductTag $productTag): RedirectResponse + { + $this->authorize('delete', $productTag); + + $productTag->delete(); + + return back()->with('success', 'Product tag deleted.'); + } +} diff --git a/erp/app/Modules/Inventory/Http/Controllers/ProductVariantController.php b/erp/app/Modules/Inventory/Http/Controllers/ProductVariantController.php new file mode 100644 index 00000000000..9f8ed52bed1 --- /dev/null +++ b/erp/app/Modules/Inventory/Http/Controllers/ProductVariantController.php @@ -0,0 +1,109 @@ +authorize('viewAny', ProductVariant::class); + $query = ProductVariant::with(['product', 'values.attribute']); + if ($request->product_id) { + $query->where('product_id', $request->product_id); + } + $variants = $query->paginate(20)->withQueryString(); + $products = Product::select('id', 'name', 'sku')->orderBy('name')->get(); + return Inertia::render('Inventory/ProductVariants/Index', [ + 'variants' => $variants, + 'products' => $products, + 'filters' => $request->only('product_id'), + ]); + } + + public function create() + { + $this->authorize('create', ProductVariant::class); + $products = Product::select('id', 'name', 'sku', 'sale_price')->orderBy('name')->get(); + $attributes = ProductAttribute::orderBy('name')->get(); + return Inertia::render('Inventory/ProductVariants/Create', [ + 'products' => $products, + 'attributes' => $attributes, + ]); + } + + public function store(Request $request) + { + $this->authorize('create', ProductVariant::class); + $data = $request->validate([ + 'product_id' => 'required|exists:products,id', + 'sku' => 'required|string|unique:product_variants,sku', + 'name' => 'required|string|max:200', + 'price_adjustment' => 'nullable|numeric', + 'stock_quantity' => 'nullable|integer|min:0', + 'values' => 'nullable|array', + 'values.*.attribute_id' => 'required|exists:product_attributes,id', + 'values.*.value' => 'required|string', + ]); + + $tenantId = app('tenant')->id; + $variant = ProductVariant::create([ + 'tenant_id' => $tenantId, + 'product_id' => $data['product_id'], + 'sku' => $data['sku'], + 'name' => $data['name'], + 'price_adjustment' => $data['price_adjustment'] ?? 0, + 'stock_quantity' => $data['stock_quantity'] ?? 0, + ]); + + foreach ($data['values'] ?? [] as $v) { + ProductVariantValue::create([ + 'tenant_id' => $tenantId, + 'variant_id' => $variant->id, + 'attribute_id' => $v['attribute_id'], + 'value' => $v['value'], + ]); + } + + return redirect()->route('inventory.product-variants.show', $variant) + ->with('success', 'Variant created.'); + } + + public function show(ProductVariant $productVariant) + { + $this->authorize('view', $productVariant); + $productVariant->load(['product', 'values.attribute']); + return Inertia::render('Inventory/ProductVariants/Show', [ + 'variant' => $productVariant, + ]); + } + + public function destroy(ProductVariant $productVariant) + { + $this->authorize('delete', $productVariant); + $productVariant->delete(); + return redirect()->route('inventory.product-variants.index') + ->with('success', 'Variant deleted.'); + } + + public function adjustStock(Request $request, ProductVariant $productVariant) + { + $this->authorize('update', $productVariant); + $data = $request->validate(['delta' => 'required|integer']); + $productVariant->adjustStock($data['delta']); + return back()->with('success', 'Stock adjusted.'); + } + + private function authorize(string $ability, $model): void + { + if (auth()->user()->cannot($ability, $model)) { + abort(403); + } + } +} diff --git a/erp/app/Modules/Inventory/Http/Controllers/ProductWarrantyController.php b/erp/app/Modules/Inventory/Http/Controllers/ProductWarrantyController.php new file mode 100644 index 00000000000..b161525083b --- /dev/null +++ b/erp/app/Modules/Inventory/Http/Controllers/ProductWarrantyController.php @@ -0,0 +1,84 @@ +orderByDesc('created_at') + ->paginate(20); + + return Inertia::render('Inventory/Warranties/Index', compact('warranties')); + } + + public function create(): Response + { + $products = Product::select('id', 'name', 'sku')->orderBy('name')->get(); + + return Inertia::render('Inventory/Warranties/Create', compact('products')); + } + + public function store(Request $request): RedirectResponse + { + $data = $request->validate([ + 'name' => 'required|string|max:255', + 'product_id' => 'required|exists:products,id', + 'duration_months' => 'required|integer|min:1', + 'warranty_type' => 'nullable|string|in:standard,extended,limited', + 'terms' => 'nullable|string', + 'is_default' => 'boolean', + ]); + + $data['tenant_id'] = app('tenant')->id; + + ProductWarranty::create($data); + + return redirect()->route('inventory.warranties.index'); + } + + public function show(ProductWarranty $warranty): Response + { + $warranty->load('product', 'claims'); + + return Inertia::render('Inventory/Warranties/Show', compact('warranty')); + } + + public function edit(ProductWarranty $warranty): Response + { + $products = Product::select('id', 'name', 'sku')->orderBy('name')->get(); + + return Inertia::render('Inventory/Warranties/Edit', compact('warranty', 'products')); + } + + public function update(Request $request, ProductWarranty $warranty): RedirectResponse + { + $data = $request->validate([ + 'name' => 'required|string|max:255', + 'product_id' => 'nullable|exists:products,id', + 'duration_months' => 'required|integer|min:1', + 'warranty_type' => 'nullable|string|in:standard,extended,limited', + 'terms' => 'nullable|string', + 'is_default' => 'boolean', + ]); + + $warranty->update($data); + + return redirect()->route('inventory.warranties.index'); + } + + public function destroy(ProductWarranty $warranty): RedirectResponse + { + $warranty->delete(); + + return redirect()->route('inventory.warranties.index'); + } +} diff --git a/erp/app/Modules/Inventory/Http/Controllers/PurchaseOrderController.php b/erp/app/Modules/Inventory/Http/Controllers/PurchaseOrderController.php new file mode 100644 index 00000000000..c2341cf0345 --- /dev/null +++ b/erp/app/Modules/Inventory/Http/Controllers/PurchaseOrderController.php @@ -0,0 +1,203 @@ +authorize('viewAny', PurchaseOrder::class); + + $orders = PurchaseOrder::with('supplier') + ->when($request->status, fn ($q) => $q->where('status', $request->status)) + ->when($request->supplier_id, fn ($q) => $q->where('supplier_id', $request->supplier_id)) + ->latest() + ->paginate(20) + ->withQueryString(); + + return Inertia::render('Inventory/PurchaseOrders/Index', [ + 'orders' => $orders, + 'filters' => $request->only(['status', 'supplier_id']), + ]); + } + + public function create(): Response + { + $this->authorize('create', PurchaseOrder::class); + + return Inertia::render('Inventory/PurchaseOrders/Create', [ + 'suppliers' => Supplier::orderBy('name')->get(['id', 'name']), + 'products' => Product::where('is_active', true)->orderBy('name')->get(['id', 'name', 'sku']), + ]); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', PurchaseOrder::class); + + $validated = $request->validate([ + 'supplier_id' => 'nullable|exists:suppliers,id', + 'order_date' => 'required|date', + 'expected_date' => 'nullable|date', + 'currency' => 'nullable|string|max:3', + 'notes' => 'nullable|string', + 'items' => 'required|array|min:1', + 'items.*.description' => 'required|string|max:255', + 'items.*.quantity' => 'required|numeric|min:0.01', + 'items.*.unit_price' => 'required|numeric|min:0', + 'items.*.product_id' => 'nullable|exists:products,id', + ]); + + $po = PurchaseOrder::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'po_number' => PurchaseOrder::generatePoNumber(), + 'supplier_id' => $validated['supplier_id'] ?? null, + 'order_date' => $validated['order_date'], + 'expected_date' => $validated['expected_date'] ?? null, + 'currency' => $validated['currency'] ?? 'USD', + 'notes' => $validated['notes'] ?? null, + 'created_by' => auth()->id(), + 'subtotal' => 0, + 'tax' => 0, + 'total' => 0, + ]); + + foreach ($validated['items'] as $item) { + PurchaseOrderItem::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'purchase_order_id' => $po->id, + 'product_id' => $item['product_id'] ?? null, + 'description' => $item['description'], + 'quantity' => $item['quantity'], + 'unit_price' => $item['unit_price'], + 'received_qty' => 0, + ]); + } + + $po->recalculateTotals(); + + return redirect()->route('inventory.purchase-orders.show', $po); + } + + public function show(PurchaseOrder $purchaseOrder): Response + { + $this->authorize('view', $purchaseOrder); + $purchaseOrder->load(['supplier', 'items.product', 'createdBy']); + + return Inertia::render('Inventory/PurchaseOrders/Show', [ + 'order' => $purchaseOrder, + ]); + } + + public function send(PurchaseOrder $purchaseOrder): RedirectResponse + { + $this->authorize('update', $purchaseOrder); + $purchaseOrder->send(); + + return back()->with('success', 'Purchase order sent.'); + } + + public function cancel(PurchaseOrder $purchaseOrder): RedirectResponse + { + $this->authorize('update', $purchaseOrder); + $purchaseOrder->cancel(); + + return back()->with('success', 'Purchase order cancelled.'); + } + + public function receive(Request $request, PurchaseOrder $purchaseOrder): RedirectResponse + { + $this->authorize('update', $purchaseOrder); + + // Accept both 'items' and 'lines' key; both 'received_qty' and 'received_quantity' + $lines = $request->input('items') ?? $request->input('lines') ?? []; + + foreach ($lines as $data) { + $item = PurchaseOrderItem::find($data['id'] ?? null); + if (!$item || $item->purchase_order_id !== $purchaseOrder->id) { + continue; + } + $qty = (float) ($data['received_qty'] ?? $data['received_quantity'] ?? 0); + $prevQty = (float) $item->received_qty; + $item->received_qty = $qty; + $item->save(); + + // Create stock movement for the delta if warehouse is set + $delta = $qty - $prevQty; + if ($delta > 0 && $purchaseOrder->warehouse_id && $item->product_id) { + StockMovement::record([ + 'product_id' => $item->product_id, + 'warehouse_id' => $purchaseOrder->warehouse_id, + 'type' => 'in', + 'quantity' => $delta, + 'reference' => $purchaseOrder->po_number ?? ('PO-' . $purchaseOrder->id), + 'notes' => 'PO receiving', + ]); + } + } + + $allReceived = $purchaseOrder->items()->get()->every(fn ($i) => (float)$i->received_qty >= (float)$i->quantity); + $anyReceived = $purchaseOrder->items()->where('received_qty', '>', 0)->exists(); + + if ($allReceived) { + $purchaseOrder->markReceived(); + } elseif ($anyReceived) { + $purchaseOrder->status = 'partial'; + $purchaseOrder->save(); + } + + return back()->with('success', 'Receiving updated.'); + } + + public function receiveForm(PurchaseOrder $purchaseOrder): Response + { + $this->authorize('view', $purchaseOrder); + $purchaseOrder->load(['supplier', 'items.product']); + return Inertia::render('Inventory/PurchaseOrders/Receive', ['order' => $purchaseOrder]); + } + + public function transition(Request $request, PurchaseOrder $purchaseOrder): RedirectResponse + { + $this->authorize('update', $purchaseOrder); + $status = $request->input('status'); + if ($status) { + $purchaseOrder->status = $status; + $purchaseOrder->save(); + } + return back()->with('success', 'Status updated.'); + } + + public function submit(PurchaseOrder $purchaseOrder): RedirectResponse + { + $this->authorize('update', $purchaseOrder); + $purchaseOrder->send(); + return back()->with('success', 'Purchase order submitted.'); + } + + public function approve(PurchaseOrder $purchaseOrder): RedirectResponse + { + $this->authorize('update', $purchaseOrder); + $purchaseOrder->status = 'sent'; + $purchaseOrder->save(); + return back()->with('success', 'Purchase order approved.'); + } + + public function destroy(PurchaseOrder $purchaseOrder): RedirectResponse + { + $this->authorize('delete', $purchaseOrder); + $purchaseOrder->delete(); + + return redirect()->route('inventory.purchase-orders.index'); + } +} diff --git a/erp/app/Modules/Inventory/Http/Controllers/PurchaseRequestController.php b/erp/app/Modules/Inventory/Http/Controllers/PurchaseRequestController.php new file mode 100644 index 00000000000..7a8c0dadc82 --- /dev/null +++ b/erp/app/Modules/Inventory/Http/Controllers/PurchaseRequestController.php @@ -0,0 +1,143 @@ +authorize('viewAny', PurchaseRequest::class); + + $purchaseRequests = PurchaseRequest::orderByDesc('created_at') + ->paginate(25); + + return Inertia::render('Inventory/PurchaseRequests/Index', compact('purchaseRequests')); + } + + public function create(): Response + { + $this->authorize('create', PurchaseRequest::class); + + return Inertia::render('Inventory/PurchaseRequests/Create'); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', PurchaseRequest::class); + + $data = $request->validate([ + 'title' => 'required|string', + 'estimated_cost' => 'nullable|numeric|min:0', + 'priority' => 'nullable|in:low,medium,high,urgent', + 'required_by' => 'nullable|date', + ]); + + PurchaseRequest::create([ + 'tenant_id' => app('tenant')->id, + 'created_by' => auth()->id(), + 'requested_by' => auth()->id(), + ...$data, + ]); + + return redirect()->route('inventory.purchase-requests.index') + ->with('success', 'Purchase request created.'); + } + + public function show(PurchaseRequest $purchaseRequest): Response + { + $this->authorize('view', $purchaseRequest); + + return Inertia::render('Inventory/PurchaseRequests/Show', compact('purchaseRequest')); + } + + public function edit(PurchaseRequest $purchaseRequest): Response + { + $this->authorize('update', $purchaseRequest); + + return Inertia::render('Inventory/PurchaseRequests/Edit', compact('purchaseRequest')); + } + + public function update(Request $request, PurchaseRequest $purchaseRequest): RedirectResponse + { + $this->authorize('update', $purchaseRequest); + + $data = $request->validate([ + 'title' => 'required|string', + 'estimated_cost' => 'nullable|numeric|min:0', + 'priority' => 'nullable|in:low,medium,high,urgent', + 'required_by' => 'nullable|date', + ]); + + $purchaseRequest->update($data); + + return redirect()->route('inventory.purchase-requests.index') + ->with('success', 'Purchase request updated.'); + } + + public function destroy(PurchaseRequest $purchaseRequest): RedirectResponse + { + $this->authorize('delete', $purchaseRequest); + + $purchaseRequest->delete(); + + return redirect()->route('inventory.purchase-requests.index') + ->with('success', 'Purchase request deleted.'); + } + + public function submit(PurchaseRequest $purchaseRequest): RedirectResponse + { + $this->authorize('submit', $purchaseRequest); + + $purchaseRequest->submit(); + + return redirect()->route('inventory.purchase-requests.index') + ->with('success', 'Purchase request submitted.'); + } + + public function approve(PurchaseRequest $purchaseRequest): RedirectResponse + { + $this->authorize('approve', $purchaseRequest); + + $purchaseRequest->approve(auth()->id()); + + return redirect()->route('inventory.purchase-requests.index') + ->with('success', 'Purchase request approved.'); + } + + public function reject(PurchaseRequest $purchaseRequest): RedirectResponse + { + $this->authorize('reject', $purchaseRequest); + + $purchaseRequest->reject(); + + return redirect()->route('inventory.purchase-requests.index') + ->with('success', 'Purchase request rejected.'); + } + + public function markOrdered(PurchaseRequest $purchaseRequest): RedirectResponse + { + $this->authorize('markOrdered', $purchaseRequest); + + $purchaseRequest->markOrdered(); + + return redirect()->route('inventory.purchase-requests.index') + ->with('success', 'Purchase request marked as ordered.'); + } + + public function cancel(PurchaseRequest $purchaseRequest): RedirectResponse + { + $this->authorize('cancel', $purchaseRequest); + + $purchaseRequest->cancel(); + + return redirect()->route('inventory.purchase-requests.index') + ->with('success', 'Purchase request cancelled.'); + } +} diff --git a/erp/app/Modules/Inventory/Http/Controllers/PurchaseRequisitionController.php b/erp/app/Modules/Inventory/Http/Controllers/PurchaseRequisitionController.php new file mode 100644 index 00000000000..90b711448da --- /dev/null +++ b/erp/app/Modules/Inventory/Http/Controllers/PurchaseRequisitionController.php @@ -0,0 +1,113 @@ +authorize('viewAny', PurchaseRequisition::class); + $requisitions = PurchaseRequisition::with(['requester', 'approver']) + ->orderByDesc('created_at') + ->paginate(25); + return Inertia::render('Inventory/PurchaseRequisitions/Index', compact('requisitions')); + } + + public function create(): Response + { + $this->authorize('create', PurchaseRequisition::class); + $products = Product::orderBy('name')->get(['id', 'name', 'sku', 'cost_price']); + return Inertia::render('Inventory/PurchaseRequisitions/Create', compact('products')); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', PurchaseRequisition::class); + $data = $request->validate([ + 'reference' => 'required|string|max:100|unique:purchase_requisitions,reference', + 'needed_by' => 'nullable|date', + 'notes' => 'nullable|string', + 'items' => 'required|array|min:1', + 'items.*.description' => 'required|string', + 'items.*.product_id' => 'nullable|exists:products,id', + 'items.*.quantity' => 'required|numeric|min:0.01', + 'items.*.estimated_unit_cost' => 'required|numeric|min:0', + ]); + + $pr = PurchaseRequisition::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'reference' => $data['reference'], + 'requested_by' => auth()->id(), + 'status' => 'draft', + 'needed_by' => $data['needed_by'] ?? null, + 'notes' => $data['notes'] ?? null, + ]); + + foreach ($data['items'] as $item) { + $pr->items()->create([ + 'product_id' => $item['product_id'] ?? null, + 'description' => $item['description'], + 'quantity' => $item['quantity'], + 'estimated_unit_cost' => $item['estimated_unit_cost'], + ]); + } + + return redirect()->route('inventory.purchase-requisitions.show', $pr) + ->with('success', 'Purchase requisition created.'); + } + + public function show(PurchaseRequisition $purchaseRequisition): Response + { + $this->authorize('view', $purchaseRequisition); + $purchaseRequisition->load(['requester', 'approver', 'items.product']); + return Inertia::render('Inventory/PurchaseRequisitions/Show', compact('purchaseRequisition')); + } + + public function submit(PurchaseRequisition $purchaseRequisition): RedirectResponse + { + $this->authorize('update', $purchaseRequisition); + abort_unless($purchaseRequisition->status === 'draft', 422, 'Only drafts can be submitted.'); + $purchaseRequisition->update(['status' => 'submitted']); + return back()->with('success', 'Requisition submitted for approval.'); + } + + public function approve(PurchaseRequisition $purchaseRequisition): RedirectResponse + { + $this->authorize('approve', $purchaseRequisition); + abort_unless($purchaseRequisition->status === 'submitted', 422, 'Only submitted requisitions can be approved.'); + $purchaseRequisition->update([ + 'status' => 'approved', + 'approved_by' => auth()->id(), + 'approved_at' => now(), + ]); + return back()->with('success', 'Requisition approved.'); + } + + public function reject(PurchaseRequisition $purchaseRequisition, Request $request): RedirectResponse + { + $this->authorize('approve', $purchaseRequisition); + abort_unless($purchaseRequisition->status === 'submitted', 422, 'Only submitted requisitions can be rejected.'); + $request->validate(['rejection_reason' => 'required|string']); + $purchaseRequisition->update([ + 'status' => 'rejected', + 'rejection_reason' => $request->rejection_reason, + ]); + return back()->with('success', 'Requisition rejected.'); + } + + public function destroy(PurchaseRequisition $purchaseRequisition): RedirectResponse + { + $this->authorize('delete', $purchaseRequisition); + abort_unless($purchaseRequisition->status === 'draft', 422, 'Only drafts can be deleted.'); + $purchaseRequisition->delete(); + return redirect()->route('inventory.purchase-requisitions.index')->with('success', 'Requisition deleted.'); + } +} diff --git a/erp/app/Modules/Inventory/Http/Controllers/PutAwayRuleController.php b/erp/app/Modules/Inventory/Http/Controllers/PutAwayRuleController.php new file mode 100644 index 00000000000..2f88de06b93 --- /dev/null +++ b/erp/app/Modules/Inventory/Http/Controllers/PutAwayRuleController.php @@ -0,0 +1,118 @@ +orderBy('sequence') + ->paginate(20); + + return Inertia::render('Inventory/PutAwayRules/Index', compact('putAwayRules')); + } + + public function create(): Response + { + $warehouses = Warehouse::select('id', 'name')->orderBy('name')->get(); + $products = Product::select('id', 'name', 'sku')->orderBy('name')->get(); + $categories = ProductCategory::select('id', 'name')->orderBy('name')->get(); + $zones = WarehouseZone::select('id', 'name', 'warehouse_id')->orderBy('name')->get(); + $bins = WarehouseBin::select('id', 'code', 'name', 'warehouse_id')->orderBy('code')->get(); + + return Inertia::render('Inventory/PutAwayRules/Create', compact('warehouses', 'products', 'categories', 'zones', 'bins')); + } + + public function store(Request $request): RedirectResponse + { + $data = $request->validate([ + 'name' => 'required|string|max:255', + 'warehouse_id' => 'required|exists:warehouses,id', + 'product_id' => 'nullable|exists:products,id', + 'product_category_id' => 'nullable|exists:product_categories,id', + 'location_in_zone_id' => 'nullable|exists:warehouse_zones,id', + 'location_out_bin_id' => 'nullable|exists:warehouse_bins,id', + 'location_out_zone_id' => 'nullable|exists:warehouse_zones,id', + 'sequence' => 'nullable|integer', + 'is_active' => 'boolean', + 'notes' => 'nullable|string', + ]); + + $data['tenant_id'] = app('tenant')->id; + + PutAwayRule::create($data); + + return redirect()->route('inventory.put-away-rules.index'); + } + + public function show(PutAwayRule $putAwayRule): Response + { + $putAwayRule->load('warehouse', 'product', 'category', 'locationInZone', 'locationOutBin', 'locationOutZone'); + + return Inertia::render('Inventory/PutAwayRules/Show', compact('putAwayRule')); + } + + public function edit(PutAwayRule $putAwayRule): Response + { + $warehouses = Warehouse::select('id', 'name')->orderBy('name')->get(); + $products = Product::select('id', 'name', 'sku')->orderBy('name')->get(); + $categories = ProductCategory::select('id', 'name')->orderBy('name')->get(); + $zones = WarehouseZone::select('id', 'name', 'warehouse_id')->orderBy('name')->get(); + $bins = WarehouseBin::select('id', 'code', 'name', 'warehouse_id')->orderBy('code')->get(); + + return Inertia::render('Inventory/PutAwayRules/Edit', compact('putAwayRule', 'warehouses', 'products', 'categories', 'zones', 'bins')); + } + + public function update(Request $request, PutAwayRule $putAwayRule): RedirectResponse + { + $data = $request->validate([ + 'name' => 'required|string|max:255', + 'warehouse_id' => 'required|exists:warehouses,id', + 'product_id' => 'nullable|exists:products,id', + 'product_category_id' => 'nullable|exists:product_categories,id', + 'location_in_zone_id' => 'nullable|exists:warehouse_zones,id', + 'location_out_bin_id' => 'nullable|exists:warehouse_bins,id', + 'location_out_zone_id' => 'nullable|exists:warehouse_zones,id', + 'sequence' => 'nullable|integer', + 'is_active' => 'boolean', + 'notes' => 'nullable|string', + ]); + + $putAwayRule->update($data); + + return redirect()->route('inventory.put-away-rules.index'); + } + + public function destroy(PutAwayRule $putAwayRule): RedirectResponse + { + $putAwayRule->delete(); + + return redirect()->route('inventory.put-away-rules.index'); + } + + public function activate(PutAwayRule $putAwayRule): RedirectResponse + { + $putAwayRule->update(['is_active' => true]); + + return redirect()->route('inventory.put-away-rules.index'); + } + + public function deactivate(PutAwayRule $putAwayRule): RedirectResponse + { + $putAwayRule->update(['is_active' => false]); + + return redirect()->route('inventory.put-away-rules.index'); + } +} diff --git a/erp/app/Modules/Inventory/Http/Controllers/QcChecklistController.php b/erp/app/Modules/Inventory/Http/Controllers/QcChecklistController.php new file mode 100644 index 00000000000..77f8f3d4621 --- /dev/null +++ b/erp/app/Modules/Inventory/Http/Controllers/QcChecklistController.php @@ -0,0 +1,94 @@ +authorize('viewAny', QcChecklist::class); + + $checklists = QcChecklist::with('product') + ->withCount('items') + ->orderByDesc('created_at') + ->paginate(15); + + return Inertia::render('Inventory/QcChecklists/Index', compact('checklists')); + } + + public function create(): Response + { + $this->authorize('create', QcChecklist::class); + + $products = Product::orderBy('name')->get(['id', 'name', 'sku']); + + return Inertia::render('Inventory/QcChecklists/Create', compact('products')); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', QcChecklist::class); + + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'product_id' => ['nullable', Rule::exists('products', 'id')], + 'description' => ['nullable', 'string'], + 'items' => ['required', 'array', 'min:1'], + 'items.*.name' => ['required', 'string', 'max:255'], + 'items.*.is_required' => ['boolean'], + 'items.*.sort_order' => ['nullable', 'integer', 'min:0'], + ]); + + $tenantId = auth()->user()->tenant_id; + + $checklist = QcChecklist::create([ + 'tenant_id' => $tenantId, + 'name' => $validated['name'], + 'product_id' => $validated['product_id'] ?? null, + 'description' => $validated['description'] ?? null, + 'is_active' => true, + ]); + + foreach ($validated['items'] as $item) { + $checklist->items()->create([ + 'tenant_id' => $tenantId, + 'name' => $item['name'], + 'is_required' => $item['is_required'] ?? true, + 'sort_order' => $item['sort_order'] ?? 0, + ]); + } + + return redirect()->route('inventory.qc-checklists.show', $checklist) + ->with('success', 'Checklist created successfully.'); + } + + public function show(QcChecklist $qcChecklist): Response + { + $this->authorize('view', $qcChecklist); + + $qcChecklist->load(['product', 'items']); + + return Inertia::render('Inventory/QcChecklists/Show', [ + 'checklist' => $qcChecklist, + ]); + } + + public function destroy(QcChecklist $qcChecklist): RedirectResponse + { + $this->authorize('delete', $qcChecklist); + + $qcChecklist->delete(); + + return redirect()->route('inventory.qc-checklists.index') + ->with('success', 'Checklist deleted.'); + } +} diff --git a/erp/app/Modules/Inventory/Http/Controllers/QcInspectionController.php b/erp/app/Modules/Inventory/Http/Controllers/QcInspectionController.php new file mode 100644 index 00000000000..1aeca195489 --- /dev/null +++ b/erp/app/Modules/Inventory/Http/Controllers/QcInspectionController.php @@ -0,0 +1,127 @@ +authorize('viewAny', QcInspection::class); + + $inspections = QcInspection::with(['checklist', 'product', 'inspector']) + ->when($request->status, fn ($q) => $q->where('status', $request->status)) + ->orderByDesc('created_at') + ->paginate(15) + ->withQueryString(); + + return Inertia::render('Inventory/QcInspections/Index', [ + 'inspections' => $inspections, + 'filters' => $request->only(['status']), + ]); + } + + public function create(): Response + { + $this->authorize('create', QcInspection::class); + + $checklists = QcChecklist::where('is_active', true)->orderBy('name')->get(['id', 'name']); + $products = Product::orderBy('name')->get(['id', 'name', 'sku']); + + return Inertia::render('Inventory/QcInspections/Create', compact('checklists', 'products')); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', QcInspection::class); + + $validated = $request->validate([ + 'qc_checklist_id' => ['required', Rule::exists('qc_checklists', 'id')], + 'product_id' => ['nullable', Rule::exists('products', 'id')], + 'batch_reference' => ['nullable', 'string', 'max:100'], + 'notes' => ['nullable', 'string'], + ]); + + $tenantId = auth()->user()->tenant_id; + + $inspection = QcInspection::create([ + 'tenant_id' => $tenantId, + 'qc_checklist_id' => $validated['qc_checklist_id'], + 'product_id' => $validated['product_id'] ?? null, + 'batch_reference' => $validated['batch_reference'] ?? null, + 'notes' => $validated['notes'] ?? null, + 'status' => 'pending', + ]); + + $checklist = QcChecklist::with('items')->find($validated['qc_checklist_id']); + foreach ($checklist->items as $item) { + QcInspectionResult::create([ + 'tenant_id' => $tenantId, + 'qc_inspection_id' => $inspection->id, + 'qc_checklist_item_id' => $item->id, + 'result' => 'na', + ]); + } + + return redirect()->route('inventory.qc-inspections.show', $inspection) + ->with('success', 'Inspection created successfully.'); + } + + public function show(QcInspection $qcInspection): Response + { + $this->authorize('view', $qcInspection); + + $qcInspection->load(['checklist.items', 'results.checklistItem', 'product', 'inspector']); + + return Inertia::render('Inventory/QcInspections/Show', [ + 'inspection' => $qcInspection->append('pass_rate'), + ]); + } + + public function destroy(QcInspection $qcInspection): RedirectResponse + { + $this->authorize('delete', $qcInspection); + + $qcInspection->delete(); + + return redirect()->route('inventory.qc-inspections.index') + ->with('success', 'Inspection deleted.'); + } + + public function updateResult(Request $request, QcInspection $qcInspection, QcInspectionResult $result): RedirectResponse + { + $this->authorize('create', $qcInspection); + + $validated = $request->validate([ + 'result' => ['required', Rule::in(['pass', 'fail', 'na'])], + 'notes' => ['nullable', 'string'], + ]); + + $result->update($validated); + + return redirect()->back()->with('success', 'Result updated.'); + } + + public function complete(Request $request, QcInspection $qcInspection): RedirectResponse + { + $this->authorize('create', $qcInspection); + + $validated = $request->validate([ + 'overall_result' => ['required', Rule::in(['pass', 'fail', 'conditional'])], + ]); + + $qcInspection->complete($validated['overall_result']); + + return redirect()->back()->with('success', 'Inspection completed.'); + } +} diff --git a/erp/app/Modules/Inventory/Http/Controllers/QualityAlertController.php b/erp/app/Modules/Inventory/Http/Controllers/QualityAlertController.php new file mode 100644 index 00000000000..09958c3852a --- /dev/null +++ b/erp/app/Modules/Inventory/Http/Controllers/QualityAlertController.php @@ -0,0 +1,140 @@ +authorize('viewAny', QualityAlert::class); + + $alerts = QualityAlert::with(['product']) + ->latest() + ->paginate(20) + ->withQueryString(); + + return Inertia::render('Inventory/QualityAlerts/Index', [ + 'alerts' => $alerts, + ]); + } + + public function create(): Response + { + $this->authorize('create', QualityAlert::class); + + return Inertia::render('Inventory/QualityAlerts/Create'); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', QualityAlert::class); + + $validated = $request->validate([ + 'title' => 'required|string', + 'alert_type' => 'nullable|in:defect,contamination,non-conformance,recall,expiry', + 'severity' => 'nullable|in:low,medium,high,critical', + 'product_id' => 'nullable|exists:products,id', + ]); + + QualityAlert::create([ + 'tenant_id' => app('tenant')->id, + 'created_by' => auth()->id(), + 'reported_by' => auth()->id(), + ...$validated, + ]); + + return redirect()->route('inventory.quality-alerts.index') + ->with('success', 'Quality alert created.'); + } + + public function show(QualityAlert $qualityAlert): Response + { + $this->authorize('view', $qualityAlert); + + $qualityAlert->load(['product']); + + return Inertia::render('Inventory/QualityAlerts/Show', [ + 'alert' => $qualityAlert, + ]); + } + + public function edit(QualityAlert $qualityAlert): Response + { + $this->authorize('update', $qualityAlert); + + $qualityAlert->load(['product']); + + return Inertia::render('Inventory/QualityAlerts/Edit', [ + 'alert' => $qualityAlert, + ]); + } + + public function update(Request $request, QualityAlert $qualityAlert): RedirectResponse + { + $this->authorize('update', $qualityAlert); + + $validated = $request->validate([ + 'title' => 'required|string', + 'alert_type' => 'nullable|in:defect,contamination,non-conformance,recall,expiry', + 'severity' => 'nullable|in:low,medium,high,critical', + 'product_id' => 'nullable|exists:products,id', + ]); + + $qualityAlert->update($validated); + + return redirect()->route('inventory.quality-alerts.index') + ->with('success', 'Quality alert updated.'); + } + + public function destroy(QualityAlert $qualityAlert): RedirectResponse + { + $this->authorize('delete', $qualityAlert); + + $qualityAlert->delete(); + + return redirect()->route('inventory.quality-alerts.index') + ->with('success', 'Quality alert deleted.'); + } + + public function investigate(QualityAlert $qualityAlert): RedirectResponse + { + $this->authorize('investigate', $qualityAlert); + + $qualityAlert->investigate(); + + return redirect()->route('inventory.quality-alerts.index') + ->with('success', 'Quality alert is now under investigation.'); + } + + public function resolve(Request $request, QualityAlert $qualityAlert): RedirectResponse + { + $this->authorize('resolve', $qualityAlert); + + $data = $request->validate([ + 'root_cause' => 'required|string', + 'corrective_action' => 'required|string', + ]); + + $qualityAlert->resolve($data['root_cause'], $data['corrective_action']); + + return redirect()->route('inventory.quality-alerts.index') + ->with('success', 'Quality alert resolved.'); + } + + public function close(QualityAlert $qualityAlert): RedirectResponse + { + $this->authorize('close', $qualityAlert); + + $qualityAlert->close(); + + return redirect()->route('inventory.quality-alerts.index') + ->with('success', 'Quality alert closed.'); + } +} diff --git a/erp/app/Modules/Inventory/Http/Controllers/ReorderController.php b/erp/app/Modules/Inventory/Http/Controllers/ReorderController.php new file mode 100644 index 00000000000..681c88ea704 --- /dev/null +++ b/erp/app/Modules/Inventory/Http/Controllers/ReorderController.php @@ -0,0 +1,87 @@ +authorize('viewAny', Product::class); + $tenantId = $request->user()->tenant_id; + + $suggestions = Product::where('tenant_id', $tenantId) + ->where('is_active', true) + ->where('reorder_point', '>', 0) + ->with(['stockLevels', 'preferredSupplier', 'category']) + ->get() + ->filter(fn ($p) => $p->needsReorder()) + ->map(fn ($p) => [ + 'id' => $p->id, + 'sku' => $p->sku, + 'name' => $p->name, + 'category' => $p->category?->name, + 'total_stock' => round($p->total_stock, 4), + 'reorder_point' => round((float) $p->reorder_point, 4), + 'reorder_quantity' => round((float) $p->reorder_quantity, 4), + 'preferred_supplier' => $p->preferredSupplier?->name, + 'preferred_supplier_id' => $p->preferred_supplier_id, + 'cost_price' => (float) $p->cost_price, + ]) + ->values(); + + $suppliers = Supplier::where('tenant_id', $tenantId)->orderBy('name')->get(['id', 'name']); + $warehouses = Warehouse::where('tenant_id', $tenantId)->orderBy('name')->get(['id', 'name']); + + return Inertia::render('Inventory/Reorder/Index', [ + 'suggestions' => $suggestions, + 'suppliers' => $suppliers, + 'warehouses' => $warehouses, + ]); + } + + public function createPurchaseOrder(Request $request) + { + $this->authorize('create', Product::class); + + $data = $request->validate([ + 'supplier_id' => 'required|exists:suppliers,id', + 'warehouse_id' => 'required|exists:warehouses,id', + 'items' => 'required|array|min:1', + 'items.*.product_id' => 'required|exists:products,id', + 'items.*.quantity' => 'required|numeric|min:0.001', + 'items.*.unit_cost' => 'required|numeric|min:0', + ]); + + $tenantId = $request->user()->tenant_id; + + $po = PurchaseOrder::create([ + 'tenant_id' => $tenantId, + 'supplier_id' => $data['supplier_id'], + 'warehouse_id' => $data['warehouse_id'], + 'status' => 'draft', + 'created_by' => $request->user()->id, + ]); + + foreach ($data['items'] as $item) { + PurchaseOrderItem::create([ + 'purchase_order_id' => $po->id, + 'product_id' => $item['product_id'], + 'quantity' => $item['quantity'], + 'unit_cost' => $item['unit_cost'], + 'received_quantity' => 0, + ]); + } + + return redirect("/inventory/purchase-orders/{$po->id}") + ->with('success', 'Purchase order created from reorder suggestions.'); + } +} diff --git a/erp/app/Modules/Inventory/Http/Controllers/ReorderRuleController.php b/erp/app/Modules/Inventory/Http/Controllers/ReorderRuleController.php new file mode 100644 index 00000000000..2083f40481c --- /dev/null +++ b/erp/app/Modules/Inventory/Http/Controllers/ReorderRuleController.php @@ -0,0 +1,137 @@ +authorize('viewAny', ReorderRule::class); + + $rules = ReorderRule::with(['product']) + ->latest() + ->paginate(20) + ->withQueryString(); + + return Inertia::render('Inventory/ReorderRules/Index', [ + 'rules' => $rules, + ]); + } + + public function create(): Response + { + $this->authorize('create', ReorderRule::class); + + return Inertia::render('Inventory/ReorderRules/Create'); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', ReorderRule::class); + + $validated = $request->validate([ + 'product_id' => 'required|exists:products,id', + 'reorder_point' => 'required|numeric|min:0', + 'reorder_quantity' => 'required|numeric|min:0', + 'warehouse_id' => 'nullable|exists:warehouses,id', + 'max_stock_level' => 'nullable|numeric|min:0', + 'rule_type' => 'nullable|string|in:fixed,dynamic', + 'is_active' => 'nullable|boolean', + ]); + + ReorderRule::create([ + 'tenant_id' => app('tenant')->id, + 'created_by' => auth()->id(), + ...$validated, + ]); + + return redirect()->route('inventory.reorder-rules.index') + ->with('success', 'Reorder rule created.'); + } + + public function show(ReorderRule $reorderRule): Response + { + $this->authorize('view', $reorderRule); + + $reorderRule->load(['product']); + + return Inertia::render('Inventory/ReorderRules/Show', [ + 'rule' => $reorderRule, + ]); + } + + public function edit(ReorderRule $reorderRule): Response + { + $this->authorize('update', $reorderRule); + + $reorderRule->load(['product']); + + return Inertia::render('Inventory/ReorderRules/Edit', [ + 'rule' => $reorderRule, + ]); + } + + public function update(Request $request, ReorderRule $reorderRule): RedirectResponse + { + $this->authorize('update', $reorderRule); + + $validated = $request->validate([ + 'product_id' => 'required|exists:products,id', + 'reorder_point' => 'required|numeric|min:0', + 'reorder_quantity' => 'required|numeric|min:0', + 'warehouse_id' => 'nullable|exists:warehouses,id', + 'max_stock_level' => 'nullable|numeric|min:0', + 'rule_type' => 'nullable|string|in:fixed,dynamic', + 'is_active' => 'nullable|boolean', + ]); + + $reorderRule->update($validated); + + return redirect()->route('inventory.reorder-rules.index') + ->with('success', 'Reorder rule updated.'); + } + + public function destroy(ReorderRule $reorderRule): RedirectResponse + { + $this->authorize('delete', $reorderRule); + + $reorderRule->delete(); + + return redirect()->route('inventory.reorder-rules.index') + ->with('success', 'Reorder rule deleted.'); + } + + public function trigger(ReorderRule $reorderRule): RedirectResponse + { + $this->authorize('trigger', $reorderRule); + + $reorderRule->trigger(); + + return back()->with('success', 'Reorder rule triggered.'); + } + + public function pause(ReorderRule $reorderRule): RedirectResponse + { + $this->authorize('pause', $reorderRule); + + $reorderRule->pause(); + + return back()->with('success', 'Reorder rule paused.'); + } + + public function resume(ReorderRule $reorderRule): RedirectResponse + { + $this->authorize('resume', $reorderRule); + + $reorderRule->resume(); + + return back()->with('success', 'Reorder rule resumed.'); + } +} diff --git a/erp/app/Modules/Inventory/Http/Controllers/ReplenishmentOrderController.php b/erp/app/Modules/Inventory/Http/Controllers/ReplenishmentOrderController.php new file mode 100644 index 00000000000..4ce69e9ab00 --- /dev/null +++ b/erp/app/Modules/Inventory/Http/Controllers/ReplenishmentOrderController.php @@ -0,0 +1,115 @@ +authorize('viewAny', ReplenishmentOrder::class); + + $replenishments = ReplenishmentOrder::with(['product', 'warehouse', 'supplier']) + ->orderByDesc('created_at') + ->paginate(20); + + return Inertia::render('Inventory/Replenishments/Index', compact('replenishments')); + } + + public function create(): Response + { + $this->authorize('create', ReplenishmentOrder::class); + + $products = Product::orderBy('name')->get(['id', 'name', 'sku']); + $warehouses = Warehouse::where('is_active', true)->orderBy('name')->get(['id', 'name']); + $suppliers = Supplier::where('is_active', true)->orderBy('name')->get(['id', 'name']); + + return Inertia::render('Inventory/Replenishments/Create', compact('products', 'warehouses', 'suppliers')); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', ReplenishmentOrder::class); + + $data = $request->validate([ + 'product_id' => 'required|exists:products,id', + 'warehouse_id' => 'required|exists:warehouses,id', + 'qty_needed' => 'required|numeric|min:0.01', + 'qty_to_order' => 'required|numeric|min:0.01', + 'route' => 'required|in:buy,manufacture,resupply', + 'scheduled_date' => 'nullable|date', + 'supplier_id' => 'nullable|exists:suppliers,id', + 'notes' => 'nullable|string', + ]); + + $data['tenant_id'] = app('tenant')->id; + $data['created_by'] = auth()->id(); + + ReplenishmentOrder::create($data); + + return redirect()->route('inventory.replenishments.index'); + } + + public function show(ReplenishmentOrder $replenishment): Response + { + $this->authorize('view', $replenishment); + + $replenishment->load(['product', 'warehouse', 'supplier']); + + return Inertia::render('Inventory/Replenishments/Show', compact('replenishment')); + } + + public function destroy(ReplenishmentOrder $replenishment): RedirectResponse + { + $this->authorize('delete', $replenishment); + + $replenishment->delete(); + + return redirect()->route('inventory.replenishments.index'); + } + + public function confirm(ReplenishmentOrder $replenishment): RedirectResponse + { + $this->authorize('confirm', $replenishment); + + $replenishment->confirm(); + + return redirect()->route('inventory.replenishments.index'); + } + + public function markInProgress(ReplenishmentOrder $replenishment): RedirectResponse + { + $this->authorize('markInProgress', $replenishment); + + $replenishment->markInProgress(); + + return redirect()->route('inventory.replenishments.index'); + } + + public function complete(ReplenishmentOrder $replenishment): RedirectResponse + { + $this->authorize('complete', $replenishment); + + $replenishment->complete(); + + return redirect()->route('inventory.replenishments.index'); + } + + public function cancel(ReplenishmentOrder $replenishment): RedirectResponse + { + $this->authorize('cancel', $replenishment); + + $replenishment->cancel(); + + return redirect()->route('inventory.replenishments.index'); + } +} diff --git a/erp/app/Modules/Inventory/Http/Controllers/RmaRequestController.php b/erp/app/Modules/Inventory/Http/Controllers/RmaRequestController.php new file mode 100644 index 00000000000..05f05af9ee6 --- /dev/null +++ b/erp/app/Modules/Inventory/Http/Controllers/RmaRequestController.php @@ -0,0 +1,111 @@ +orderByDesc('created_at') + ->paginate(20); + + return Inertia::render('Inventory/RmaRequests/Index', compact('rmaRequests')); + } + + public function create(): Response + { + return Inertia::render('Inventory/RmaRequests/Create'); + } + + public function store(Request $request): RedirectResponse + { + $data = $request->validate([ + 'type' => 'required|string|in:customer_return,supplier_return', + 'reason' => 'required|string', + 'contact_name' => 'nullable|string|max:255', + 'reference' => 'nullable|string|max:255', + 'disposition' => 'nullable|string|in:restock,scrap,repair,replace,credit', + 'requested_date' => 'nullable|date', + 'notes' => 'nullable|string', + 'warehouse_id' => 'nullable|exists:warehouses,id', + ]); + + $data['tenant_id'] = app('tenant')->id; + $data['created_by'] = auth()->id(); + + RmaRequest::create($data); + + return redirect()->route('inventory.rma-requests.index'); + } + + public function show(RmaRequest $rmaRequest): Response + { + $rmaRequest->load('items.product', 'warehouse', 'creator', 'approver'); + return Inertia::render('Inventory/RmaRequests/Show', compact('rmaRequest')); + } + + public function edit(RmaRequest $rmaRequest): Response + { + return Inertia::render('Inventory/RmaRequests/Edit', compact('rmaRequest')); + } + + public function update(Request $request, RmaRequest $rmaRequest): RedirectResponse + { + $data = $request->validate([ + 'reason' => 'required|string', + 'contact_name' => 'nullable|string|max:255', + 'reference' => 'nullable|string|max:255', + 'disposition' => 'nullable|string|in:restock,scrap,repair,replace,credit', + 'requested_date' => 'nullable|date', + 'notes' => 'nullable|string', + 'warehouse_id' => 'nullable|exists:warehouses,id', + ]); + + $rmaRequest->update($data); + + return redirect()->route('inventory.rma-requests.index'); + } + + public function destroy(RmaRequest $rmaRequest): RedirectResponse + { + $rmaRequest->delete(); + return redirect()->route('inventory.rma-requests.index'); + } + + public function approve(RmaRequest $rmaRequest): RedirectResponse + { + $rmaRequest->approve(auth()->id()); + return redirect()->route('inventory.rma-requests.index'); + } + + public function receive(RmaRequest $rmaRequest): RedirectResponse + { + $rmaRequest->receive(); + return redirect()->route('inventory.rma-requests.index'); + } + + public function inspect(RmaRequest $rmaRequest): RedirectResponse + { + $rmaRequest->inspect(); + return redirect()->route('inventory.rma-requests.index'); + } + + public function close(RmaRequest $rmaRequest): RedirectResponse + { + $rmaRequest->close(); + return redirect()->route('inventory.rma-requests.index'); + } + + public function reject(RmaRequest $rmaRequest): RedirectResponse + { + $rmaRequest->reject(); + return redirect()->route('inventory.rma-requests.index'); + } +} diff --git a/erp/app/Modules/Inventory/Http/Controllers/SalesOrderController.php b/erp/app/Modules/Inventory/Http/Controllers/SalesOrderController.php new file mode 100644 index 00000000000..3c0958c0c49 --- /dev/null +++ b/erp/app/Modules/Inventory/Http/Controllers/SalesOrderController.php @@ -0,0 +1,141 @@ +authorize('viewAny', SalesOrder::class); + + $orders = SalesOrder::with('customer') + ->when($request->status, fn ($q) => $q->where('status', $request->status)) + ->when($request->customer_id, fn ($q) => $q->where('customer_id', $request->customer_id)) + ->latest() + ->paginate(20) + ->withQueryString(); + + return Inertia::render('Inventory/SalesOrders/Index', [ + 'orders' => $orders, + 'filters' => $request->only(['status', 'customer_id']), + ]); + } + + public function create(): Response + { + $this->authorize('create', SalesOrder::class); + + return Inertia::render('Inventory/SalesOrders/Create', [ + 'customers' => Customer::orderBy('name')->get(['id', 'name']), + 'products' => Product::where('is_active', true)->orderBy('name')->get(['id', 'name', 'sku']), + ]); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', SalesOrder::class); + + $validated = $request->validate([ + 'order_date' => 'required|date', + 'expected_date' => 'nullable|date', + 'currency' => 'nullable|string|max:3', + 'notes' => 'nullable|string', + 'customer_id' => 'nullable|exists:contacts,id', + 'items' => 'required|array|min:1', + 'items.*.description' => 'required|string|max:255', + 'items.*.quantity' => 'required|numeric|min:0.01', + 'items.*.unit_price' => 'required|numeric|min:0', + 'items.*.product_id' => 'nullable|exists:products,id', + ]); + + $so = SalesOrder::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'so_number' => SalesOrder::generateSoNumber(), + 'customer_id' => $validated['customer_id'] ?? null, + 'order_date' => $validated['order_date'], + 'expected_date' => $validated['expected_date'] ?? null, + 'currency' => $validated['currency'] ?? 'USD', + 'notes' => $validated['notes'] ?? null, + 'created_by' => auth()->id(), + 'subtotal' => 0, + 'tax' => 0, + 'total' => 0, + ]); + + foreach ($validated['items'] as $item) { + SalesOrderItem::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'sales_order_id' => $so->id, + 'product_id' => $item['product_id'] ?? null, + 'description' => $item['description'], + 'quantity' => $item['quantity'], + 'unit_price' => $item['unit_price'], + 'shipped_qty' => 0, + ]); + } + + $so->recalculateTotals(); + + return redirect()->route('inventory.sales-orders.show', $so); + } + + public function show(SalesOrder $salesOrder): Response + { + $this->authorize('view', $salesOrder); + $salesOrder->load(['customer', 'items.product', 'createdBy']); + + return Inertia::render('Inventory/SalesOrders/Show', [ + 'order' => $salesOrder, + ]); + } + + public function confirm(SalesOrder $salesOrder): RedirectResponse + { + $this->authorize('update', $salesOrder); + $salesOrder->confirm(); + + return back()->with('success', 'Sales order confirmed.'); + } + + public function ship(SalesOrder $salesOrder): RedirectResponse + { + $this->authorize('update', $salesOrder); + $salesOrder->ship(); + + return back()->with('success', 'Sales order shipped.'); + } + + public function deliver(SalesOrder $salesOrder): RedirectResponse + { + $this->authorize('update', $salesOrder); + $salesOrder->deliver(); + + return back()->with('success', 'Sales order delivered.'); + } + + public function cancel(SalesOrder $salesOrder): RedirectResponse + { + $this->authorize('update', $salesOrder); + $salesOrder->cancel(); + + return back()->with('success', 'Sales order cancelled.'); + } + + public function destroy(SalesOrder $salesOrder): RedirectResponse + { + $this->authorize('delete', $salesOrder); + $salesOrder->delete(); + + return redirect()->route('inventory.sales-orders.index'); + } +} diff --git a/erp/app/Modules/Inventory/Http/Controllers/SerialNumberController.php b/erp/app/Modules/Inventory/Http/Controllers/SerialNumberController.php new file mode 100644 index 00000000000..df68667a0ac --- /dev/null +++ b/erp/app/Modules/Inventory/Http/Controllers/SerialNumberController.php @@ -0,0 +1,72 @@ +when($request->product_id, fn ($q) => $q->where('product_id', $request->product_id)) + ->when($request->status, fn ($q) => $q->where('status', $request->status)) + ->latest() + ->paginate(20) + ->withQueryString(); + + return Inertia::render('Inventory/SerialNumbers/Index', [ + 'serials' => $serials, + 'products' => Product::orderBy('name')->get(['id', 'name', 'sku']), + 'warehouses' => Warehouse::orderBy('name')->get(['id', 'name']), + 'filters' => $request->only(['product_id', 'status']), + ]); + } + + public function store(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'product_id' => 'required|exists:products,id', + 'warehouse_id' => 'required|exists:warehouses,id', + 'serial_number' => 'required|string|max:255|unique:serial_numbers,serial_number', + 'received_date' => 'nullable|date', + 'lot_number_id' => 'nullable|exists:lot_numbers,id', + ]); + + $validated['tenant_id'] = auth()->user()->tenant_id; + + SerialNumber::create($validated); + + return back()->with('success', 'Serial number created successfully.'); + } + + public function show(SerialNumber $serialNumber): Response + { + $serialNumber->load(['product', 'warehouse', 'lot']); + + return Inertia::render('Inventory/SerialNumbers/Show', [ + 'serial' => $serialNumber, + ]); + } + + public function sell(Request $request, SerialNumber $serialNumber): RedirectResponse + { + $serialNumber->sell($request->notes); + + return back()->with('success', 'Serial number marked as sold.'); + } + + public function scrap(Request $request, SerialNumber $serialNumber): RedirectResponse + { + $serialNumber->scrap($request->notes); + + return back()->with('success', 'Serial number scrapped.'); + } +} diff --git a/erp/app/Modules/Inventory/Http/Controllers/ShipmentController.php b/erp/app/Modules/Inventory/Http/Controllers/ShipmentController.php new file mode 100644 index 00000000000..d94fa075c5c --- /dev/null +++ b/erp/app/Modules/Inventory/Http/Controllers/ShipmentController.php @@ -0,0 +1,113 @@ +orderByDesc('created_at') + ->paginate(20); + + return Inertia::render('Inventory/Shipments/Index', compact('shipments')); + } + + public function create(): Response + { + return Inertia::render('Inventory/Shipments/Create'); + } + + public function store(Request $request): RedirectResponse + { + $data = $request->validate([ + 'type' => 'required|string|in:inbound,outbound', + 'carrier' => 'nullable|string|max:255', + 'tracking_number' => 'nullable|string|max:255', + 'service_level' => 'nullable|string|in:standard,express,overnight', + 'origin_address' => 'nullable|string', + 'destination_address' => 'nullable|string', + 'ship_date' => 'nullable|date', + 'estimated_delivery' => 'nullable|date', + 'weight_kg' => 'nullable|numeric|min:0', + 'freight_cost' => 'nullable|numeric|min:0', + 'notes' => 'nullable|string', + 'warehouse_id' => 'nullable|exists:warehouses,id', + ]); + + $data['tenant_id'] = app('tenant')->id; + $data['created_by'] = auth()->id(); + + Shipment::create($data); + + return redirect()->route('inventory.shipments.index'); + } + + public function show(Shipment $shipment): Response + { + $shipment->load('items.product', 'warehouse', 'creator'); + return Inertia::render('Inventory/Shipments/Show', compact('shipment')); + } + + public function edit(Shipment $shipment): Response + { + return Inertia::render('Inventory/Shipments/Edit', compact('shipment')); + } + + public function update(Request $request, Shipment $shipment): RedirectResponse + { + $data = $request->validate([ + 'carrier' => 'nullable|string|max:255', + 'tracking_number' => 'nullable|string|max:255', + 'service_level' => 'nullable|string|in:standard,express,overnight', + 'origin_address' => 'nullable|string', + 'destination_address' => 'nullable|string', + 'ship_date' => 'nullable|date', + 'estimated_delivery' => 'nullable|date', + 'weight_kg' => 'nullable|numeric|min:0', + 'freight_cost' => 'nullable|numeric|min:0', + 'notes' => 'nullable|string', + 'warehouse_id' => 'nullable|exists:warehouses,id', + ]); + + $shipment->update($data); + + return redirect()->route('inventory.shipments.index'); + } + + public function destroy(Shipment $shipment): RedirectResponse + { + $shipment->delete(); + return redirect()->route('inventory.shipments.index'); + } + + public function dispatch(Shipment $shipment): RedirectResponse + { + $shipment->dispatch(); + return redirect()->route('inventory.shipments.index'); + } + + public function deliver(Shipment $shipment): RedirectResponse + { + $shipment->deliver(); + return redirect()->route('inventory.shipments.index'); + } + + public function returnShipment(Shipment $shipment): RedirectResponse + { + $shipment->returnShipment(); + return redirect()->route('inventory.shipments.index'); + } + + public function cancel(Shipment $shipment): RedirectResponse + { + $shipment->cancel(); + return redirect()->route('inventory.shipments.index'); + } +} diff --git a/erp/app/Modules/Inventory/Http/Controllers/StockAdjustmentController.php b/erp/app/Modules/Inventory/Http/Controllers/StockAdjustmentController.php new file mode 100644 index 00000000000..25bc98d13f9 --- /dev/null +++ b/erp/app/Modules/Inventory/Http/Controllers/StockAdjustmentController.php @@ -0,0 +1,101 @@ +authorize('viewAny', StockAdjustment::class); + $adjustments = StockAdjustment::with(['warehouse', 'adjuster']) + ->orderByDesc('created_at') + ->paginate(25); + return Inertia::render('Inventory/StockAdjustments/Index', compact('adjustments')); + } + + public function create(): Response + { + $this->authorize('create', StockAdjustment::class); + $warehouses = Warehouse::orderBy('name')->get(['id', 'name']); + $products = Product::orderBy('name')->get(['id', 'name', 'sku']); + return Inertia::render('Inventory/StockAdjustments/Create', compact('warehouses', 'products')); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', StockAdjustment::class); + $data = $request->validate([ + 'warehouse_id' => 'required|exists:warehouses,id', + 'reference' => 'required|string|max:100|unique:stock_adjustments,reference', + 'reason' => 'required|in:count,damage,theft,expiry,correction,other', + 'notes' => 'nullable|string', + 'items' => 'required|array|min:1', + 'items.*.product_id' => 'required|exists:products,id', + 'items.*.expected_quantity' => 'required|numeric|min:0', + 'items.*.actual_quantity' => 'required|numeric|min:0', + ]); + + $adj = StockAdjustment::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'warehouse_id' => $data['warehouse_id'], + 'reference' => $data['reference'], + 'reason' => $data['reason'], + 'status' => 'draft', + 'notes' => $data['notes'] ?? null, + ]); + + foreach ($data['items'] as $item) { + $adj->items()->create([ + 'product_id' => $item['product_id'], + 'expected_quantity' => $item['expected_quantity'], + 'actual_quantity' => $item['actual_quantity'], + 'difference' => $item['actual_quantity'] - $item['expected_quantity'], + ]); + } + + return redirect()->route('inventory.stock-adjustments.show', $adj) + ->with('success', 'Stock adjustment created.'); + } + + public function show(StockAdjustment $stockAdjustment): Response + { + $this->authorize('view', $stockAdjustment); + $stockAdjustment->load(['warehouse', 'adjuster', 'items.product']); + return Inertia::render('Inventory/StockAdjustments/Show', compact('stockAdjustment')); + } + + public function confirm(StockAdjustment $stockAdjustment): RedirectResponse + { + $this->authorize('update', $stockAdjustment); + /** @var User $user */ + $user = auth()->user(); + $stockAdjustment->confirm($user); + return back()->with('success', 'Adjustment confirmed and stock updated.'); + } + + public function cancel(StockAdjustment $stockAdjustment): RedirectResponse + { + $this->authorize('update', $stockAdjustment); + abort_unless($stockAdjustment->status === 'draft', 422, 'Only draft adjustments can be cancelled.'); + $stockAdjustment->update(['status' => 'cancelled']); + return back()->with('success', 'Adjustment cancelled.'); + } + + public function destroy(StockAdjustment $stockAdjustment): RedirectResponse + { + $this->authorize('delete', $stockAdjustment); + abort_unless($stockAdjustment->status === 'draft', 422, 'Only draft adjustments can be deleted.'); + $stockAdjustment->delete(); + return redirect()->route('inventory.stock-adjustments.index')->with('success', 'Adjustment deleted.'); + } +} diff --git a/erp/app/Modules/Inventory/Http/Controllers/StockMovementController.php b/erp/app/Modules/Inventory/Http/Controllers/StockMovementController.php new file mode 100644 index 00000000000..c8680b22d9d --- /dev/null +++ b/erp/app/Modules/Inventory/Http/Controllers/StockMovementController.php @@ -0,0 +1,59 @@ +when($request->product_id, fn ($q) => $q->where('product_id', $request->product_id)) + ->when($request->warehouse_id, fn ($q) => $q->where('warehouse_id', $request->warehouse_id)) + ->when($request->type, fn ($q) => $q->where('type', $request->type)) + ->when($request->date_from, fn ($q) => $q->whereDate('created_at', '>=', $request->date_from)) + ->when($request->date_to, fn ($q) => $q->whereDate('created_at', '<=', $request->date_to)) + ->latest('created_at') + ->paginate(50) + ->withQueryString(); + + return Inertia::render('Inventory/StockMovements/Index', [ + 'movements' => $movements, + 'products' => Product::active()->orderBy('name')->get(['id', 'name', 'sku']), + 'warehouses' => Warehouse::where('is_active', true)->orderBy('name')->get(['id', 'name']), + 'filters' => $request->only(['product_id', 'warehouse_id', 'type', 'date_from', 'date_to']), + 'breadcrumbs' => [ + ['label' => 'Inventory'], + ['label' => 'Stock Movements', 'href' => route('inventory.stock-movements.index')], + ], + ]); + } + + public function store(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'product_id' => ['required', 'integer', 'exists:products,id'], + 'warehouse_id' => ['required', 'integer', 'exists:warehouses,id'], + 'type' => ['required', 'in:in,out,adjustment'], + 'quantity' => ['required', 'numeric', 'min:0.01'], + 'reference' => ['nullable', 'string', 'max:255'], + 'notes' => ['nullable', 'string'], + ]); + + try { + StockMovement::record($validated); + } catch (\DomainException $e) { + return back()->withErrors(['quantity' => $e->getMessage()]); + } + + return back()->with('success', 'Stock movement recorded.'); + } +} diff --git a/erp/app/Modules/Inventory/Http/Controllers/StockPickingController.php b/erp/app/Modules/Inventory/Http/Controllers/StockPickingController.php new file mode 100644 index 00000000000..c1b0ce216f5 --- /dev/null +++ b/erp/app/Modules/Inventory/Http/Controllers/StockPickingController.php @@ -0,0 +1,145 @@ +authorize('viewAny', StockPicking::class); + + $stockPickings = StockPicking::with('warehouse') + ->withCount('lines') + ->orderByDesc('created_at') + ->paginate(20); + + return Inertia::render('Inventory/StockPickings/Index', compact('stockPickings')); + } + + public function create(): Response + { + $this->authorize('create', StockPicking::class); + + $warehouses = Warehouse::where('is_active', true)->orderBy('name')->get(['id', 'name']); + $zones = WarehouseZone::orderBy('name')->get(['id', 'name', 'warehouse_id']); + + return Inertia::render('Inventory/StockPickings/Create', compact('warehouses', 'zones')); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', StockPicking::class); + + $data = $request->validate([ + 'picking_type' => 'required|in:incoming,outgoing,internal,return', + 'warehouse_id' => 'nullable|exists:warehouses,id', + 'origin' => 'nullable|string|max:255', + 'partner_name' => 'nullable|string|max:255', + 'scheduled_date' => 'nullable|date', + 'notes' => 'nullable|string', + ]); + + $data['tenant_id'] = app('tenant')->id; + $data['created_by'] = auth()->id(); + + StockPicking::create($data); + + return redirect()->route('inventory.stock-pickings.index'); + } + + public function show(StockPicking $stockPicking): Response + { + $this->authorize('view', $stockPicking); + + $stockPicking->load([ + 'lines.product', + 'lines.lot', + 'lines.serial', + 'warehouse', + ]); + + return Inertia::render('Inventory/StockPickings/Show', compact('stockPicking')); + } + + public function edit(StockPicking $stockPicking): Response + { + $this->authorize('update', $stockPicking); + + $warehouses = Warehouse::where('is_active', true)->orderBy('name')->get(['id', 'name']); + $zones = WarehouseZone::orderBy('name')->get(['id', 'name', 'warehouse_id']); + + return Inertia::render('Inventory/StockPickings/Edit', compact('stockPicking', 'warehouses', 'zones')); + } + + public function update(Request $request, StockPicking $stockPicking): RedirectResponse + { + $this->authorize('update', $stockPicking); + + $data = $request->validate([ + 'picking_type' => 'required|in:incoming,outgoing,internal,return', + 'warehouse_id' => 'nullable|exists:warehouses,id', + 'origin' => 'nullable|string|max:255', + 'partner_name' => 'nullable|string|max:255', + 'scheduled_date' => 'nullable|date', + 'notes' => 'nullable|string', + ]); + + $stockPicking->update($data); + + return redirect()->route('inventory.stock-pickings.index'); + } + + public function destroy(StockPicking $stockPicking): RedirectResponse + { + $this->authorize('delete', $stockPicking); + + $stockPicking->delete(); + + return redirect()->route('inventory.stock-pickings.index'); + } + + public function confirm(StockPicking $stockPicking): RedirectResponse + { + $this->authorize('confirm', $stockPicking); + + $stockPicking->confirm(); + + return redirect()->route('inventory.stock-pickings.index'); + } + + public function startProcessing(StockPicking $stockPicking): RedirectResponse + { + $this->authorize('confirm', $stockPicking); + + $stockPicking->startProcessing(); + + return redirect()->route('inventory.stock-pickings.index'); + } + + public function validate(StockPicking $stockPicking): RedirectResponse + { + $this->authorize('validate', $stockPicking); + + $stockPicking->validate(auth()->id()); + + return redirect()->route('inventory.stock-pickings.index'); + } + + public function cancel(StockPicking $stockPicking): RedirectResponse + { + $this->authorize('cancel', $stockPicking); + + $stockPicking->cancel(); + + return redirect()->route('inventory.stock-pickings.index'); + } +} diff --git a/erp/app/Modules/Inventory/Http/Controllers/StockReservationController.php b/erp/app/Modules/Inventory/Http/Controllers/StockReservationController.php new file mode 100644 index 00000000000..a5a4e2913e6 --- /dev/null +++ b/erp/app/Modules/Inventory/Http/Controllers/StockReservationController.php @@ -0,0 +1,134 @@ +authorize('viewAny', StockReservation::class); + + $reservations = StockReservation::latest() + ->paginate(20) + ->withQueryString(); + + return Inertia::render('Inventory/StockReservations/Index', [ + 'reservations' => $reservations, + ]); + } + + public function create(): Response + { + $this->authorize('create', StockReservation::class); + + return Inertia::render('Inventory/StockReservations/Create'); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', StockReservation::class); + + $validated = $request->validate([ + 'product_id' => 'required|exists:products,id', + 'quantity' => 'required|numeric|min:0.01', + 'reserved_until' => 'nullable|date', + 'reference_type' => 'nullable|string', + 'reference_id' => 'nullable|string', + 'notes' => 'nullable|string', + ]); + + $reservation = StockReservation::create([ + 'tenant_id' => app('tenant')->id, + 'reserved_by' => auth()->id(), + ...$validated, + ]); + + $reservation->reservation_number = $reservation->generateReservationNumber(); + $reservation->save(); + + return redirect()->route('inventory.stock-reservations.index'); + } + + public function show(StockReservation $stockReservation): Response + { + $this->authorize('view', $stockReservation); + + return Inertia::render('Inventory/StockReservations/Show', [ + 'reservation' => $stockReservation, + ]); + } + + public function edit(StockReservation $stockReservation): Response + { + $this->authorize('update', $stockReservation); + + return Inertia::render('Inventory/StockReservations/Edit', [ + 'reservation' => $stockReservation, + ]); + } + + public function update(Request $request, StockReservation $stockReservation): RedirectResponse + { + $this->authorize('update', $stockReservation); + + $validated = $request->validate([ + 'product_id' => 'sometimes|exists:products,id', + 'quantity' => 'sometimes|numeric|min:0.01', + 'reserved_until' => 'nullable|date', + 'reference_type' => 'nullable|string', + 'reference_id' => 'nullable|string', + 'notes' => 'nullable|string', + ]); + + $stockReservation->update($validated); + + return redirect()->route('inventory.stock-reservations.index'); + } + + public function destroy(StockReservation $stockReservation): RedirectResponse + { + $this->authorize('delete', $stockReservation); + + $stockReservation->delete(); + + return redirect()->route('inventory.stock-reservations.index'); + } + + public function fulfill(Request $request, StockReservation $stockReservation): RedirectResponse + { + $this->authorize('fulfill', $stockReservation); + + $validated = $request->validate([ + 'quantity' => 'required|numeric|min:0.01', + ]); + + $stockReservation->fulfill((float) $validated['quantity']); + + return redirect()->route('inventory.stock-reservations.index'); + } + + public function cancel(StockReservation $stockReservation): RedirectResponse + { + $this->authorize('cancel', $stockReservation); + + $stockReservation->cancel(); + + return redirect()->route('inventory.stock-reservations.index'); + } + + public function expire(StockReservation $stockReservation): RedirectResponse + { + $this->authorize('expire', $stockReservation); + + $stockReservation->expire(); + + return redirect()->route('inventory.stock-reservations.index'); + } +} diff --git a/erp/app/Modules/Inventory/Http/Controllers/StockTransferController.php b/erp/app/Modules/Inventory/Http/Controllers/StockTransferController.php new file mode 100644 index 00000000000..3b90b72a97a --- /dev/null +++ b/erp/app/Modules/Inventory/Http/Controllers/StockTransferController.php @@ -0,0 +1,105 @@ +authorize('viewAny', StockTransfer::class); + + $transfers = StockTransfer::with(['fromWarehouse', 'toWarehouse']) + ->withCount('items') + ->orderByDesc('created_at') + ->paginate(15); + + return Inertia::render('Inventory/StockTransfers/Index', compact('transfers')); + } + + public function create(): Response + { + $this->authorize('create', StockTransfer::class); + + $warehouses = Warehouse::orderBy('name')->get(['id', 'name']); + $products = Product::orderBy('name')->get(['id', 'name', 'sku']); + + return Inertia::render('Inventory/StockTransfers/Create', compact('warehouses', 'products')); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', StockTransfer::class); + + $data = $request->validate([ + 'from_warehouse_id' => ['required', Rule::exists('warehouses', 'id')], + 'to_warehouse_id' => ['required', Rule::exists('warehouses', 'id'), 'different:from_warehouse_id'], + 'notes' => ['nullable', 'string'], + 'items' => ['required', 'array', 'min:1'], + 'items.*.product_id' => ['required', Rule::exists('products', 'id')], + 'items.*.quantity' => ['required', 'numeric', 'min:0.0001'], + ]); + + $transfer = StockTransfer::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'from_warehouse_id' => $data['from_warehouse_id'], + 'to_warehouse_id' => $data['to_warehouse_id'], + 'status' => 'draft', + 'notes' => $data['notes'] ?? null, + ]); + + foreach ($data['items'] as $item) { + $transfer->items()->create([ + 'tenant_id' => auth()->user()->tenant_id, + 'product_id' => $item['product_id'], + 'quantity' => $item['quantity'], + ]); + } + + return redirect()->route('inventory.stock-transfers.show', $transfer) + ->with('success', 'Stock transfer created.'); + } + + public function show(StockTransfer $stockTransfer): Response + { + $this->authorize('view', $stockTransfer); + $stockTransfer->load(['fromWarehouse', 'toWarehouse', 'items.product']); + + return Inertia::render('Inventory/StockTransfers/Show', compact('stockTransfer')); + } + + public function destroy(StockTransfer $stockTransfer): RedirectResponse + { + $this->authorize('delete', $stockTransfer); + abort_unless($stockTransfer->status === 'draft', 422, 'Only draft transfers can be deleted.'); + $stockTransfer->delete(); + + return redirect()->route('inventory.stock-transfers.index') + ->with('success', 'Transfer deleted.'); + } + + public function complete(StockTransfer $stockTransfer): RedirectResponse + { + $this->authorize('update', $stockTransfer); + $stockTransfer->complete(); + + return back()->with('success', 'Transfer completed and stock updated.'); + } + + public function cancel(StockTransfer $stockTransfer): RedirectResponse + { + $this->authorize('update', $stockTransfer); + $stockTransfer->cancel(); + + return back()->with('success', 'Transfer cancelled.'); + } +} diff --git a/erp/app/Modules/Inventory/Http/Controllers/SupplierContractController.php b/erp/app/Modules/Inventory/Http/Controllers/SupplierContractController.php new file mode 100644 index 00000000000..8cec74034ff --- /dev/null +++ b/erp/app/Modules/Inventory/Http/Controllers/SupplierContractController.php @@ -0,0 +1,66 @@ +when($request->supplier_id, fn ($q) => $q->where('supplier_id', $request->supplier_id)) + ->when($request->status, fn ($q) => $q->where('status', $request->status)) + ->latest() + ->paginate(20) + ->withQueryString(); + + return Inertia::render('Inventory/SupplierContracts/Index', [ + 'contracts' => $contracts, + 'suppliers' => Supplier::orderBy('name')->get(['id', 'name']), + 'filters' => $request->only(['supplier_id', 'status']), + ]); + } + + public function store(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'supplier_id' => 'required|exists:suppliers,id', + 'title' => 'required|string|max:255', + 'contract_number'=> 'nullable|string|max:255', + 'start_date' => 'required|date', + 'end_date' => 'nullable|date|after:start_date', + 'value' => 'nullable|numeric', + 'payment_terms' => 'nullable|string|max:255', + 'status' => 'required|in:active,expired,terminated', + 'terms' => 'nullable|string', + ]); + + $validated['tenant_id'] = auth()->user()->tenant_id; + + SupplierContract::create($validated); + + return back()->with('success', 'Contract created successfully.'); + } + + public function destroy(SupplierContract $supplierContract): RedirectResponse + { + $this->authorize('delete', $supplierContract); + $supplierContract->delete(); + + return back()->with('success', 'Contract deleted.'); + } + + public function terminate(Request $request, SupplierContract $supplierContract): RedirectResponse + { + $supplierContract->update(['status' => 'terminated']); + + return back()->with('success', 'Contract terminated.'); + } +} diff --git a/erp/app/Modules/Inventory/Http/Controllers/SupplierController.php b/erp/app/Modules/Inventory/Http/Controllers/SupplierController.php new file mode 100644 index 00000000000..f808781fd92 --- /dev/null +++ b/erp/app/Modules/Inventory/Http/Controllers/SupplierController.php @@ -0,0 +1,99 @@ +search, fn ($q) => + $q->where('name', 'like', "%{$request->search}%") + ->orWhere('email', 'like', "%{$request->search}%") + ) + ->orderBy('name') + ->paginate(25) + ->withQueryString(); + + return Inertia::render('Inventory/Suppliers/Index', [ + 'suppliers' => SupplierResource::collection($suppliers), + 'filters' => $request->only(['search']), + 'breadcrumbs' => [ + ['label' => 'Inventory'], + ['label' => 'Suppliers', 'href' => route('inventory.suppliers.index')], + ], + ]); + } + + public function create(): Response + { + return Inertia::render('Inventory/Suppliers/Create', [ + 'breadcrumbs' => [ + ['label' => 'Inventory'], + ['label' => 'Suppliers', 'href' => route('inventory.suppliers.index')], + ['label' => 'New Supplier'], + ], + ]); + } + + public function store(StoreSupplierRequest $request): RedirectResponse + { + Supplier::create([...$request->validated(), 'tenant_id' => auth()->user()->tenant_id]); + + return redirect()->route('inventory.suppliers.index') + ->with('success', 'Supplier created successfully.'); + } + + public function show(Supplier $supplier): Response + { + $supplier->load(['reviews', 'contracts']); + + return Inertia::render('Inventory/Suppliers/Show', [ + 'supplier' => array_merge($supplier->toArray(), [ + 'average_rating' => $supplier->average_rating, + ]), + 'breadcrumbs' => [ + ['label' => 'Inventory'], + ['label' => 'Suppliers', 'href' => route('inventory.suppliers.index')], + ['label' => $supplier->name], + ], + ]); + } + + public function edit(Supplier $supplier): Response + { + return Inertia::render('Inventory/Suppliers/Edit', [ + 'supplier' => new SupplierResource($supplier), + 'breadcrumbs' => [ + ['label' => 'Inventory'], + ['label' => 'Suppliers', 'href' => route('inventory.suppliers.index')], + ['label' => $supplier->name, 'href' => route('inventory.suppliers.edit', $supplier)], + ['label' => 'Edit'], + ], + ]); + } + + public function update(StoreSupplierRequest $request, Supplier $supplier): RedirectResponse + { + $supplier->update($request->validated()); + + return redirect()->route('inventory.suppliers.index') + ->with('success', 'Supplier updated successfully.'); + } + + public function destroy(Supplier $supplier): RedirectResponse + { + $supplier->delete(); + + return redirect()->route('inventory.suppliers.index') + ->with('success', 'Supplier deleted.'); + } +} diff --git a/erp/app/Modules/Inventory/Http/Controllers/SupplierReviewController.php b/erp/app/Modules/Inventory/Http/Controllers/SupplierReviewController.php new file mode 100644 index 00000000000..17917784c4a --- /dev/null +++ b/erp/app/Modules/Inventory/Http/Controllers/SupplierReviewController.php @@ -0,0 +1,58 @@ +when($request->supplier_id, fn ($q) => $q->where('supplier_id', $request->supplier_id)) + ->latest() + ->paginate(20) + ->withQueryString(); + + return Inertia::render('Inventory/SupplierReviews/Index', [ + 'reviews' => $reviews, + 'suppliers' => Supplier::orderBy('name')->get(['id', 'name']), + 'filters' => $request->only(['supplier_id']), + ]); + } + + public function store(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'supplier_id' => 'required|exists:suppliers,id', + 'review_date' => 'required|date', + 'quality_score' => 'required|integer|min:1|max:5', + 'delivery_score' => 'required|integer|min:1|max:5', + 'communication_score' => 'required|integer|min:1|max:5', + 'price_score' => 'required|integer|min:1|max:5', + 'notes' => 'nullable|string', + 'purchase_order_id' => 'nullable|integer', + ]); + + $validated['reviewed_by'] = auth()->id(); + $validated['tenant_id'] = auth()->user()->tenant_id; + + SupplierReview::create($validated); + + return back()->with('success', 'Review added successfully.'); + } + + public function destroy(SupplierReview $supplierReview): RedirectResponse + { + $this->authorize('delete', $supplierReview); + $supplierReview->delete(); + + return back()->with('success', 'Review deleted.'); + } +} diff --git a/erp/app/Modules/Inventory/Http/Controllers/SupplierScorecardController.php b/erp/app/Modules/Inventory/Http/Controllers/SupplierScorecardController.php new file mode 100644 index 00000000000..6402655cd86 --- /dev/null +++ b/erp/app/Modules/Inventory/Http/Controllers/SupplierScorecardController.php @@ -0,0 +1,116 @@ +authorize('viewAny', SupplierScorecard::class); + + $scorecards = SupplierScorecard::latest() + ->paginate(20) + ->withQueryString(); + + return Inertia::render('Inventory/SupplierScorecards/Index', [ + 'scorecards' => $scorecards, + 'filters' => $request->only(['supplier_name', 'period', 'status']), + ]); + } + + public function create(): Response + { + $this->authorize('create', SupplierScorecard::class); + + return Inertia::render('Inventory/SupplierScorecards/Create'); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', SupplierScorecard::class); + + $validated = $request->validate([ + 'supplier_name' => 'required|string', + 'period' => 'required|string', + 'supplier_code' => 'nullable|string', + 'quality_score' => 'nullable|numeric|min:0|max:100', + 'delivery_score' => 'nullable|numeric|min:0|max:100', + 'pricing_score' => 'nullable|numeric|min:0|max:100', + 'service_score' => 'nullable|numeric|min:0|max:100', + 'notes' => 'nullable|string', + ]); + + $validated['tenant_id'] = app('tenant')->id; + + SupplierScorecard::create($validated); + + return redirect()->route('inventory.supplier-scorecards.index') + ->with('success', 'Supplier scorecard created.'); + } + + public function show(SupplierScorecard $supplierScorecard): Response + { + $this->authorize('view', $supplierScorecard); + + return Inertia::render('Inventory/SupplierScorecards/Show', [ + 'scorecard' => $supplierScorecard, + ]); + } + + public function edit(SupplierScorecard $supplierScorecard): Response + { + $this->authorize('update', $supplierScorecard); + + return Inertia::render('Inventory/SupplierScorecards/Edit', [ + 'scorecard' => $supplierScorecard, + ]); + } + + public function update(Request $request, SupplierScorecard $supplierScorecard): RedirectResponse + { + $this->authorize('update', $supplierScorecard); + + $validated = $request->validate([ + 'supplier_name' => 'required|string', + 'period' => 'required|string', + 'supplier_code' => 'nullable|string', + 'quality_score' => 'nullable|numeric|min:0|max:100', + 'delivery_score' => 'nullable|numeric|min:0|max:100', + 'pricing_score' => 'nullable|numeric|min:0|max:100', + 'service_score' => 'nullable|numeric|min:0|max:100', + 'notes' => 'nullable|string', + ]); + + $supplierScorecard->update($validated); + + return redirect()->route('inventory.supplier-scorecards.index') + ->with('success', 'Supplier scorecard updated.'); + } + + public function publish(SupplierScorecard $supplierScorecard): RedirectResponse + { + $this->authorize('publish', $supplierScorecard); + + $supplierScorecard->publish(auth()->id()); + + return redirect()->route('inventory.supplier-scorecards.index') + ->with('success', 'Supplier scorecard published.'); + } + + public function destroy(SupplierScorecard $supplierScorecard): RedirectResponse + { + $this->authorize('delete', $supplierScorecard); + + $supplierScorecard->delete(); + + return redirect()->route('inventory.supplier-scorecards.index') + ->with('success', 'Supplier scorecard deleted.'); + } +} diff --git a/erp/app/Modules/Inventory/Http/Controllers/TraceabilityController.php b/erp/app/Modules/Inventory/Http/Controllers/TraceabilityController.php new file mode 100644 index 00000000000..5a3f4b26d35 --- /dev/null +++ b/erp/app/Modules/Inventory/Http/Controllers/TraceabilityController.php @@ -0,0 +1,44 @@ +get('lot_id'); + $serialId = $request->get('serial_id'); + $tenantId = auth()->user()->tenant_id; + + $lots = LotNumber::where('tenant_id', $tenantId)->with('product')->orderBy('lot_number')->get(['id', 'lot_number', 'product_id']); + $serials = SerialNumber::where('tenant_id', $tenantId)->with('product')->orderBy('serial_number')->get(['id', 'serial_number', 'product_id']); + + $movements = collect(); + $trackedItem = null; + + if ($lotId) { + $trackedItem = LotNumber::with('product')->find($lotId); + $movements = StockMovement::where('tenant_id', $tenantId) + ->where('lot_id', $lotId) + ->with(['product', 'warehouse']) + ->orderBy('created_at') + ->get(); + } elseif ($serialId) { + $trackedItem = SerialNumber::with('product')->find($serialId); + $movements = StockMovement::where('tenant_id', $tenantId) + ->where('serial_id', $serialId) + ->with(['product', 'warehouse']) + ->orderBy('created_at') + ->get(); + } + + return Inertia::render('Inventory/Traceability/Index', compact('lots', 'serials', 'movements', 'trackedItem', 'lotId', 'serialId')); + } +} diff --git a/erp/app/Modules/Inventory/Http/Controllers/UnitOfMeasureController.php b/erp/app/Modules/Inventory/Http/Controllers/UnitOfMeasureController.php new file mode 100644 index 00000000000..4959feb6fa9 --- /dev/null +++ b/erp/app/Modules/Inventory/Http/Controllers/UnitOfMeasureController.php @@ -0,0 +1,86 @@ +authorize('viewAny', UnitOfMeasure::class); + + $query = UnitOfMeasure::query(); + + if ($request->filled('type')) { + $query->where('type', $request->input('type')); + } + + $units = $query->latest()->paginate(20)->withQueryString(); + + return Inertia::render('Inventory/UnitsOfMeasure/Index', [ + 'units' => $units, + ]); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', UnitOfMeasure::class); + + $validated = $request->validate([ + 'name' => 'required|string|max:100', + 'abbreviation' => 'required|string|max:20', + 'type' => 'nullable|string|max:50', + 'is_base' => 'boolean', + 'conversion_factor' => 'nullable|numeric|min:0', + 'is_active' => 'boolean', + ]); + + $validated['tenant_id'] = app('tenant')->id; + + UnitOfMeasure::create($validated); + + return back(); + } + + public function show(UnitOfMeasure $unitsOfMeasure): Response + { + $this->authorize('view', $unitsOfMeasure); + + return Inertia::render('Inventory/UnitsOfMeasure/Show', [ + 'unit' => $unitsOfMeasure, + ]); + } + + public function update(Request $request, UnitOfMeasure $unitsOfMeasure): RedirectResponse + { + $this->authorize('update', $unitsOfMeasure); + + $validated = $request->validate([ + 'name' => 'required|string|max:100', + 'abbreviation' => 'required|string|max:20', + 'type' => 'nullable|string|max:50', + 'is_base' => 'boolean', + 'conversion_factor' => 'nullable|numeric|min:0', + 'is_active' => 'boolean', + ]); + + $unitsOfMeasure->update($validated); + + return back(); + } + + public function destroy(UnitOfMeasure $unitsOfMeasure): RedirectResponse + { + $this->authorize('delete', $unitsOfMeasure); + + $unitsOfMeasure->delete(); + + return redirect()->route('inventory.units-of-measure.index'); + } +} diff --git a/erp/app/Modules/Inventory/Http/Controllers/VehicleController.php b/erp/app/Modules/Inventory/Http/Controllers/VehicleController.php new file mode 100644 index 00000000000..7c2d093abc5 --- /dev/null +++ b/erp/app/Modules/Inventory/Http/Controllers/VehicleController.php @@ -0,0 +1,144 @@ +authorize('viewAny', Vehicle::class); + + $vehicles = Vehicle::with(['assignedEmployee']) + ->when($request->status, fn ($q) => $q->where('status', $request->status)) + ->orderBy('registration') + ->paginate(20) + ->withQueryString(); + + return Inertia::render('Inventory/Vehicles/Index', [ + 'vehicles' => $vehicles, + 'filters' => $request->only(['status']), + 'statusOptions' => ['available', 'in_use', 'maintenance', 'retired'], + ]); + } + + public function create(): Response + { + $this->authorize('create', Vehicle::class); + + return Inertia::render('Inventory/Vehicles/Create'); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', Vehicle::class); + + $validated = $request->validate([ + 'registration' => ['required', 'string', 'max:50', Rule::unique('vehicles')], + 'make' => ['required', 'string', 'max:100'], + 'model' => ['required', 'string', 'max:100'], + 'year' => ['nullable', 'integer', 'min:1900', 'max:2100'], + 'vin' => ['nullable', 'string', 'max:100'], + 'colour' => ['nullable', 'string', 'max:50'], + 'fuel_type' => ['nullable', Rule::in(['petrol', 'diesel', 'electric', 'hybrid'])], + 'odometer_km' => ['nullable', 'numeric', 'min:0'], + 'status' => ['nullable', Rule::in(['available', 'in_use', 'maintenance', 'retired'])], + 'insurance_expiry' => ['nullable', 'date'], + 'registration_expiry' => ['nullable', 'date'], + 'notes' => ['nullable', 'string'], + ]); + + $vehicle = Vehicle::create([...$validated, 'tenant_id' => auth()->user()->tenant_id]); + + return redirect()->route('inventory.vehicles.show', $vehicle) + ->with('success', 'Vehicle created successfully.'); + } + + public function show(Vehicle $vehicle): Response + { + $this->authorize('view', $vehicle); + + $vehicle->load(['logs', 'assignedEmployee']); + + return Inertia::render('Inventory/Vehicles/Show', [ + 'vehicle' => $vehicle->append(['is_insurance_expiring', 'is_registration_expiring', 'total_distance']), + ]); + } + + public function destroy(Vehicle $vehicle): RedirectResponse + { + $this->authorize('delete', $vehicle); + + $vehicle->delete(); + + return redirect()->route('inventory.vehicles.index') + ->with('success', 'Vehicle deleted.'); + } + + public function assign(Request $request, Vehicle $vehicle): RedirectResponse + { + $this->authorize('update', $vehicle); + + $validated = $request->validate([ + 'employee_id' => ['required', 'integer', Rule::exists('employees', 'id')], + ]); + + $vehicle->assign($validated['employee_id']); + + return redirect()->back()->with('success', 'Vehicle assigned.'); + } + + public function unassign(Request $request, Vehicle $vehicle): RedirectResponse + { + $this->authorize('update', $vehicle); + + $vehicle->unassign(); + + return redirect()->back()->with('success', 'Vehicle unassigned.'); + } + + public function retire(Request $request, Vehicle $vehicle): RedirectResponse + { + $this->authorize('update', $vehicle); + + $vehicle->retire(); + + return redirect()->back()->with('success', 'Vehicle retired.'); + } + + public function addLog(Request $request, Vehicle $vehicle): RedirectResponse + { + $this->authorize('update', $vehicle); + + $validated = $request->validate([ + 'log_type' => ['required', Rule::in(['trip', 'refuel', 'maintenance', 'inspection'])], + 'log_date' => ['required', 'date'], + 'odometer_start' => ['nullable', 'numeric', 'min:0'], + 'odometer_end' => ['nullable', 'numeric', 'min:0'], + 'distance_km' => ['nullable', 'numeric', 'min:0'], + 'fuel_litres' => ['nullable', 'numeric', 'min:0'], + 'cost' => ['nullable', 'numeric', 'min:0'], + 'driver_name' => ['nullable', 'string', 'max:255'], + 'destination' => ['nullable', 'string', 'max:255'], + 'purpose' => ['nullable', 'string', 'max:255'], + 'notes' => ['nullable', 'string'], + ]); + + if (isset($validated['odometer_end']) && $validated['odometer_end'] > $vehicle->odometer_km) { + $vehicle->odometer_km = $validated['odometer_end']; + $vehicle->save(); + } + + VehicleLog::create([...$validated, 'tenant_id' => auth()->user()->tenant_id, 'vehicle_id' => $vehicle->id]); + + return redirect()->back()->with('success', 'Log added.'); + } +} diff --git a/erp/app/Modules/Inventory/Http/Controllers/WarehouseBinController.php b/erp/app/Modules/Inventory/Http/Controllers/WarehouseBinController.php new file mode 100644 index 00000000000..b167ef8934d --- /dev/null +++ b/erp/app/Modules/Inventory/Http/Controllers/WarehouseBinController.php @@ -0,0 +1,127 @@ +authorize('viewAny', WarehouseBin::class); + + $query = WarehouseBin::with(['zone', 'warehouse']); + + if ($request->filled('warehouse_id')) { + $query->where('warehouse_id', $request->input('warehouse_id')); + } + + $bins = $query->orderBy('code')->paginate(20)->withQueryString(); + + return Inertia::render('Inventory/WarehouseBins/Index', [ + 'bins' => $bins, + 'filters' => $request->only('warehouse_id'), + ]); + } + + public function create(): Response + { + $this->authorize('create', WarehouseBin::class); + + $warehouses = Warehouse::where('is_active', true)->orderBy('name')->get(['id', 'name']); + $zones = WarehouseZone::where('is_active', true)->orderBy('name')->get(['id', 'warehouse_id', 'name', 'code']); + + return Inertia::render('Inventory/WarehouseBins/Create', compact('warehouses', 'zones')); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', WarehouseBin::class); + + $data = $request->validate([ + 'warehouse_id' => ['required', 'exists:warehouses,id'], + 'zone_id' => ['nullable', 'exists:warehouse_zones,id'], + 'code' => ['required', 'string', 'max:30'], + 'name' => ['nullable', 'string'], + 'bin_type' => ['required', 'in:standard,cold,hazmat,oversize'], + 'capacity' => ['nullable', 'numeric', 'min:0'], + 'is_active' => ['boolean'], + ]); + + $data['tenant_id'] = app('tenant')->id; + + $bin = WarehouseBin::create($data); + + return redirect()->route('inventory.warehouse-bins.show', $bin); + } + + public function show(WarehouseBin $warehouseBin): Response + { + $this->authorize('view', $warehouseBin); + + $warehouseBin->load(['stockLocations.product', 'zone', 'warehouse']); + $warehouseBin->append(['used_capacity', 'available_capacity']); + + return Inertia::render('Inventory/WarehouseBins/Show', [ + 'bin' => $warehouseBin, + ]); + } + + public function destroy(WarehouseBin $warehouseBin): RedirectResponse + { + $this->authorize('delete', $warehouseBin); + + $warehouseBin->delete(); + + return redirect()->route('inventory.warehouse-bins.index'); + } + + public function addStock(Request $request, WarehouseBin $warehouseBin): RedirectResponse + { + $this->authorize('create', $warehouseBin); + + $data = $request->validate([ + 'product_id' => ['required', 'exists:products,id'], + 'quantity' => ['required', 'numeric', 'min:0.0001'], + 'lot_number' => ['nullable', 'string', 'max:50'], + 'expiry_date' => ['nullable', 'date'], + ]); + + $location = BinStockLocation::where('bin_id', $warehouseBin->id) + ->where('product_id', $data['product_id']) + ->where('lot_number', $data['lot_number'] ?? null) + ->first(); + + if ($location) { + $location->increment('quantity', $data['quantity']); + } else { + BinStockLocation::create([ + 'tenant_id' => app('tenant')->id, + 'bin_id' => $warehouseBin->id, + 'product_id' => $data['product_id'], + 'quantity' => $data['quantity'], + 'lot_number' => $data['lot_number'] ?? null, + 'expiry_date' => $data['expiry_date'] ?? null, + ]); + } + + return back()->with('success', 'Stock added successfully.'); + } + + public function removeStock(WarehouseBin $warehouseBin, BinStockLocation $location): RedirectResponse + { + $this->authorize('delete', $warehouseBin); + + $location->delete(); + + return back(); + } +} diff --git a/erp/app/Modules/Inventory/Http/Controllers/WarehouseController.php b/erp/app/Modules/Inventory/Http/Controllers/WarehouseController.php new file mode 100644 index 00000000000..08594aa8d32 --- /dev/null +++ b/erp/app/Modules/Inventory/Http/Controllers/WarehouseController.php @@ -0,0 +1,61 @@ +orderBy('name') + ->get(); + + return Inertia::render('Inventory/Warehouses/Index', [ + 'warehouses' => $warehouses, + 'breadcrumbs' => [ + ['label' => 'Inventory'], + ['label' => 'Warehouses', 'href' => route('inventory.warehouses.index')], + ], + ]); + } + + public function store(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'location' => ['nullable', 'string', 'max:255'], + 'is_active' => ['boolean'], + ]); + + Warehouse::create([...$validated, 'tenant_id' => auth()->user()->tenant_id]); + + return back()->with('success', 'Warehouse created.'); + } + + public function update(Request $request, Warehouse $warehouse): RedirectResponse + { + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'location' => ['nullable', 'string', 'max:255'], + 'is_active' => ['boolean'], + ]); + + $warehouse->update($validated); + + return back()->with('success', 'Warehouse updated.'); + } + + public function destroy(Warehouse $warehouse): RedirectResponse + { + $warehouse->delete(); + + return back()->with('success', 'Warehouse deleted.'); + } +} diff --git a/erp/app/Modules/Inventory/Http/Controllers/WarehouseStockController.php b/erp/app/Modules/Inventory/Http/Controllers/WarehouseStockController.php new file mode 100644 index 00000000000..11600a5095c --- /dev/null +++ b/erp/app/Modules/Inventory/Http/Controllers/WarehouseStockController.php @@ -0,0 +1,55 @@ +authorize('viewAny', WarehouseStock::class); + + $query = WarehouseStock::with(['product', 'warehouse']); + + if ($request->filled('warehouse_id')) { + $query->where('warehouse_id', $request->input('warehouse_id')); + } + + $stocks = $query->orderBy('warehouse_id')->orderBy('product_id')->paginate(20)->withQueryString(); + $warehouses = Warehouse::orderBy('name')->get(['id', 'name']); + + return Inertia::render('Inventory/WarehouseStock/Index', [ + 'stocks' => $stocks, + 'warehouses' => $warehouses, + 'warehouse_id' => $request->input('warehouse_id'), + ]); + } + + public function show(WarehouseStock $warehouseStock): Response + { + $this->authorize('view', $warehouseStock); + $warehouseStock->load(['product', 'warehouse']); + + return Inertia::render('Inventory/WarehouseStock/Show', compact('warehouseStock')); + } + + public function update(Request $request, WarehouseStock $warehouseStock): RedirectResponse + { + $this->authorize('update', $warehouseStock); + + $data = $request->validate([ + 'reorder_point' => ['nullable', 'numeric', 'min:0'], + ]); + + $warehouseStock->update(['reorder_point' => $data['reorder_point']]); + + return back()->with('success', 'Reorder point updated.'); + } +} diff --git a/erp/app/Modules/Inventory/Http/Controllers/WarehouseTransferController.php b/erp/app/Modules/Inventory/Http/Controllers/WarehouseTransferController.php new file mode 100644 index 00000000000..04e63216a8e --- /dev/null +++ b/erp/app/Modules/Inventory/Http/Controllers/WarehouseTransferController.php @@ -0,0 +1,65 @@ +authorize('viewAny', WarehouseTransfer::class); + $tenantId = $request->user()->tenant_id; + $transfers = WarehouseTransfer::where('tenant_id', $tenantId) + ->with(['product', 'fromWarehouse', 'toWarehouse']) + ->latest() + ->paginate(50); + return Inertia::render('Inventory/WarehouseTransfers/Index', ['transfers' => $transfers]); + } + + public function create(Request $request) + { + $this->authorize('create', WarehouseTransfer::class); + $tenantId = $request->user()->tenant_id; + $products = Product::where('tenant_id', $tenantId)->where('is_active', true)->orderBy('name')->get(['id', 'name', 'sku']); + $warehouses = Warehouse::where('tenant_id', $tenantId)->orderBy('name')->get(['id', 'name']); + return Inertia::render('Inventory/WarehouseTransfers/Create', compact('products', 'warehouses')); + } + + public function store(Request $request) + { + $this->authorize('create', WarehouseTransfer::class); + + $data = $request->validate([ + 'product_id' => 'required|exists:products,id', + 'from_warehouse_id' => 'required|exists:warehouses,id', + 'to_warehouse_id' => 'required|exists:warehouses,id|different:from_warehouse_id', + 'quantity' => 'required|numeric|min:0.0001', + 'reference' => 'nullable|string|max:100', + 'notes' => 'nullable|string|max:500', + ]); + + $data['tenant_id'] = $request->user()->tenant_id; + + // Bind tenant so StockMovement::record() can resolve tenant_id for stock levels + if (!app()->has('tenant')) { + $tenant = \App\Modules\Core\Models\Tenant::find($data['tenant_id']); + if ($tenant) { + app()->instance('tenant', $tenant); + } + } + + try { + WarehouseTransfer::execute($data); + } catch (\DomainException $e) { + return back()->withErrors(['quantity' => $e->getMessage()]); + } + + return redirect('/inventory/warehouse-transfers')->with('success', 'Transfer completed.'); + } +} diff --git a/erp/app/Modules/Inventory/Http/Controllers/WarehouseZoneController.php b/erp/app/Modules/Inventory/Http/Controllers/WarehouseZoneController.php new file mode 100644 index 00000000000..d1cf1448c9f --- /dev/null +++ b/erp/app/Modules/Inventory/Http/Controllers/WarehouseZoneController.php @@ -0,0 +1,61 @@ +authorize('viewAny', WarehouseBin::class); + + $query = WarehouseZone::with('warehouse')->withCount('bins'); + + if ($request->filled('warehouse_id')) { + $query->where('warehouse_id', $request->input('warehouse_id')); + } + + $zones = $query->orderBy('name')->paginate(20)->withQueryString(); + $warehouses = Warehouse::where('is_active', true)->orderBy('name')->get(['id', 'name']); + + return Inertia::render('Inventory/WarehouseZones/Index', [ + 'zones' => $zones, + 'filters' => $request->only('warehouse_id'), + 'warehouses' => $warehouses, + ]); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', WarehouseBin::class); + + $data = $request->validate([ + 'warehouse_id' => ['required', 'exists:warehouses,id'], + 'name' => ['required', 'string'], + 'code' => ['required', 'string', 'max:20'], + ]); + + $data['tenant_id'] = app('tenant')->id; + + WarehouseZone::create($data); + + return back()->with('success', 'Zone created successfully.'); + } + + public function destroy(WarehouseZone $warehouseZone): RedirectResponse + { + $this->authorize('delete', WarehouseBin::class); + + $warehouseZone->delete(); + + return back(); + } +} diff --git a/erp/app/Modules/Inventory/Http/Controllers/WarrantyClaimController.php b/erp/app/Modules/Inventory/Http/Controllers/WarrantyClaimController.php new file mode 100644 index 00000000000..b81ead64e97 --- /dev/null +++ b/erp/app/Modules/Inventory/Http/Controllers/WarrantyClaimController.php @@ -0,0 +1,96 @@ +orderByDesc('claim_date') + ->paginate(20); + + return Inertia::render('Inventory/WarrantyClaims/Index', compact('warrantyClaims')); + } + + public function create(): Response + { + $warranties = ProductWarranty::with('product')->orderBy('name')->get() + ->map(fn ($w) => [ + 'id' => $w->id, + 'name' => $w->name, + 'product' => $w->product ? ['id' => $w->product->id, 'name' => $w->product->name] : null, + ]); + + return Inertia::render('Inventory/WarrantyClaims/Create', compact('warranties')); + } + + public function store(Request $request): RedirectResponse + { + $data = $request->validate([ + 'claim_date' => 'required|date', + 'customer_name' => 'required|string|max:255', + 'product_warranty_id' => 'required|exists:product_warranties,id', + 'serial_number_id' => 'nullable|exists:serial_numbers,id', + 'customer_email' => 'nullable|email|max:255', + 'customer_phone' => 'nullable|string|max:50', + 'purchase_date' => 'nullable|date', + 'warranty_expiry' => 'nullable|date', + 'issue_description' => 'required|string', + ]); + + $data['tenant_id'] = app('tenant')->id; + $data['created_by'] = auth()->id(); + + WarrantyClaim::create($data); + + return redirect()->route('inventory.warranty-claims.index'); + } + + public function show(WarrantyClaim $warrantyClaim): Response + { + $warrantyClaim->load('warranty.product', 'serialNumber', 'creator', 'assignee'); + + return Inertia::render('Inventory/WarrantyClaims/Show', compact('warrantyClaim')); + } + + public function destroy(WarrantyClaim $warrantyClaim): RedirectResponse + { + $warrantyClaim->delete(); + + return redirect()->route('inventory.warranty-claims.index'); + } + + public function approve(WarrantyClaim $warrantyClaim): RedirectResponse + { + $warrantyClaim->approve(); + + return redirect()->route('inventory.warranty-claims.index'); + } + + public function reject(WarrantyClaim $warrantyClaim): RedirectResponse + { + $warrantyClaim->reject(); + + return redirect()->route('inventory.warranty-claims.index'); + } + + public function resolve(Request $request, WarrantyClaim $warrantyClaim): RedirectResponse + { + $data = $request->validate([ + 'resolution_type' => 'required|string|in:repair,replace,refund,reject', + 'resolution_notes' => 'nullable|string', + ]); + + $warrantyClaim->resolve($data['resolution_type'], $data['resolution_notes'] ?? null); + + return redirect()->route('inventory.warranty-claims.index'); + } +} diff --git a/erp/app/Modules/Inventory/Http/Requests/ReceivePurchaseOrderRequest.php b/erp/app/Modules/Inventory/Http/Requests/ReceivePurchaseOrderRequest.php new file mode 100644 index 00000000000..9fd96d33c43 --- /dev/null +++ b/erp/app/Modules/Inventory/Http/Requests/ReceivePurchaseOrderRequest.php @@ -0,0 +1,19 @@ + ['required', 'array', 'min:1'], + 'lines.*.id' => ['required', 'integer'], + 'lines.*.received_quantity' => ['required', 'numeric', 'min:0'], + ]; + } +} diff --git a/erp/app/Modules/Inventory/Http/Requests/StoreProductRequest.php b/erp/app/Modules/Inventory/Http/Requests/StoreProductRequest.php new file mode 100644 index 00000000000..f89abc3b04e --- /dev/null +++ b/erp/app/Modules/Inventory/Http/Requests/StoreProductRequest.php @@ -0,0 +1,32 @@ +user()?->tenant_id; + + return [ + 'sku' => ['required', 'string', 'max:100', + Rule::unique('products')->where('tenant_id', $tenantId), + ], + 'name' => ['required', 'string', 'max:255'], + 'description' => ['nullable', 'string'], + 'category_id' => ['nullable', 'integer', 'exists:product_categories,id'], + 'uom_id' => ['nullable', 'integer', 'exists:units_of_measure,id'], + 'cost_price' => ['required', 'numeric', 'min:0'], + 'sale_price' => ['required', 'numeric', 'min:0'], + 'reorder_point' => ['nullable', 'numeric', 'min:0'], + 'reorder_quantity' => ['nullable', 'numeric', 'min:0'], + 'preferred_supplier_id' => ['nullable', 'integer', 'exists:suppliers,id'], + 'is_active' => ['boolean'], + ]; + } +} diff --git a/erp/app/Modules/Inventory/Http/Requests/StorePurchaseOrderRequest.php b/erp/app/Modules/Inventory/Http/Requests/StorePurchaseOrderRequest.php new file mode 100644 index 00000000000..a8e19e9f68c --- /dev/null +++ b/erp/app/Modules/Inventory/Http/Requests/StorePurchaseOrderRequest.php @@ -0,0 +1,24 @@ + ['required', 'integer', 'exists:suppliers,id'], + 'warehouse_id' => ['required', 'integer', 'exists:warehouses,id'], + 'expected_date' => ['nullable', 'date', 'after_or_equal:today'], + 'notes' => ['nullable', 'string'], + 'items' => ['required', 'array', 'min:1'], + 'items.*.product_id' => ['required', 'integer', 'exists:products,id'], + 'items.*.quantity' => ['required', 'numeric', 'min:0.01'], + 'items.*.unit_cost' => ['required', 'numeric', 'min:0'], + ]; + } +} diff --git a/erp/app/Modules/Inventory/Http/Requests/StoreSupplierRequest.php b/erp/app/Modules/Inventory/Http/Requests/StoreSupplierRequest.php new file mode 100644 index 00000000000..441f1bb3954 --- /dev/null +++ b/erp/app/Modules/Inventory/Http/Requests/StoreSupplierRequest.php @@ -0,0 +1,22 @@ + ['required', 'string', 'max:255'], + 'contact_person' => ['nullable', 'string', 'max:255'], + 'email' => ['nullable', 'email', 'max:255'], + 'phone' => ['nullable', 'string', 'max:50'], + 'address' => ['nullable', 'string'], + 'is_active' => ['boolean'], + ]; + } +} diff --git a/erp/app/Modules/Inventory/Http/Requests/UpdateProductRequest.php b/erp/app/Modules/Inventory/Http/Requests/UpdateProductRequest.php new file mode 100644 index 00000000000..edd7475bbd9 --- /dev/null +++ b/erp/app/Modules/Inventory/Http/Requests/UpdateProductRequest.php @@ -0,0 +1,33 @@ +user()?->tenant_id; + $productId = $this->route('product'); + + return [ + 'sku' => ['required', 'string', 'max:100', + Rule::unique('products')->where('tenant_id', $tenantId)->ignore($productId), + ], + 'name' => ['required', 'string', 'max:255'], + 'description' => ['nullable', 'string'], + 'category_id' => ['nullable', 'integer', 'exists:product_categories,id'], + 'uom_id' => ['nullable', 'integer', 'exists:units_of_measure,id'], + 'cost_price' => ['required', 'numeric', 'min:0'], + 'sale_price' => ['required', 'numeric', 'min:0'], + 'reorder_point' => ['nullable', 'numeric', 'min:0'], + 'reorder_quantity' => ['nullable', 'numeric', 'min:0'], + 'preferred_supplier_id' => ['nullable', 'integer', 'exists:suppliers,id'], + 'is_active' => ['boolean'], + ]; + } +} diff --git a/erp/app/Modules/Inventory/Http/Resources/ProductResource.php b/erp/app/Modules/Inventory/Http/Resources/ProductResource.php new file mode 100644 index 00000000000..c02bdda9183 --- /dev/null +++ b/erp/app/Modules/Inventory/Http/Resources/ProductResource.php @@ -0,0 +1,62 @@ + $this->id, + 'sku' => $this->sku, + 'name' => $this->name, + 'description' => $this->description, + 'category_id' => $this->category_id, + 'category' => $this->whenLoaded('category', fn () => [ + 'id' => $this->category->id, + 'name' => $this->category->name, + ]), + 'uom_id' => $this->uom_id, + 'uom' => $this->whenLoaded('uom', fn () => [ + 'id' => $this->uom->id, + 'name' => $this->uom->name, + 'abbreviation' => $this->uom->abbreviation, + ]), + 'cost_price' => $this->cost_price, + 'sale_price' => $this->sale_price, + 'reorder_point' => $this->reorder_point, + 'reorder_quantity' => $this->reorder_quantity, + 'preferred_supplier_id' => $this->preferred_supplier_id, + 'preferred_supplier' => $this->whenLoaded('preferredSupplier', fn () => $this->preferredSupplier ? [ + 'id' => $this->preferredSupplier->id, + 'name' => $this->preferredSupplier->name, + ] : null), + 'is_active' => $this->is_active, + 'stock_levels' => $this->whenLoaded('stockLevels', fn () => + $this->stockLevels->map(fn ($sl) => [ + 'warehouse_id' => $sl->warehouse_id, + 'warehouse_name' => $sl->warehouse?->name, + 'quantity' => $sl->quantity, + 'reserved_quantity' => $sl->reserved_quantity, + 'available' => $sl->available, + ]) + ), + 'total_quantity' => $this->when( + $this->relationLoaded('stockLevels'), + fn () => $this->total_quantity + ), + 'total_stock' => $this->when( + $this->relationLoaded('stockLevels'), + fn () => round($this->total_stock, 4) + ), + 'needs_reorder' => $this->when( + $this->relationLoaded('stockLevels'), + fn () => $this->needsReorder() + ), + 'created_at' => $this->created_at, + ]; + } +} diff --git a/erp/app/Modules/Inventory/Http/Resources/PurchaseOrderResource.php b/erp/app/Modules/Inventory/Http/Resources/PurchaseOrderResource.php new file mode 100644 index 00000000000..570f146dd3f --- /dev/null +++ b/erp/app/Modules/Inventory/Http/Resources/PurchaseOrderResource.php @@ -0,0 +1,42 @@ + $this->id, + 'status' => $this->status, + 'expected_date' => $this->expected_date?->toDateString(), + 'notes' => $this->notes, + 'total' => $this->total, + 'supplier' => $this->whenLoaded('supplier', fn () => [ + 'id' => $this->supplier->id, + 'name' => $this->supplier->name, + ]), + 'warehouse' => $this->whenLoaded('warehouse', fn () => [ + 'id' => $this->warehouse->id, + 'name' => $this->warehouse->name, + ]), + 'items' => $this->whenLoaded('items', fn () => + $this->items->map(fn ($item) => [ + 'id' => $item->id, + 'product_id' => $item->product_id, + 'product_name' => $item->product?->name, + 'product_sku' => $item->product?->sku, + 'quantity' => $item->quantity, + 'unit_cost' => $item->unit_cost, + 'received_quantity' => $item->received_quantity, + 'line_total' => $item->line_total, + ]) + ), + 'created_by' => $this->whenLoaded('creator', fn () => $this->creator?->name), + 'created_at' => $this->created_at, + ]; + } +} diff --git a/erp/app/Modules/Inventory/Http/Resources/SupplierResource.php b/erp/app/Modules/Inventory/Http/Resources/SupplierResource.php new file mode 100644 index 00000000000..cc8b3649ed7 --- /dev/null +++ b/erp/app/Modules/Inventory/Http/Resources/SupplierResource.php @@ -0,0 +1,23 @@ + $this->id, + 'name' => $this->name, + 'contact_person' => $this->contact_person, + 'email' => $this->email, + 'phone' => $this->phone, + 'address' => $this->address, + 'is_active' => $this->is_active, + 'created_at' => $this->created_at, + ]; + } +} diff --git a/erp/app/Modules/Inventory/Models/Asset.php b/erp/app/Modules/Inventory/Models/Asset.php new file mode 100644 index 00000000000..03eb55e0bec --- /dev/null +++ b/erp/app/Modules/Inventory/Models/Asset.php @@ -0,0 +1,62 @@ + 'date', + 'disposed_at' => 'datetime', + 'purchase_cost' => 'decimal:2', + 'current_value' => 'decimal:2', + ]; + + public function assignedEmployee(): BelongsTo + { + return $this->belongsTo(Employee::class, 'assigned_to_employee_id'); + } + + public function maintenances(): HasMany + { + return $this->hasMany(AssetMaintenance::class); + } + + public function getDepreciationAttribute(): ?float + { + if ($this->purchase_cost !== null && $this->current_value !== null) { + return round((float) $this->purchase_cost - (float) $this->current_value, 2); + } + + return null; + } + + public function dispose(): void + { + $this->status = 'disposed'; + $this->disposed_at = now(); + $this->save(); + } + + public function assignTo(int $employeeId): void + { + $this->assigned_to_employee_id = $employeeId; + $this->status = 'active'; + $this->save(); + } +} diff --git a/erp/app/Modules/Inventory/Models/AssetMaintenance.php b/erp/app/Modules/Inventory/Models/AssetMaintenance.php new file mode 100644 index 00000000000..5eabf70d3e2 --- /dev/null +++ b/erp/app/Modules/Inventory/Models/AssetMaintenance.php @@ -0,0 +1,40 @@ + 'date', + 'completed_date' => 'date', + 'cost' => 'decimal:2', + ]; + + public function asset(): BelongsTo + { + return $this->belongsTo(Asset::class); + } + + public function complete(string $completedDate, ?float $cost = null): void + { + $this->status = 'completed'; + $this->completed_date = $completedDate; + if ($cost !== null) { + $this->cost = $cost; + } + $this->save(); + } +} diff --git a/erp/app/Modules/Inventory/Models/Backorder.php b/erp/app/Modules/Inventory/Models/Backorder.php new file mode 100644 index 00000000000..37acb141765 --- /dev/null +++ b/erp/app/Modules/Inventory/Models/Backorder.php @@ -0,0 +1,80 @@ + 'pending', + 'quantity_fulfilled' => 0, + ]; + + protected $casts = [ + 'quantity_ordered' => 'float', + 'quantity_fulfilled' => 'float', + 'expected_date' => 'date', + ]; + + public function generateBackorderNumber(): string + { + return 'BO-' . now()->year . '-' . str_pad((string) $this->id, 5, '0', STR_PAD_LEFT); + } + + public function fulfill(float $quantity): void + { + $this->quantity_fulfilled += $quantity; + + if ($this->quantity_fulfilled >= $this->quantity_ordered) { + $this->status = 'fulfilled'; + } else { + $this->status = 'partial'; + } + + if ($this->backorder_number === null) { + $this->backorder_number = $this->generateBackorderNumber(); + } + + $this->save(); + } + + public function cancel(): void + { + $this->status = 'cancelled'; + $this->save(); + } + + public function getQuantityRemainingAttribute(): float + { + return (float) $this->quantity_ordered - (float) $this->quantity_fulfilled; + } + + public function getIsPendingAttribute(): bool + { + return in_array($this->status, ['pending', 'partial']); + } + + public function getIsFulfilledAttribute(): bool + { + return $this->status === 'fulfilled'; + } +} diff --git a/erp/app/Modules/Inventory/Models/BinStockLocation.php b/erp/app/Modules/Inventory/Models/BinStockLocation.php new file mode 100644 index 00000000000..6404872b4b6 --- /dev/null +++ b/erp/app/Modules/Inventory/Models/BinStockLocation.php @@ -0,0 +1,43 @@ + 'decimal:4', + 'expiry_date' => 'date', + ]; + + public function bin(): BelongsTo + { + return $this->belongsTo(WarehouseBin::class, 'bin_id'); + } + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + public function getIsExpiredAttribute(): bool + { + return $this->expiry_date !== null && $this->expiry_date->isPast(); + } +} diff --git a/erp/app/Modules/Inventory/Models/Category.php b/erp/app/Modules/Inventory/Models/Category.php new file mode 100644 index 00000000000..312b09d4d27 --- /dev/null +++ b/erp/app/Modules/Inventory/Models/Category.php @@ -0,0 +1,44 @@ +slug)) { + $model->slug = Str::slug($model->name); + } + }); + } + + public function parent(): BelongsTo + { + return $this->belongsTo(self::class, 'parent_id'); + } + + public function children(): HasMany + { + return $this->hasMany(self::class, 'parent_id'); + } + + public function products(): HasMany + { + return $this->hasMany(Product::class); + } +} diff --git a/erp/app/Modules/Inventory/Models/CostingLayer.php b/erp/app/Modules/Inventory/Models/CostingLayer.php new file mode 100644 index 00000000000..ef7b16f2334 --- /dev/null +++ b/erp/app/Modules/Inventory/Models/CostingLayer.php @@ -0,0 +1,114 @@ + 'decimal:4', + 'quantity_remaining' => 'decimal:4', + 'unit_cost' => 'decimal:4', + 'received_at' => 'datetime', + ]; + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + public function warehouse(): BelongsTo + { + return $this->belongsTo(Warehouse::class); + } + + public function scopeForProduct($query, int $productId) + { + return $query->where('product_id', $productId); + } + + public function scopeWithRemaining($query) + { + return $query->where('quantity_remaining', '>', 0); + } + + public function scopeFifo($query) + { + return $query->orderBy('received_at', 'asc'); + } + + public static function getAverageCost(int $tenantId, int $productId): float + { + $layers = static::withoutGlobalScope('tenant') + ->where('tenant_id', $tenantId) + ->where('product_id', $productId) + ->where('quantity_remaining', '>', 0) + ->get(); + + $totalQty = $layers->sum(fn ($l) => (float) $l->quantity_remaining); + $totalValue = $layers->sum(fn ($l) => (float) $l->quantity_remaining * (float) $l->unit_cost); + + if ($totalQty <= 0) { + return 0.0; + } + + return $totalValue / $totalQty; + } + + public static function consumeFifo(int $tenantId, int $productId, float $quantity): float + { + $layers = static::withoutGlobalScope('tenant') + ->where('tenant_id', $tenantId) + ->where('product_id', $productId) + ->where('quantity_remaining', '>', 0) + ->orderBy('received_at', 'asc') + ->get(); + + $needed = $quantity; + $totalCost = 0.0; + + foreach ($layers as $layer) { + if ($needed <= 0) { + break; + } + + $layerRemaining = (float) $layer->quantity_remaining; + $unitCost = (float) $layer->unit_cost; + + if ($layerRemaining >= $needed) { + $totalCost += $needed * $unitCost; + $layer->quantity_remaining = $layerRemaining - $needed; + $layer->save(); + $needed = 0; + break; + } else { + $totalCost += $layerRemaining * $unitCost; + $needed -= $layerRemaining; + $layer->quantity_remaining = 0; + $layer->save(); + } + } + + return $totalCost; + } +} diff --git a/erp/app/Modules/Inventory/Models/Customer.php b/erp/app/Modules/Inventory/Models/Customer.php new file mode 100644 index 00000000000..4133dd990bd --- /dev/null +++ b/erp/app/Modules/Inventory/Models/Customer.php @@ -0,0 +1,18 @@ + 'float', + 'is_active' => 'boolean', + 'valid_from' => 'date', + 'valid_to' => 'date', + ]; + + public function getIsValidAttribute(): bool + { + if (! $this->is_active) { + return false; + } + + $today = Carbon::today(); + + if ($this->valid_from !== null && $this->valid_from->gt($today)) { + return false; + } + + if ($this->valid_to !== null && $this->valid_to->lt($today)) { + return false; + } + + return true; + } + + public function calculate(float $amount): float + { + if ($this->discount_type === 'percentage') { + return $amount * ($this->discount_value / 100); + } + + return (float) $this->discount_value; + } +} diff --git a/erp/app/Modules/Inventory/Models/CycleCount.php b/erp/app/Modules/Inventory/Models/CycleCount.php new file mode 100644 index 00000000000..e623e23e5ec --- /dev/null +++ b/erp/app/Modules/Inventory/Models/CycleCount.php @@ -0,0 +1,79 @@ + 'date', + 'started_at' => 'datetime', + 'completed_at' => 'datetime', + ]; + + public function warehouse(): BelongsTo + { + return $this->belongsTo(Warehouse::class); + } + + public function items(): HasMany + { + return $this->hasMany(CycleCountItem::class); + } + + public function createdBy(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + public static function generateCountNumber(): string + { + return 'CC-' . strtoupper(uniqid()); + } + + public function start(): void + { + $this->status = 'in_progress'; + $this->started_at = now(); + $this->save(); + } + + public function complete(): void + { + $this->status = 'completed'; + $this->completed_at = now(); + $this->save(); + } + + public function cancel(): void + { + $this->status = 'cancelled'; + $this->save(); + } + + public function getTotalVarianceAttribute(): float + { + return (float) $this->items() + ->whereNotNull('counted_qty') + ->get() + ->sum(fn ($i) => abs($i->counted_qty - $i->system_qty)); + } + + public function getItemsCountedAttribute(): int + { + return $this->items()->whereNotNull('counted_qty')->count(); + } +} diff --git a/erp/app/Modules/Inventory/Models/CycleCountItem.php b/erp/app/Modules/Inventory/Models/CycleCountItem.php new file mode 100644 index 00000000000..5fe4e6ec414 --- /dev/null +++ b/erp/app/Modules/Inventory/Models/CycleCountItem.php @@ -0,0 +1,41 @@ + 'float', + 'counted_qty' => 'float', + ]; + + public function cycleCount(): BelongsTo + { + return $this->belongsTo(CycleCount::class); + } + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + public function getVarianceAttribute(): float + { + return ($this->counted_qty ?? $this->system_qty) - $this->system_qty; + } + + public function getIsCountedAttribute(): bool + { + return $this->counted_qty !== null; + } +} diff --git a/erp/app/Modules/Inventory/Models/DemandForecast.php b/erp/app/Modules/Inventory/Models/DemandForecast.php new file mode 100644 index 00000000000..994958cd9b0 --- /dev/null +++ b/erp/app/Modules/Inventory/Models/DemandForecast.php @@ -0,0 +1,74 @@ + 'date', + 'forecasted_quantity' => 'decimal:2', + 'actual_quantity' => 'decimal:2', + 'confidence_score' => 'decimal:2', + ]; + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + public function warehouse(): BelongsTo + { + return $this->belongsTo(Warehouse::class); + } + + public function getAccuracyAttribute(): ?float + { + if ($this->actual_quantity === null) { + return null; + } + + if ((float) $this->forecasted_quantity <= 0) { + return null; + } + + $accuracy = 100 - abs((float) $this->actual_quantity - (float) $this->forecasted_quantity) / (float) $this->forecasted_quantity * 100; + + return (float) round(max(0, $accuracy), 1); + } + + public static function generateMovingAvg(int $tenantId, int $productId, int $periods = 3): float + { + $records = static::where('tenant_id', $tenantId) + ->where('product_id', $productId) + ->whereNotNull('actual_quantity') + ->orderByDesc('forecast_date') + ->take($periods) + ->get(); + + if ($records->isEmpty()) { + return 0.0; + } + + return (float) $records->avg('actual_quantity'); + } +} diff --git a/erp/app/Modules/Inventory/Models/ForecastAlert.php b/erp/app/Modules/Inventory/Models/ForecastAlert.php new file mode 100644 index 00000000000..8403ab7b30e --- /dev/null +++ b/erp/app/Modules/Inventory/Models/ForecastAlert.php @@ -0,0 +1,46 @@ + 'boolean', + 'resolved_at' => 'datetime', + ]; + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + public function resolve(): void + { + $this->is_resolved = true; + $this->resolved_at = now(); + $this->save(); + } + + public function scopeUnresolved($query) + { + return $query->where('is_resolved', false); + } +} diff --git a/erp/app/Modules/Inventory/Models/GoodsReceipt.php b/erp/app/Modules/Inventory/Models/GoodsReceipt.php new file mode 100644 index 00000000000..06708e18fae --- /dev/null +++ b/erp/app/Modules/Inventory/Models/GoodsReceipt.php @@ -0,0 +1,114 @@ + 'date', + 'confirmed_at' => 'datetime', + ]; + + protected $attributes = [ + 'status' => 'draft', + ]; + + // Relations + + public function items(): HasMany + { + return $this->hasMany(GoodsReceiptItem::class); + } + + public function warehouse(): BelongsTo + { + return $this->belongsTo(Warehouse::class); + } + + public function receiver(): BelongsTo + { + return $this->belongsTo(User::class, 'received_by'); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + // Methods + + public function confirm(int $userId): void + { + if ($this->receipt_number === null) { + $this->receipt_number = $this->generateReceiptNumber(); + } + $this->status = 'confirmed'; + $this->confirmed_at = now(); + $this->received_by = $userId; + $this->save(); + } + + public function post(): void + { + $this->status = 'posted'; + $this->save(); + } + + public function reject(): void + { + $this->status = 'rejected'; + $this->save(); + } + + public function generateReceiptNumber(): string + { + return 'GR-' . date('Y') . '-' . str_pad((string) $this->id, 5, '0', STR_PAD_LEFT); + } + + // Accessors + + protected function isDraft(): Attribute + { + return Attribute::make( + get: fn () => $this->status === 'draft', + ); + } + + protected function isConfirmed(): Attribute + { + return Attribute::make( + get: fn () => $this->status === 'confirmed', + ); + } + + protected function totalItems(): Attribute + { + return Attribute::make( + get: fn () => $this->items()->count(), + ); + } +} diff --git a/erp/app/Modules/Inventory/Models/GoodsReceiptItem.php b/erp/app/Modules/Inventory/Models/GoodsReceiptItem.php new file mode 100644 index 00000000000..de5ed094174 --- /dev/null +++ b/erp/app/Modules/Inventory/Models/GoodsReceiptItem.php @@ -0,0 +1,54 @@ + 'decimal:2', + 'quantity_received' => 'decimal:2', + 'unit_cost' => 'decimal:2', + ]; + + protected $attributes = [ + 'condition' => 'good', + 'quantity_expected' => 0, + 'quantity_received' => 0, + 'unit_cost' => 0, + ]; + + // Relations + + public function receipt(): BelongsTo + { + return $this->belongsTo(GoodsReceipt::class, 'goods_receipt_id'); + } + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + // Accessors + + protected function lineTotal(): Attribute + { + return Attribute::make( + get: fn () => (float) $this->quantity_received * (float) $this->unit_cost, + ); + } +} diff --git a/erp/app/Modules/Inventory/Models/LotNumber.php b/erp/app/Modules/Inventory/Models/LotNumber.php new file mode 100644 index 00000000000..f0f7dc15739 --- /dev/null +++ b/erp/app/Modules/Inventory/Models/LotNumber.php @@ -0,0 +1,78 @@ + 'date', + 'expiry_date' => 'date', + 'quantity_received' => 'integer', + 'quantity_remaining' => 'integer', + ]; + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + public function warehouse(): BelongsTo + { + return $this->belongsTo(Warehouse::class); + } + + public function serialNumbers(): HasMany + { + return $this->hasMany(SerialNumber::class, 'lot_number_id'); + } + + public function getIsExpiredAttribute(): bool + { + return $this->expiry_date !== null && $this->expiry_date->isPast(); + } + + public function getIsExpiringAttribute(): bool + { + return $this->expiry_date !== null + && $this->expiry_date->isFuture() + && $this->expiry_date->diffInDays(now()) <= 30; + } + + public function quarantine(?string $reason = null): void + { + $this->status = 'quarantine'; + if ($reason !== null) { + $this->notes = $reason; + } + $this->save(); + } + + public function consume(int $qty): void + { + $this->quantity_remaining = max(0, $this->quantity_remaining - $qty); + if ($this->quantity_remaining <= 0) { + $this->status = 'consumed'; + } + $this->save(); + } +} diff --git a/erp/app/Modules/Inventory/Models/PriceList.php b/erp/app/Modules/Inventory/Models/PriceList.php new file mode 100644 index 00000000000..17dcde0127b --- /dev/null +++ b/erp/app/Modules/Inventory/Models/PriceList.php @@ -0,0 +1,67 @@ + 'boolean', + 'is_default' => 'boolean', + 'valid_from' => 'date', + 'valid_to' => 'date', + ]; + + public function items(): HasMany + { + return $this->hasMany(PriceListItem::class); + } + + public function getIsValidAttribute(): bool + { + if (! $this->is_active) { + return false; + } + + $today = Carbon::today(); + + if ($this->valid_from !== null && $this->valid_from->gt($today)) { + return false; + } + + if ($this->valid_to !== null && $this->valid_to->lt($today)) { + return false; + } + + return true; + } + + public function getItemCountAttribute(): int + { + return $this->items()->count(); + } + + public function getPriceForProduct(int $productId, float $quantity = 1): ?float + { + $item = $this->items() + ->where('product_id', $productId) + ->where('min_quantity', '<=', $quantity) + ->orderByDesc('min_quantity') + ->first(); + + return $item ? (float) $item->price : null; + } +} diff --git a/erp/app/Modules/Inventory/Models/PriceListItem.php b/erp/app/Modules/Inventory/Models/PriceListItem.php new file mode 100644 index 00000000000..6de57d78777 --- /dev/null +++ b/erp/app/Modules/Inventory/Models/PriceListItem.php @@ -0,0 +1,31 @@ + 'float', + 'min_quantity' => 'float', + ]; + + public function priceList(): BelongsTo + { + return $this->belongsTo(PriceList::class); + } + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } +} diff --git a/erp/app/Modules/Inventory/Models/Product.php b/erp/app/Modules/Inventory/Models/Product.php new file mode 100644 index 00000000000..b21bbde2674 --- /dev/null +++ b/erp/app/Modules/Inventory/Models/Product.php @@ -0,0 +1,160 @@ + 'decimal:2', + 'sale_price' => 'decimal:2', + 'reorder_point' => 'float', + 'reorder_quantity' => 'float', + 'is_active' => 'boolean', + 'is_bundle' => 'boolean', + 'stock_quantity' => 'decimal:4', + ]; + + public function category(): BelongsTo + { + return $this->belongsTo(ProductCategory::class, 'category_id'); + } + + public function uom(): BelongsTo + { + return $this->belongsTo(UnitOfMeasure::class, 'uom_id'); + } + + public function stockLevels(): HasMany + { + return $this->hasMany(StockLevel::class); + } + + public function stockMovements(): HasMany + { + return $this->hasMany(StockMovement::class); + } + + public function preferredSupplier(): BelongsTo + { + return $this->belongsTo(Supplier::class, 'preferred_supplier_id'); + } + + public function bundleItems(): HasMany + { + return $this->hasMany(ProductBundleItem::class, 'bundle_product_id'); + } + + public function componentInBundles(): HasMany + { + return $this->hasMany(ProductBundleItem::class, 'component_product_id'); + } + + public function getStockSufficientForBundleAttribute(): bool + { + if (! $this->is_bundle) { + return true; + } + + foreach ($this->bundleItems as $item) { + $component = $item->componentProduct; + if ($component === null) { + return false; + } + if ((float) $component->stock_quantity < (float) $item->quantity) { + return false; + } + } + + return true; + } + + public function getTotalQuantityAttribute(): float + { + return (float) $this->stockLevels()->sum('quantity'); + } + + public function getTotalAvailableAttribute(): float + { + return (float) $this->stockLevels() + ->selectRaw('SUM(quantity - reserved_quantity) as available') + ->value('available') ?? 0.0; + } + + public function getTotalStockAttribute(): float + { + return (float) $this->stockLevels->sum('quantity'); + } + + public function needsReorder(): bool + { + return $this->reorder_point > 0 && $this->total_stock <= $this->reorder_point; + } + + public function scopeSearch($query, string $term) + { + return $query->where(function ($q) use ($term) { + $q->where('name', 'like', "%{$term}%") + ->orWhere('sku', 'like', "%{$term}%"); + }); + } + + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + public function costingLayers(): HasMany + { + return $this->hasMany(CostingLayer::class); + } + + public function getAverageCostAttribute(): float + { + return CostingLayer::getAverageCost($this->tenant_id, $this->id); + } + + public function tags(): BelongsToMany + { + return $this->belongsToMany(ProductTag::class, 'product_tag_assignments', 'product_id', 'product_tag_id') + ->withTimestamps(); + } + + public function substitutes(): HasMany + { + return $this->hasMany(ProductSubstitute::class, 'product_id')->orderBy('priority'); + } + + public function activeSubstitutes(): HasMany + { + return $this->hasMany(ProductSubstitute::class, 'product_id') + ->where('is_active', true) + ->orderBy('priority'); + } + + public function variants(): HasMany + { + return $this->hasMany(ProductVariant::class); + } + +} diff --git a/erp/app/Modules/Inventory/Models/ProductAttribute.php b/erp/app/Modules/Inventory/Models/ProductAttribute.php new file mode 100644 index 00000000000..7e5d12af5fa --- /dev/null +++ b/erp/app/Modules/Inventory/Models/ProductAttribute.php @@ -0,0 +1,22 @@ + 'array']; + + public function values(): HasMany + { + return $this->hasMany(ProductVariantValue::class, 'attribute_id'); + } +} diff --git a/erp/app/Modules/Inventory/Models/ProductBundle.php b/erp/app/Modules/Inventory/Models/ProductBundle.php new file mode 100644 index 00000000000..ae164af431b --- /dev/null +++ b/erp/app/Modules/Inventory/Models/ProductBundle.php @@ -0,0 +1,76 @@ + true, + ]; + + protected $fillable = [ + 'tenant_id', + 'name', + 'sku', + 'description', + 'bundle_price', + 'is_active', + ]; + + protected $casts = [ + 'bundle_price' => 'float', + 'is_active' => 'boolean', + ]; + + public function items(): HasMany + { + return $this->hasMany(ProductBundleItem::class); + } + + public function products(): BelongsToMany + { + return $this->belongsToMany(Product::class, 'product_bundle_items') + ->withPivot('quantity') + ->withTimestamps(); + } + + public function calculatePrice(): float + { + if (! is_null($this->bundle_price)) { + return (float) $this->bundle_price; + } + + return (float) $this->items->sum(function ($item) { + $product = $item->product; + if ($product === null) { + return 0; + } + $price = isset($product->sale_price) ? (float) $product->sale_price : 0.0; + return $price * (float) $item->quantity; + }); + } + + public function activate(): void + { + $this->update(['is_active' => true]); + } + + public function deactivate(): void + { + $this->update(['is_active' => false]); + } + + public function getItemCountAttribute(): int + { + return $this->items()->count(); + } +} diff --git a/erp/app/Modules/Inventory/Models/ProductBundleItem.php b/erp/app/Modules/Inventory/Models/ProductBundleItem.php new file mode 100644 index 00000000000..bd110ef1ebc --- /dev/null +++ b/erp/app/Modules/Inventory/Models/ProductBundleItem.php @@ -0,0 +1,33 @@ + 'float', + ]; + + public function bundle(): BelongsTo + { + return $this->belongsTo(ProductBundle::class, 'product_bundle_id'); + } + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } +} diff --git a/erp/app/Modules/Inventory/Models/ProductCategory.php b/erp/app/Modules/Inventory/Models/ProductCategory.php new file mode 100644 index 00000000000..3ce9431f370 --- /dev/null +++ b/erp/app/Modules/Inventory/Models/ProductCategory.php @@ -0,0 +1,36 @@ +id) + ->update(['category_id' => null]); + }); + } + + public function products(): HasMany + { + return $this->hasMany(Product::class, 'category_id'); + } +} diff --git a/erp/app/Modules/Inventory/Models/ProductCostSnapshot.php b/erp/app/Modules/Inventory/Models/ProductCostSnapshot.php new file mode 100644 index 00000000000..51e11fbb100 --- /dev/null +++ b/erp/app/Modules/Inventory/Models/ProductCostSnapshot.php @@ -0,0 +1,73 @@ + 'decimal:4', + 'fifo_cost' => 'decimal:4', + 'total_quantity' => 'decimal:4', + 'total_value' => 'decimal:4', + 'snapshot_date' => 'date', + ]; + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + public static function takeSnapshot(int $tenantId, int $productId): self + { + $averageCost = CostingLayer::getAverageCost($tenantId, $productId); + + // FIFO cost is the unit cost of the oldest remaining layer + $oldestLayer = CostingLayer::withoutGlobalScope('tenant') + ->where('tenant_id', $tenantId) + ->where('product_id', $productId) + ->where('quantity_remaining', '>', 0) + ->orderBy('received_at', 'asc') + ->first(); + + $fifoCost = $oldestLayer ? (float) $oldestLayer->unit_cost : 0.0; + + $layers = CostingLayer::withoutGlobalScope('tenant') + ->where('tenant_id', $tenantId) + ->where('product_id', $productId) + ->where('quantity_remaining', '>', 0) + ->get(); + + $totalQuantity = $layers->sum(fn ($l) => (float) $l->quantity_remaining); + $totalValue = $layers->sum(fn ($l) => (float) $l->quantity_remaining * (float) $l->unit_cost); + + return static::create([ + 'tenant_id' => $tenantId, + 'product_id' => $productId, + 'costing_method' => 'fifo', + 'average_cost' => $averageCost, + 'fifo_cost' => $fifoCost, + 'snapshot_date' => now()->toDateString(), + 'total_quantity' => $totalQuantity, + 'total_value' => $totalValue, + ]); + } +} diff --git a/erp/app/Modules/Inventory/Models/ProductSubstitute.php b/erp/app/Modules/Inventory/Models/ProductSubstitute.php new file mode 100644 index 00000000000..4a6ed2ce1cf --- /dev/null +++ b/erp/app/Modules/Inventory/Models/ProductSubstitute.php @@ -0,0 +1,33 @@ + 'integer', + 'is_bidirectional' => 'boolean', + 'is_active' => 'boolean', + ]; + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class, 'product_id'); + } + + public function substituteProduct(): BelongsTo + { + return $this->belongsTo(Product::class, 'substitute_product_id'); + } +} diff --git a/erp/app/Modules/Inventory/Models/ProductTag.php b/erp/app/Modules/Inventory/Models/ProductTag.php new file mode 100644 index 00000000000..6363a8b626f --- /dev/null +++ b/erp/app/Modules/Inventory/Models/ProductTag.php @@ -0,0 +1,27 @@ + 'boolean']; + + public function products(): BelongsToMany + { + return $this->belongsToMany(Product::class, 'product_tag_assignments', 'product_tag_id', 'product_id') + ->withTimestamps(); + } + + public function getProductCountAttribute(): int + { + return $this->products()->count(); + } +} diff --git a/erp/app/Modules/Inventory/Models/ProductVariant.php b/erp/app/Modules/Inventory/Models/ProductVariant.php new file mode 100644 index 00000000000..f8639f30174 --- /dev/null +++ b/erp/app/Modules/Inventory/Models/ProductVariant.php @@ -0,0 +1,47 @@ + 'boolean', 'price_adjustment' => 'float', 'stock_quantity' => 'integer']; + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + public function values(): HasMany + { + return $this->hasMany(ProductVariantValue::class, 'variant_id'); + } + + public function getEffectivePriceAttribute(): float + { + return (float) ($this->product?->sale_price ?? 0) + (float) $this->price_adjustment; + } + + public function adjustStock(int $delta): void + { + $this->stock_quantity = max(0, $this->stock_quantity + $delta); + $this->save(); + } + + public function scopeActive($query) + { + return $query->where('is_active', true); + } +} diff --git a/erp/app/Modules/Inventory/Models/ProductVariantValue.php b/erp/app/Modules/Inventory/Models/ProductVariantValue.php new file mode 100644 index 00000000000..ffe8d61e33f --- /dev/null +++ b/erp/app/Modules/Inventory/Models/ProductVariantValue.php @@ -0,0 +1,24 @@ +belongsTo(ProductVariant::class, 'variant_id'); + } + + public function attribute(): BelongsTo + { + return $this->belongsTo(ProductAttribute::class, 'attribute_id'); + } +} diff --git a/erp/app/Modules/Inventory/Models/ProductWarranty.php b/erp/app/Modules/Inventory/Models/ProductWarranty.php new file mode 100644 index 00000000000..9705e979e3d --- /dev/null +++ b/erp/app/Modules/Inventory/Models/ProductWarranty.php @@ -0,0 +1,59 @@ + 'boolean', + 'duration_months' => 'integer', + ]; + + protected $attributes = [ + 'warranty_type' => 'standard', + 'is_default' => false, + ]; + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + public function claims(): HasMany + { + return $this->hasMany(WarrantyClaim::class); + } + + public function isExpiredFor(Carbon $purchaseDate): bool + { + return Carbon::now()->greaterThan($purchaseDate->copy()->addMonths($this->duration_months)); + } + + public function durationLabel(): Attribute + { + return Attribute::make( + get: fn () => "{$this->duration_months} months", + ); + } +} diff --git a/erp/app/Modules/Inventory/Models/PurchaseOrder.php b/erp/app/Modules/Inventory/Models/PurchaseOrder.php new file mode 100644 index 00000000000..ea93d46f6a5 --- /dev/null +++ b/erp/app/Modules/Inventory/Models/PurchaseOrder.php @@ -0,0 +1,94 @@ + 'date', + 'expected_date' => 'date', + 'subtotal' => 'float', + 'tax' => 'float', + 'total' => 'float', + 'sent_at' => 'datetime', + 'received_at' => 'datetime', + ]; + + public function supplier(): BelongsTo + { + return $this->belongsTo(Supplier::class); + } + + public function items(): HasMany + { + return $this->hasMany(PurchaseOrderItem::class); + } + + public function createdBy(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + public static function generatePoNumber(): string + { + return 'PO-' . strtoupper(uniqid()); + } + + public function send(): void + { + $this->status = 'sent'; + $this->sent_at = now(); + $this->save(); + } + + public function cancel(): void + { + $this->status = 'cancelled'; + $this->save(); + } + + public function markReceived(): void + { + $this->status = 'received'; + $this->received_at = now(); + $this->save(); + } + + public function recalculateTotals(): void + { + $subtotal = $this->items()->get()->sum(fn ($i) => $i->quantity * $i->unit_price); + $this->subtotal = $subtotal; + $this->total = $subtotal + $this->tax; + $this->save(); + } + + public function getIsOpenAttribute(): bool + { + return in_array($this->status, ['draft', 'sent', 'partial']); + } + + public function getReceivingProgressAttribute(): float + { + $items = $this->items()->get(); + $totalQty = $items->sum('quantity'); + if ($totalQty == 0) { + return 0.0; + } + return round(($items->sum('received_qty') / $totalQty) * 100, 1); + } +} diff --git a/erp/app/Modules/Inventory/Models/PurchaseOrderItem.php b/erp/app/Modules/Inventory/Models/PurchaseOrderItem.php new file mode 100644 index 00000000000..bfb5d82e789 --- /dev/null +++ b/erp/app/Modules/Inventory/Models/PurchaseOrderItem.php @@ -0,0 +1,43 @@ + 'float', + 'unit_price' => 'float', + 'received_qty' => 'float', + ]; + + public function purchaseOrder(): BelongsTo + { + return $this->belongsTo(PurchaseOrder::class); + } + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + public function getLineTotalAttribute(): float + { + return (float) $this->quantity * (float) $this->unit_price; + } + + public function getIsFullyReceivedAttribute(): bool + { + return $this->received_qty >= $this->quantity; + } +} diff --git a/erp/app/Modules/Inventory/Models/PurchaseRequest.php b/erp/app/Modules/Inventory/Models/PurchaseRequest.php new file mode 100644 index 00000000000..5a231b2b185 --- /dev/null +++ b/erp/app/Modules/Inventory/Models/PurchaseRequest.php @@ -0,0 +1,107 @@ + 'draft', + 'priority' => 'medium', + 'currency' => 'USD', + 'estimated_cost' => 0, + ]; + + protected $casts = [ + 'estimated_cost' => 'decimal:2', + 'required_by' => 'date', + 'approved_at' => 'datetime', + 'submitted_at' => 'datetime', + ]; + + // ── State transitions ────────────────────────────────────────────────── + + public function submit(): void + { + $this->status = 'submitted'; + $this->submitted_at = now(); + + if ($this->request_number === null) { + $this->request_number = $this->generateRequestNumber(); + } + + $this->save(); + } + + public function approve(int $userId): void + { + $this->status = 'approved'; + $this->approved_by = $userId; + $this->approved_at = now(); + $this->save(); + } + + public function reject(): void + { + $this->status = 'rejected'; + $this->save(); + } + + public function markOrdered(): void + { + $this->status = 'ordered'; + $this->save(); + } + + public function cancel(): void + { + $this->status = 'cancelled'; + $this->save(); + } + + public function generateRequestNumber(): string + { + return 'PR-' . date('Y') . '-' . str_pad((string) $this->id, 5, '0', STR_PAD_LEFT); + } + + // ── Accessors ────────────────────────────────────────────────────────── + + public function getIsDraftAttribute(): bool + { + return $this->status === 'draft'; + } + + public function getIsSubmittedAttribute(): bool + { + return $this->status === 'submitted'; + } + + public function getIsApprovedAttribute(): bool + { + return $this->status === 'approved'; + } +} diff --git a/erp/app/Modules/Inventory/Models/PurchaseRequisition.php b/erp/app/Modules/Inventory/Models/PurchaseRequisition.php new file mode 100644 index 00000000000..53fb368cd3b --- /dev/null +++ b/erp/app/Modules/Inventory/Models/PurchaseRequisition.php @@ -0,0 +1,42 @@ + 'date', 'approved_at' => 'datetime']; + + public function requester(): BelongsTo + { + return $this->belongsTo(User::class, 'requested_by'); + } + + public function approver(): BelongsTo + { + return $this->belongsTo(User::class, 'approved_by'); + } + + public function items(): HasMany + { + return $this->hasMany(PurchaseRequisitionItem::class); + } + + public function getTotalEstimatedCostAttribute(): float + { + return round($this->items->sum(fn ($i) => $i->quantity * $i->estimated_unit_cost), 2); + } +} diff --git a/erp/app/Modules/Inventory/Models/PurchaseRequisitionItem.php b/erp/app/Modules/Inventory/Models/PurchaseRequisitionItem.php new file mode 100644 index 00000000000..deaa93ac20f --- /dev/null +++ b/erp/app/Modules/Inventory/Models/PurchaseRequisitionItem.php @@ -0,0 +1,23 @@ +belongsTo(PurchaseRequisition::class, 'purchase_requisition_id'); + } + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } +} diff --git a/erp/app/Modules/Inventory/Models/PutAwayRule.php b/erp/app/Modules/Inventory/Models/PutAwayRule.php new file mode 100644 index 00000000000..9f5951cb15c --- /dev/null +++ b/erp/app/Modules/Inventory/Models/PutAwayRule.php @@ -0,0 +1,113 @@ + 'boolean', + 'sequence' => 'integer', + ]; + + protected $attributes = [ + 'sequence' => 10, + 'is_active' => true, + ]; + + public function warehouse(): BelongsTo + { + return $this->belongsTo(Warehouse::class); + } + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + public function category(): BelongsTo + { + return $this->belongsTo(ProductCategory::class, 'product_category_id'); + } + + public function locationInZone(): BelongsTo + { + return $this->belongsTo(WarehouseZone::class, 'location_in_zone_id'); + } + + public function locationOutBin(): BelongsTo + { + return $this->belongsTo(WarehouseBin::class, 'location_out_bin_id'); + } + + public function locationOutZone(): BelongsTo + { + return $this->belongsTo(WarehouseZone::class, 'location_out_zone_id'); + } + + public static function findBestRule(int $tenantId, int $productId, int $warehouseId): ?static + { + $rules = static::where('tenant_id', $tenantId) + ->where('warehouse_id', $warehouseId) + ->where('is_active', true) + ->orderBy('sequence') + ->with('product') + ->get(); + + // First pass: product-specific match + foreach ($rules as $rule) { + if ($rule->product_id === $productId) { + return $rule; + } + } + + // Second pass: category match + $product = Product::find($productId); + if ($product && $product->category_id) { + foreach ($rules as $rule) { + if ($rule->product_category_id === $product->category_id) { + return $rule; + } + } + } + + return null; + } + + public function targetLocationLabel(): Attribute + { + return Attribute::make( + get: function () { + if ($this->locationOutBin) { + return $this->locationOutBin->code; + } + if ($this->locationOutZone) { + return $this->locationOutZone->name; + } + return '—'; + }, + ); + } +} diff --git a/erp/app/Modules/Inventory/Models/QcChecklist.php b/erp/app/Modules/Inventory/Models/QcChecklist.php new file mode 100644 index 00000000000..085a9230a04 --- /dev/null +++ b/erp/app/Modules/Inventory/Models/QcChecklist.php @@ -0,0 +1,42 @@ + 'boolean', + ]; + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + public function items(): HasMany + { + return $this->hasMany(QcChecklistItem::class)->orderBy('sort_order'); + } + + public function inspections(): HasMany + { + return $this->hasMany(QcInspection::class); + } +} diff --git a/erp/app/Modules/Inventory/Models/QcChecklistItem.php b/erp/app/Modules/Inventory/Models/QcChecklistItem.php new file mode 100644 index 00000000000..639508e230a --- /dev/null +++ b/erp/app/Modules/Inventory/Models/QcChecklistItem.php @@ -0,0 +1,30 @@ + 'boolean', + ]; + + public function checklist(): BelongsTo + { + return $this->belongsTo(QcChecklist::class); + } +} diff --git a/erp/app/Modules/Inventory/Models/QcInspection.php b/erp/app/Modules/Inventory/Models/QcInspection.php new file mode 100644 index 00000000000..76aa935c672 --- /dev/null +++ b/erp/app/Modules/Inventory/Models/QcInspection.php @@ -0,0 +1,71 @@ + 'datetime', + ]; + + public function checklist(): BelongsTo + { + return $this->belongsTo(QcChecklist::class, 'qc_checklist_id'); + } + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + public function inspector(): BelongsTo + { + return $this->belongsTo(User::class, 'inspector_id'); + } + + public function results(): HasMany + { + return $this->hasMany(QcInspectionResult::class); + } + + public function getPassRateAttribute(): ?float + { + $results = $this->results; + if ($results->isEmpty()) { + return null; + } + + return round($results->where('result', 'pass')->count() / $results->count() * 100, 1); + } + + public function complete(string $overallResult): void + { + $this->status = $overallResult === 'pass' ? 'passed' : 'failed'; + $this->overall_result = $overallResult; + $this->inspected_at = now(); + $this->inspector_id = auth()->id(); + $this->save(); + } +} diff --git a/erp/app/Modules/Inventory/Models/QcInspectionResult.php b/erp/app/Modules/Inventory/Models/QcInspectionResult.php new file mode 100644 index 00000000000..e873cfe9c04 --- /dev/null +++ b/erp/app/Modules/Inventory/Models/QcInspectionResult.php @@ -0,0 +1,30 @@ +belongsTo(QcInspection::class, 'qc_inspection_id'); + } + + public function checklistItem(): BelongsTo + { + return $this->belongsTo(QcChecklistItem::class, 'qc_checklist_item_id'); + } +} diff --git a/erp/app/Modules/Inventory/Models/QualityAlert.php b/erp/app/Modules/Inventory/Models/QualityAlert.php new file mode 100644 index 00000000000..3e2a4fda3a6 --- /dev/null +++ b/erp/app/Modules/Inventory/Models/QualityAlert.php @@ -0,0 +1,91 @@ + 'open', + 'severity' => 'medium', + 'alert_type' => 'defect', + 'affected_quantity' => 0, + ]; + + protected $casts = [ + 'affected_quantity' => 'integer', + 'resolved_at' => 'datetime', + ]; + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + public function investigate(): void + { + $this->status = 'investigating'; + $this->save(); + } + + public function resolve(string $rootCause, string $correctiveAction): void + { + $this->status = 'resolved'; + $this->root_cause = $rootCause; + $this->corrective_action = $correctiveAction; + $this->resolved_at = now(); + $this->save(); + } + + public function close(): void + { + $this->status = 'closed'; + $this->save(); + } + + public function generateAlertNumber(): string + { + return 'QA-' . date('Y') . '-' . str_pad((string) $this->id, 5, '0', STR_PAD_LEFT); + } + + public function getIsOpenAttribute(): bool + { + return $this->status === 'open'; + } + + public function getIsCriticalAttribute(): bool + { + return $this->severity === 'critical'; + } + + public function getIsResolvedAttribute(): bool + { + return $this->status === 'resolved' || $this->status === 'closed'; + } +} diff --git a/erp/app/Modules/Inventory/Models/ReorderRule.php b/erp/app/Modules/Inventory/Models/ReorderRule.php new file mode 100644 index 00000000000..1b024262d23 --- /dev/null +++ b/erp/app/Modules/Inventory/Models/ReorderRule.php @@ -0,0 +1,89 @@ + 'active', + 'rule_type' => 'fixed', + 'is_active' => true, + 'reorder_point' => 0, + 'reorder_quantity' => 0, + ]; + + protected $casts = [ + 'reorder_point' => 'decimal:2', + 'reorder_quantity' => 'decimal:2', + 'max_stock_level' => 'decimal:2', + 'is_active' => 'boolean', + 'last_triggered_at' => 'datetime', + ]; + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + public function trigger(): void + { + $this->status = 'triggered'; + $this->last_triggered_at = now(); + $this->save(); + } + + public function pause(): void + { + $this->status = 'paused'; + $this->save(); + } + + public function resume(): void + { + $this->status = 'active'; + $this->save(); + } + + public function generateRuleNumber(): string + { + return 'RR-' . date('Y') . '-' . str_pad((string) $this->id, 5, '0', STR_PAD_LEFT); + } + + public function getIsTriggeredAttribute(): bool + { + return $this->status === 'triggered'; + } + + public function getIsPausedAttribute(): bool + { + return $this->status === 'paused'; + } + + public function getNeedsReorderAttribute(): bool + { + return $this->is_active && $this->status === 'active'; + } +} diff --git a/erp/app/Modules/Inventory/Models/ReplenishmentOrder.php b/erp/app/Modules/Inventory/Models/ReplenishmentOrder.php new file mode 100644 index 00000000000..5a74c49789b --- /dev/null +++ b/erp/app/Modules/Inventory/Models/ReplenishmentOrder.php @@ -0,0 +1,133 @@ + 'buy', + 'status' => 'draft', + 'qty_on_hand' => 0, + ]; + + protected $casts = [ + 'qty_on_hand' => 'float', + 'qty_needed' => 'float', + 'qty_to_order' => 'float', + 'scheduled_date' => 'date', + ]; + + // Relations + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + public function warehouse(): BelongsTo + { + return $this->belongsTo(Warehouse::class); + } + + public function supplier(): BelongsTo + { + return $this->belongsTo(Supplier::class); + } + + public function reorderRule(): BelongsTo + { + return $this->belongsTo(ReorderRule::class); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + // Methods + + public function confirm(): void + { + if ($this->order_number === null) { + $this->order_number = $this->generateOrderNumber(); + } + $this->status = 'confirmed'; + $this->save(); + } + + public function markInProgress(): void + { + $this->status = 'in_progress'; + $this->save(); + } + + public function complete(): void + { + $this->status = 'done'; + $this->save(); + } + + public function cancel(): void + { + $this->status = 'cancelled'; + $this->save(); + } + + public function generateOrderNumber(): string + { + return 'REP-' . date('Y') . '-' . str_pad((string) $this->id, 5, '0', STR_PAD_LEFT); + } + + // Accessors + + protected function isDraft(): Attribute + { + return Attribute::make( + get: fn () => $this->status === 'draft', + ); + } + + protected function isConfirmed(): Attribute + { + return Attribute::make( + get: fn () => $this->status === 'confirmed', + ); + } + + protected function routeLabel(): Attribute + { + return Attribute::make( + get: fn () => match ($this->route) { + 'manufacture' => 'Manufacturing Order', + 'resupply' => 'Internal Transfer', + default => 'Purchase Order', + }, + ); + } +} diff --git a/erp/app/Modules/Inventory/Models/RmaRequest.php b/erp/app/Modules/Inventory/Models/RmaRequest.php new file mode 100644 index 00000000000..db3b25d65ac --- /dev/null +++ b/erp/app/Modules/Inventory/Models/RmaRequest.php @@ -0,0 +1,129 @@ + 'date', + 'received_date' => 'date', + 'inspected_date' => 'date', + ]; + + protected $attributes = [ + 'type' => 'customer_return', + 'status' => 'pending', + 'disposition' => 'restock', + ]; + + public function items(): HasMany + { + return $this->hasMany(RmaRequestItem::class); + } + + public function warehouse(): BelongsTo + { + return $this->belongsTo(Warehouse::class); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function approver(): BelongsTo + { + return $this->belongsTo(User::class, 'approved_by'); + } + + public function approve(int $userId): void + { + if ($this->rma_number === null) { + $this->rma_number = $this->generateRmaNumber(); + } + $this->status = 'approved'; + $this->approved_by = $userId; + $this->save(); + } + + public function receive(): void + { + $this->status = 'received'; + $this->received_date = now()->toDateString(); + $this->save(); + } + + public function inspect(): void + { + $this->status = 'inspected'; + $this->inspected_date = now()->toDateString(); + $this->save(); + } + + public function close(): void + { + $this->status = 'closed'; + $this->save(); + } + + public function reject(): void + { + $this->status = 'rejected'; + $this->save(); + } + + public function generateRmaNumber(): string + { + return 'RMA-' . date('Y') . '-' . str_pad((string) $this->id, 5, '0', STR_PAD_LEFT); + } + + protected function isPending(): Attribute + { + return Attribute::make(get: fn () => $this->status === 'pending'); + } + + protected function isApproved(): Attribute + { + return Attribute::make(get: fn () => $this->status === 'approved'); + } + + protected function isReceived(): Attribute + { + return Attribute::make(get: fn () => $this->status === 'received'); + } + + protected function totalItems(): Attribute + { + return Attribute::make(get: fn () => $this->items()->count()); + } +} diff --git a/erp/app/Modules/Inventory/Models/RmaRequestItem.php b/erp/app/Modules/Inventory/Models/RmaRequestItem.php new file mode 100644 index 00000000000..76b694ef5a1 --- /dev/null +++ b/erp/app/Modules/Inventory/Models/RmaRequestItem.php @@ -0,0 +1,42 @@ + 'float', + 'quantity_received' => 'float', + ]; + + protected $attributes = [ + 'condition' => 'good', + 'quantity_received' => 0, + ]; + + public function rmaRequest(): BelongsTo + { + return $this->belongsTo(RmaRequest::class); + } + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } +} diff --git a/erp/app/Modules/Inventory/Models/SalesOrder.php b/erp/app/Modules/Inventory/Models/SalesOrder.php new file mode 100644 index 00000000000..2cdcaea194c --- /dev/null +++ b/erp/app/Modules/Inventory/Models/SalesOrder.php @@ -0,0 +1,95 @@ + 'date', + 'expected_date' => 'date', + 'subtotal' => 'float', + 'tax' => 'float', + 'total' => 'float', + 'confirmed_at' => 'datetime', + 'shipped_at' => 'datetime', + 'delivered_at' => 'datetime', + ]; + + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } + + public function items(): HasMany + { + return $this->hasMany(SalesOrderItem::class); + } + + public function createdBy(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + public static function generateSoNumber(): string + { + return 'SO-' . strtoupper(uniqid()); + } + + public function confirm(): void + { + $this->status = 'confirmed'; + $this->confirmed_at = now(); + $this->save(); + } + + public function ship(): void + { + $this->status = 'shipped'; + $this->shipped_at = now(); + $this->save(); + } + + public function deliver(): void + { + $this->status = 'delivered'; + $this->delivered_at = now(); + $this->save(); + } + + public function cancel(): void + { + $this->status = 'cancelled'; + $this->save(); + } + + public function recalculateTotals(): void + { + $subtotal = $this->items()->get()->sum(fn ($i) => $i->quantity * $i->unit_price); + $this->subtotal = $subtotal; + $this->total = $subtotal + $this->tax; + $this->save(); + } + + public function getIsOpenAttribute(): bool + { + return in_array($this->status, ['draft', 'confirmed']); + } +} diff --git a/erp/app/Modules/Inventory/Models/SalesOrderItem.php b/erp/app/Modules/Inventory/Models/SalesOrderItem.php new file mode 100644 index 00000000000..1fae02f9a4d --- /dev/null +++ b/erp/app/Modules/Inventory/Models/SalesOrderItem.php @@ -0,0 +1,38 @@ + 'float', + 'unit_price' => 'float', + 'shipped_qty' => 'float', + ]; + + public function salesOrder(): BelongsTo + { + return $this->belongsTo(SalesOrder::class); + } + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + public function getLineTotalAttribute(): float + { + return (float) $this->quantity * (float) $this->unit_price; + } +} diff --git a/erp/app/Modules/Inventory/Models/SerialNumber.php b/erp/app/Modules/Inventory/Models/SerialNumber.php new file mode 100644 index 00000000000..537111efb5e --- /dev/null +++ b/erp/app/Modules/Inventory/Models/SerialNumber.php @@ -0,0 +1,69 @@ + 'date', + 'sold_date' => 'date', + ]; + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + public function warehouse(): BelongsTo + { + return $this->belongsTo(Warehouse::class); + } + + public function lot(): BelongsTo + { + return $this->belongsTo(LotNumber::class, 'lot_number_id'); + } + + public function sell(?string $notes = null): void + { + $this->status = 'sold'; + $this->sold_date = Carbon::today(); + if ($notes !== null) { + $this->notes = $notes; + } + $this->save(); + } + + public function scrap(?string $notes = null): void + { + $this->status = 'scrapped'; + if ($notes !== null) { + $this->notes = $notes; + } + $this->save(); + } + + public function getIsAvailableAttribute(): bool + { + return $this->status === 'in_stock'; + } +} diff --git a/erp/app/Modules/Inventory/Models/Shipment.php b/erp/app/Modules/Inventory/Models/Shipment.php new file mode 100644 index 00000000000..981d1afaf69 --- /dev/null +++ b/erp/app/Modules/Inventory/Models/Shipment.php @@ -0,0 +1,119 @@ + 'date', + 'estimated_delivery' => 'date', + 'actual_delivery' => 'date', + 'weight_kg' => 'float', + 'freight_cost' => 'float', + ]; + + protected $attributes = [ + 'type' => 'outbound', + 'status' => 'pending', + ]; + + public function items(): HasMany + { + return $this->hasMany(ShipmentItem::class); + } + + public function warehouse(): BelongsTo + { + return $this->belongsTo(Warehouse::class); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function dispatch(): void + { + if ($this->shipment_number === null) { + $this->shipment_number = $this->generateShipmentNumber(); + } + $this->status = 'in-transit'; + $this->ship_date = $this->ship_date ?? now()->toDateString(); + $this->save(); + } + + public function deliver(): void + { + $this->status = 'delivered'; + $this->actual_delivery = now()->toDateString(); + $this->save(); + } + + public function returnShipment(): void + { + $this->status = 'returned'; + $this->save(); + } + + public function cancel(): void + { + $this->status = 'cancelled'; + $this->save(); + } + + public function generateShipmentNumber(): string + { + $prefix = $this->type === 'inbound' ? 'SHI' : 'SHO'; + return $prefix . '-' . date('Y') . '-' . str_pad((string) $this->id, 5, '0', STR_PAD_LEFT); + } + + protected function isPending(): Attribute + { + return Attribute::make(get: fn () => $this->status === 'pending'); + } + + protected function isInTransit(): Attribute + { + return Attribute::make(get: fn () => $this->status === 'in-transit'); + } + + protected function isDelivered(): Attribute + { + return Attribute::make(get: fn () => $this->status === 'delivered'); + } + + protected function totalItems(): Attribute + { + return Attribute::make(get: fn () => $this->items()->count()); + } +} diff --git a/erp/app/Modules/Inventory/Models/ShipmentItem.php b/erp/app/Modules/Inventory/Models/ShipmentItem.php new file mode 100644 index 00000000000..b9c4b764dfa --- /dev/null +++ b/erp/app/Modules/Inventory/Models/ShipmentItem.php @@ -0,0 +1,33 @@ + 'float', + 'weight_kg' => 'float', + ]; + + public function shipment(): BelongsTo + { + return $this->belongsTo(Shipment::class); + } + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } +} diff --git a/erp/app/Modules/Inventory/Models/StockAdjustment.php b/erp/app/Modules/Inventory/Models/StockAdjustment.php new file mode 100644 index 00000000000..c7eb607b25b --- /dev/null +++ b/erp/app/Modules/Inventory/Models/StockAdjustment.php @@ -0,0 +1,68 @@ + 'datetime']; + + public function warehouse(): BelongsTo + { + return $this->belongsTo(Warehouse::class); + } + + public function adjuster(): BelongsTo + { + return $this->belongsTo(User::class, 'adjusted_by'); + } + + public function items(): HasMany + { + return $this->hasMany(StockAdjustmentItem::class); + } + + /** + * Confirm the adjustment: create stock movements for each item with a difference. + */ + public function confirm(User $user): void + { + abort_unless($this->status === 'draft', 422, 'Only draft adjustments can be confirmed.'); + $this->load('items.product', 'warehouse'); + + foreach ($this->items as $item) { + if ($item->difference == 0) { + continue; + } + + $type = $item->difference > 0 ? 'in' : 'out'; + + StockMovement::record([ + 'product_id' => $item->product_id, + 'warehouse_id' => $this->warehouse_id, + 'type' => $type, + 'quantity' => abs((float) $item->difference), + 'reference' => $this->reference, + ]); + } + + $this->update([ + 'status' => 'confirmed', + 'adjusted_by' => $user->id, + 'confirmed_at' => now(), + ]); + } +} diff --git a/erp/app/Modules/Inventory/Models/StockAdjustmentItem.php b/erp/app/Modules/Inventory/Models/StockAdjustmentItem.php new file mode 100644 index 00000000000..0a97fc3db9e --- /dev/null +++ b/erp/app/Modules/Inventory/Models/StockAdjustmentItem.php @@ -0,0 +1,29 @@ + 'decimal:2', + 'actual_quantity' => 'decimal:2', + 'difference' => 'decimal:2', + ]; + + public function stockAdjustment(): BelongsTo + { + return $this->belongsTo(StockAdjustment::class); + } + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } +} diff --git a/erp/app/Modules/Inventory/Models/StockLevel.php b/erp/app/Modules/Inventory/Models/StockLevel.php new file mode 100644 index 00000000000..7de4d65d5e3 --- /dev/null +++ b/erp/app/Modules/Inventory/Models/StockLevel.php @@ -0,0 +1,54 @@ + 'decimal:2', + 'reserved_quantity' => 'decimal:2', + ]; + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + public function warehouse(): BelongsTo + { + return $this->belongsTo(Warehouse::class); + } + + public function getAvailableAttribute(): float + { + return (float) $this->quantity - (float) $this->reserved_quantity; + } + + public function checkReorderRules(): void + { + $rules = \App\Modules\Inventory\Models\ReorderRule::where('product_id', $this->product_id) + ->where('is_active', true) + ->where('status', 'active') + ->get(); + + foreach ($rules as $rule) { + if ((float) $this->quantity <= (float) $rule->reorder_point) { + $product = $this->product ?? \App\Modules\Inventory\Models\Product::find($this->product_id); + if ($product) { + event(new \App\Events\Inventory\InventoryStockLow($product, $this, $rule)); + } + } + } + } +} diff --git a/erp/app/Modules/Inventory/Models/StockMovement.php b/erp/app/Modules/Inventory/Models/StockMovement.php new file mode 100644 index 00000000000..1d398af3990 --- /dev/null +++ b/erp/app/Modules/Inventory/Models/StockMovement.php @@ -0,0 +1,101 @@ + 'decimal:2']; + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + public function warehouse(): BelongsTo + { + return $this->belongsTo(Warehouse::class); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function lot(): BelongsTo + { + return $this->belongsTo(LotNumber::class, 'lot_id'); + } + + public function serial(): BelongsTo + { + return $this->belongsTo(SerialNumber::class, 'serial_id'); + } + + /** + * Record a stock movement and update StockLevel atomically. + * + * @param array{product_id:int, warehouse_id:int, type:string, quantity:float, reference?:string, notes?:string} $data + */ + public static function record(array $data): self + { + return DB::transaction(function () use ($data) { + $tenantId = app()->has('tenant') ? app('tenant')->id : null; + + $level = StockLevel::firstOrCreate( + ['product_id' => $data['product_id'], 'warehouse_id' => $data['warehouse_id']], + ['tenant_id' => $tenantId, 'quantity' => 0, 'reserved_quantity' => 0], + ); + + $qty = (float) $data['quantity']; + + match ($data['type']) { + 'in' => $level->increment('quantity', $qty), + 'out' => self::deductStock($level, $qty), + 'adjustment' => self::adjustStock($level, $qty), + default => null, + }; + + return self::create([ + ...$data, + 'tenant_id' => $tenantId, + 'created_by' => Auth::id(), + 'quantity' => $qty, + ]); + }); + } + + private static function deductStock(StockLevel $level, float $qty): void + { + $available = (float) $level->quantity - (float) $level->reserved_quantity; + + if ($qty > $available) { + throw new \DomainException( + "Insufficient stock. Available: {$available}, requested: {$qty}." + ); + } + + $level->decrement('quantity', $qty); + } + + private static function adjustStock(StockLevel $level, float $newQty): void + { + $level->update(['quantity' => max(0, $newQty)]); + } +} diff --git a/erp/app/Modules/Inventory/Models/StockPicking.php b/erp/app/Modules/Inventory/Models/StockPicking.php new file mode 100644 index 00000000000..4b4f7acc226 --- /dev/null +++ b/erp/app/Modules/Inventory/Models/StockPicking.php @@ -0,0 +1,160 @@ + 'incoming', + 'status' => 'draft', + ]; + + protected $casts = [ + 'scheduled_date' => 'date', + 'done_date' => 'date', + ]; + + // Relations + + public function lines(): HasMany + { + return $this->hasMany(StockPickingLine::class); + } + + public function warehouse(): BelongsTo + { + return $this->belongsTo(Warehouse::class); + } + + public function sourceLocation(): BelongsTo + { + return $this->belongsTo(WarehouseZone::class, 'source_location_id'); + } + + public function destinationLocation(): BelongsTo + { + return $this->belongsTo(WarehouseZone::class, 'destination_location_id'); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function validator(): BelongsTo + { + return $this->belongsTo(User::class, 'validated_by'); + } + + // Methods + + public function confirm(): void + { + if ($this->picking_number === null) { + $this->picking_number = $this->generatePickingNumber(); + } + $this->status = 'confirmed'; + $this->save(); + } + + public function startProcessing(): void + { + $this->status = 'in_progress'; + $this->save(); + } + + public function validate(int $userId): void + { + $this->status = 'done'; + $this->done_date = now(); + $this->validated_by = $userId; + $this->save(); + } + + public function cancel(): void + { + $this->status = 'cancelled'; + $this->save(); + } + + public function generatePickingNumber(): string + { + $prefix = match ($this->picking_type) { + 'outgoing' => 'WH/OUT/', + 'internal' => 'WH/INT/', + 'return' => 'WH/RET/', + default => 'WH/IN/', + }; + + return $prefix . date('Y') . '/' . str_pad((string) $this->id, 5, '0', STR_PAD_LEFT); + } + + // Accessors + + protected function isDraft(): Attribute + { + return Attribute::make( + get: fn () => $this->status === 'draft', + ); + } + + protected function isConfirmed(): Attribute + { + return Attribute::make( + get: fn () => $this->status === 'confirmed', + ); + } + + protected function isDone(): Attribute + { + return Attribute::make( + get: fn () => $this->status === 'done', + ); + } + + protected function totalLines(): Attribute + { + return Attribute::make( + get: fn () => $this->lines()->count(), + ); + } + + protected function pickingTypeLabel(): Attribute + { + return Attribute::make( + get: fn () => match ($this->picking_type) { + 'outgoing' => 'Outgoing Delivery', + 'internal' => 'Internal Transfer', + 'return' => 'Return', + default => 'Incoming Receipt', + }, + ); + } +} diff --git a/erp/app/Modules/Inventory/Models/StockPickingLine.php b/erp/app/Modules/Inventory/Models/StockPickingLine.php new file mode 100644 index 00000000000..518c0c79d9d --- /dev/null +++ b/erp/app/Modules/Inventory/Models/StockPickingLine.php @@ -0,0 +1,64 @@ + 'pending', + 'qty_demanded' => 0, + 'qty_done' => 0, + ]; + + protected $casts = [ + 'qty_demanded' => 'float', + 'qty_done' => 'float', + ]; + + // Relations + + public function picking(): BelongsTo + { + return $this->belongsTo(StockPicking::class); + } + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + public function lot(): BelongsTo + { + return $this->belongsTo(LotNumber::class, 'lot_id'); + } + + public function serial(): BelongsTo + { + return $this->belongsTo(SerialNumber::class, 'serial_id'); + } + + // Accessors + + protected function remainingQty(): Attribute + { + return Attribute::make( + get: fn () => max(0, $this->qty_demanded - $this->qty_done), + ); + } +} diff --git a/erp/app/Modules/Inventory/Models/StockReservation.php b/erp/app/Modules/Inventory/Models/StockReservation.php new file mode 100644 index 00000000000..76db74e3ebf --- /dev/null +++ b/erp/app/Modules/Inventory/Models/StockReservation.php @@ -0,0 +1,104 @@ + 'active', + 'quantity_fulfilled' => 0, + ]; + + protected $casts = [ + 'quantity' => 'decimal:2', + 'quantity_fulfilled' => 'decimal:2', + 'reserved_until' => 'date', + ]; + + // Relations + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + // Mutating methods + + public function fulfill(float $quantity): void + { + $this->quantity_fulfilled = (float) $this->quantity_fulfilled + $quantity; + if ((float) $this->quantity_fulfilled >= (float) $this->quantity) { + $this->status = 'fulfilled'; + } + $this->save(); + } + + public function cancel(): void + { + $this->status = 'cancelled'; + $this->save(); + } + + public function expire(): void + { + $this->status = 'expired'; + $this->save(); + } + + public function generateReservationNumber(): string + { + return 'SR-' . date('Y') . '-' . str_pad((string) $this->id, 5, '0', STR_PAD_LEFT); + } + + // Accessors + + public function getQuantityRemainingAttribute(): float + { + return max(0, (float) $this->quantity - (float) $this->quantity_fulfilled); + } + + public function getIsActiveAttribute(): bool + { + return $this->status === 'active'; + } + + public function getIsFulfilledAttribute(): bool + { + return $this->status === 'fulfilled'; + } + + public function getIsExpiredAttribute(): bool + { + if ($this->status === 'expired') { + return true; + } + + if ($this->status === 'active' && $this->reserved_until !== null) { + return $this->reserved_until->lt(now()->startOfDay()); + } + + return false; + } +} diff --git a/erp/app/Modules/Inventory/Models/StockTransfer.php b/erp/app/Modules/Inventory/Models/StockTransfer.php new file mode 100644 index 00000000000..80d277fb55f --- /dev/null +++ b/erp/app/Modules/Inventory/Models/StockTransfer.php @@ -0,0 +1,84 @@ + 'datetime', + ]; + + public function fromWarehouse(): BelongsTo + { + return $this->belongsTo(Warehouse::class, 'from_warehouse_id'); + } + + public function toWarehouse(): BelongsTo + { + return $this->belongsTo(Warehouse::class, 'to_warehouse_id'); + } + + public function items(): HasMany + { + return $this->hasMany(StockTransferItem::class); + } + + public function complete(): void + { + $this->loadMissing('items'); + + foreach ($this->items as $item) { + // Decrement from source + $fromStock = WarehouseStock::firstOrCreate( + [ + 'tenant_id' => $this->tenant_id, + 'warehouse_id' => $this->from_warehouse_id, + 'product_id' => $item->product_id, + ], + ['quantity' => 0] + ); + $fromStock->decrement('quantity', $item->quantity); + + // Increment at destination + $toStock = WarehouseStock::firstOrCreate( + [ + 'tenant_id' => $this->tenant_id, + 'warehouse_id' => $this->to_warehouse_id, + 'product_id' => $item->product_id, + ], + ['quantity' => 0] + ); + $toStock->increment('quantity', $item->quantity); + } + + $this->status = 'completed'; + $this->transferred_at = Carbon::now(); + $this->save(); + } + + public function cancel(): void + { + $this->status = 'cancelled'; + $this->save(); + } +} diff --git a/erp/app/Modules/Inventory/Models/StockTransferItem.php b/erp/app/Modules/Inventory/Models/StockTransferItem.php new file mode 100644 index 00000000000..15f9d9eab0c --- /dev/null +++ b/erp/app/Modules/Inventory/Models/StockTransferItem.php @@ -0,0 +1,33 @@ + 'decimal:4', + ]; + + public function stockTransfer(): BelongsTo + { + return $this->belongsTo(StockTransfer::class); + } + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } +} diff --git a/erp/app/Modules/Inventory/Models/Supplier.php b/erp/app/Modules/Inventory/Models/Supplier.php new file mode 100644 index 00000000000..c645402e362 --- /dev/null +++ b/erp/app/Modules/Inventory/Models/Supplier.php @@ -0,0 +1,56 @@ + 'boolean']; + + public function purchaseOrders(): HasMany + { + return $this->hasMany(PurchaseOrder::class); + } + + public function reviews(): HasMany + { + return $this->hasMany(SupplierReview::class); + } + + public function contracts(): HasMany + { + return $this->hasMany(SupplierContract::class); + } + + public function getAverageRatingAttribute(): ?float + { + if ($this->reviews->isEmpty()) { + return null; + } + + $avg = $this->reviews->map(fn ($r) => + ($r->quality_score + $r->delivery_score + $r->communication_score + $r->price_score) / 4 + )->avg(); + + return $avg !== null ? round($avg, 1) : null; + } + + public function getActiveContractAttribute(): ?SupplierContract + { + return $this->contracts->firstWhere('status', 'active'); + } +} diff --git a/erp/app/Modules/Inventory/Models/SupplierContract.php b/erp/app/Modules/Inventory/Models/SupplierContract.php new file mode 100644 index 00000000000..52fa41d451e --- /dev/null +++ b/erp/app/Modules/Inventory/Models/SupplierContract.php @@ -0,0 +1,62 @@ + 'date', + 'end_date' => 'date', + 'value' => 'float', + ]; + + public function supplier(): BelongsTo + { + return $this->belongsTo(Supplier::class); + } + + public function getIsActiveAttribute(): bool + { + return $this->status === 'active'; + } + + public function getIsExpiredAttribute(): bool + { + return $this->end_date !== null && $this->end_date->isPast(); + } + + public function getIsExpiringAttribute(): bool + { + return $this->end_date !== null + && $this->end_date->isFuture() + && $this->end_date->diffInDays(now()) <= 30; + } + + public function getDaysRemainingAttribute(): ?int + { + return $this->end_date !== null + ? max(0, (int) now()->diffInDays($this->end_date, false)) + : null; + } +} diff --git a/erp/app/Modules/Inventory/Models/SupplierReview.php b/erp/app/Modules/Inventory/Models/SupplierReview.php new file mode 100644 index 00000000000..ba3a53669d4 --- /dev/null +++ b/erp/app/Modules/Inventory/Models/SupplierReview.php @@ -0,0 +1,50 @@ + 'date', + 'quality_score' => 'integer', + 'delivery_score' => 'integer', + 'communication_score' => 'integer', + 'price_score' => 'integer', + ]; + + public function supplier(): BelongsTo + { + return $this->belongsTo(Supplier::class); + } + + public function reviewedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'reviewed_by'); + } + + public function getOverallScoreAttribute(): float + { + $avg = ($this->quality_score + $this->delivery_score + $this->communication_score + $this->price_score) / 4; + return round($avg, 1); + } +} diff --git a/erp/app/Modules/Inventory/Models/SupplierScorecard.php b/erp/app/Modules/Inventory/Models/SupplierScorecard.php new file mode 100644 index 00000000000..951a8b3d251 --- /dev/null +++ b/erp/app/Modules/Inventory/Models/SupplierScorecard.php @@ -0,0 +1,101 @@ + 'draft', + 'rating' => 'pending', + 'quality_score' => 0, + 'delivery_score' => 0, + 'pricing_score' => 0, + 'service_score' => 0, + 'overall_score' => 0, + ]; + + protected $casts = [ + 'quality_score' => 'decimal:2', + 'delivery_score' => 'decimal:2', + 'pricing_score' => 'decimal:2', + 'service_score' => 'decimal:2', + 'overall_score' => 'decimal:2', + 'published_at' => 'datetime', + ]; + + public function calculateOverallScore(): void + { + $avg = ((float) $this->quality_score + + (float) $this->delivery_score + + (float) $this->pricing_score + + (float) $this->service_score) / 4.0; + + $this->overall_score = round($avg, 2); + + if ($avg < 40) { + $this->rating = 'poor'; + } elseif ($avg < 60) { + $this->rating = 'fair'; + } elseif ($avg < 80) { + $this->rating = 'good'; + } else { + $this->rating = 'excellent'; + } + + $this->save(); + } + + public function publish(int $userId): void + { + $this->calculateOverallScore(); + + if ($this->scorecard_number === null) { + $this->scorecard_number = $this->generateScorecardNumber(); + } + + $this->status = 'published'; + $this->evaluated_by = $userId; + $this->published_at = now(); + + $this->save(); + } + + public function generateScorecardNumber(): string + { + return 'SC-' . date('Y') . '-' . str_pad((string) $this->id, 5, '0', STR_PAD_LEFT); + } + + public function getIsPublishedAttribute(): bool + { + return $this->status === 'published'; + } + + public function getIsDraftAttribute(): bool + { + return $this->status === 'draft'; + } +} diff --git a/erp/app/Modules/Inventory/Models/UnitOfMeasure.php b/erp/app/Modules/Inventory/Models/UnitOfMeasure.php new file mode 100644 index 00000000000..0bfe4e4bfb8 --- /dev/null +++ b/erp/app/Modules/Inventory/Models/UnitOfMeasure.php @@ -0,0 +1,51 @@ + 'boolean', + 'is_active' => 'boolean', + 'conversion_factor' => 'float', + ]; + + public function products(): HasMany + { + return $this->hasMany(Product::class, 'uom_id'); + } + + public function convertTo(float $quantity, self $target): float + { + if ($target->conversion_factor == 0) { + return 0.0; + } + + return ($quantity * $this->conversion_factor) / $target->conversion_factor; + } + + public function getDisplayNameAttribute(): string + { + return "{$this->name} ({$this->abbreviation})"; + } +} diff --git a/erp/app/Modules/Inventory/Models/Vehicle.php b/erp/app/Modules/Inventory/Models/Vehicle.php new file mode 100644 index 00000000000..f1174134f71 --- /dev/null +++ b/erp/app/Modules/Inventory/Models/Vehicle.php @@ -0,0 +1,78 @@ + 'float', + 'insurance_expiry' => 'date', + 'registration_expiry' => 'date', + 'year' => 'integer', + ]; + + public function logs(): HasMany + { + return $this->hasMany(VehicleLog::class); + } + + public function assignedEmployee(): BelongsTo + { + return $this->belongsTo(Employee::class, 'assigned_to_employee_id'); + } + + public function assign(int $employeeId): void + { + $this->assigned_to_employee_id = $employeeId; + $this->status = 'in_use'; + $this->save(); + } + + public function unassign(): void + { + $this->assigned_to_employee_id = null; + $this->status = 'available'; + $this->save(); + } + + public function retire(): void + { + $this->status = 'retired'; + $this->save(); + } + + public function getIsInsuranceExpiringAttribute(): bool + { + return $this->insurance_expiry !== null + && $this->insurance_expiry->isFuture() + && $this->insurance_expiry->diffInDays(now()) <= 30; + } + + public function getIsRegistrationExpiringAttribute(): bool + { + return $this->registration_expiry !== null + && $this->registration_expiry->isFuture() + && $this->registration_expiry->diffInDays(now()) <= 30; + } + + public function getTotalDistanceAttribute(): float + { + return (float) $this->logs()->sum('distance_km'); + } +} diff --git a/erp/app/Modules/Inventory/Models/VehicleLog.php b/erp/app/Modules/Inventory/Models/VehicleLog.php new file mode 100644 index 00000000000..54a7e47f511 --- /dev/null +++ b/erp/app/Modules/Inventory/Models/VehicleLog.php @@ -0,0 +1,41 @@ + 'date', + 'odometer_start' => 'float', + 'odometer_end' => 'float', + 'distance_km' => 'float', + 'fuel_litres' => 'float', + 'cost' => 'float', + ]; + + public function vehicle(): BelongsTo + { + return $this->belongsTo(Vehicle::class); + } + + public function getFuelEfficiencyAttribute(): ?float + { + if ($this->fuel_litres > 0 && $this->distance_km > 0) { + return round($this->distance_km / $this->fuel_litres, 2); + } + + return null; + } +} diff --git a/erp/app/Modules/Inventory/Models/Warehouse.php b/erp/app/Modules/Inventory/Models/Warehouse.php new file mode 100644 index 00000000000..425e3d2eb17 --- /dev/null +++ b/erp/app/Modules/Inventory/Models/Warehouse.php @@ -0,0 +1,40 @@ + 'boolean']; + + public function stockLevels(): HasMany + { + return $this->hasMany(StockLevel::class); + } + + public function stockMovements(): HasMany + { + return $this->hasMany(StockMovement::class); + } + + public function purchaseOrders(): HasMany + { + return $this->hasMany(PurchaseOrder::class); + } + + public function warehouseStock(): HasMany + { + return $this->hasMany(WarehouseStock::class); + } +} diff --git a/erp/app/Modules/Inventory/Models/WarehouseBin.php b/erp/app/Modules/Inventory/Models/WarehouseBin.php new file mode 100644 index 00000000000..d7448cb9b45 --- /dev/null +++ b/erp/app/Modules/Inventory/Models/WarehouseBin.php @@ -0,0 +1,60 @@ + 'decimal:2', + 'is_active' => 'boolean', + ]; + + public function warehouse(): BelongsTo + { + return $this->belongsTo(Warehouse::class); + } + + public function zone(): BelongsTo + { + return $this->belongsTo(WarehouseZone::class, 'zone_id'); + } + + public function stockLocations(): HasMany + { + return $this->hasMany(BinStockLocation::class, 'bin_id'); + } + + public function getUsedCapacityAttribute(): float + { + return (float) $this->stockLocations->sum('quantity'); + } + + public function getAvailableCapacityAttribute(): ?float + { + if ($this->capacity === null) { + return null; + } + + return max(0, (float) $this->capacity - $this->used_capacity); + } +} diff --git a/erp/app/Modules/Inventory/Models/WarehouseStock.php b/erp/app/Modules/Inventory/Models/WarehouseStock.php new file mode 100644 index 00000000000..b9dd5f0067d --- /dev/null +++ b/erp/app/Modules/Inventory/Models/WarehouseStock.php @@ -0,0 +1,46 @@ + 'decimal:4', + 'reorder_point' => 'decimal:4', + ]; + + public function warehouse(): BelongsTo + { + return $this->belongsTo(Warehouse::class); + } + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + public function getIsBelowReorderPointAttribute(): bool + { + if ($this->reorder_point === null) { + return false; + } + + return (float) $this->quantity < (float) $this->reorder_point; + } +} diff --git a/erp/app/Modules/Inventory/Models/WarehouseTransfer.php b/erp/app/Modules/Inventory/Models/WarehouseTransfer.php new file mode 100644 index 00000000000..8f00564a6b8 --- /dev/null +++ b/erp/app/Modules/Inventory/Models/WarehouseTransfer.php @@ -0,0 +1,82 @@ + 'float', + ]; + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + public function fromWarehouse(): BelongsTo + { + return $this->belongsTo(Warehouse::class, 'from_warehouse_id'); + } + + public function toWarehouse(): BelongsTo + { + return $this->belongsTo(Warehouse::class, 'to_warehouse_id'); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + public static function execute(array $data): static + { + return DB::transaction(function () use ($data) { + // Record OUT from source warehouse + StockMovement::record([ + 'product_id' => $data['product_id'], + 'warehouse_id' => $data['from_warehouse_id'], + 'type' => 'out', + 'quantity' => $data['quantity'], + 'reference' => $data['reference'] ?? null, + 'notes' => $data['notes'] ?? null, + ]); + + // Record IN to destination warehouse + StockMovement::record([ + 'product_id' => $data['product_id'], + 'warehouse_id' => $data['to_warehouse_id'], + 'type' => 'in', + 'quantity' => $data['quantity'], + 'reference' => $data['reference'] ?? null, + 'notes' => $data['notes'] ?? null, + ]); + + // Create the transfer record + return static::create([ + 'tenant_id' => $data['tenant_id'], + 'product_id' => $data['product_id'], + 'from_warehouse_id' => $data['from_warehouse_id'], + 'to_warehouse_id' => $data['to_warehouse_id'], + 'quantity' => $data['quantity'], + 'reference' => $data['reference'] ?? null, + 'notes' => $data['notes'] ?? null, + 'status' => 'completed', + 'created_by' => Auth::id(), + ]); + }); + } +} diff --git a/erp/app/Modules/Inventory/Models/WarehouseZone.php b/erp/app/Modules/Inventory/Models/WarehouseZone.php new file mode 100644 index 00000000000..2e192e2eaa5 --- /dev/null +++ b/erp/app/Modules/Inventory/Models/WarehouseZone.php @@ -0,0 +1,38 @@ + 'boolean', + ]; + + public function warehouse(): BelongsTo + { + return $this->belongsTo(Warehouse::class); + } + + public function bins(): HasMany + { + return $this->hasMany(WarehouseBin::class, 'zone_id'); + } +} diff --git a/erp/app/Modules/Inventory/Models/WarrantyClaim.php b/erp/app/Modules/Inventory/Models/WarrantyClaim.php new file mode 100644 index 00000000000..4e88244943a --- /dev/null +++ b/erp/app/Modules/Inventory/Models/WarrantyClaim.php @@ -0,0 +1,108 @@ + 'date', + 'claim_date' => 'date', + 'warranty_expiry' => 'date', + 'resolved_date' => 'date', + ]; + + protected $attributes = [ + 'status' => 'open', + ]; + + public function warranty(): BelongsTo + { + return $this->belongsTo(ProductWarranty::class, 'product_warranty_id'); + } + + public function serialNumber(): BelongsTo + { + return $this->belongsTo(SerialNumber::class, 'serial_number_id'); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function assignee(): BelongsTo + { + return $this->belongsTo(User::class, 'assigned_to'); + } + + public function approve(): void + { + $this->status = 'approved'; + $this->save(); + } + + public function reject(): void + { + $this->status = 'rejected'; + $this->save(); + } + + public function resolve(string $resolutionType, ?string $notes = null): void + { + $this->status = 'resolved'; + $this->resolution_type = $resolutionType; + $this->resolution_notes = $notes; + $this->resolved_date = Carbon::today(); + $this->save(); + } + + public function generateClaimNumber(): string + { + return 'WC-' . now()->year . '-' . str_pad((string) $this->id, 5, '0', STR_PAD_LEFT); + } + + public function isOpen(): Attribute + { + return Attribute::make( + get: fn () => $this->status === 'open', + ); + } + + public function isResolved(): Attribute + { + return Attribute::make( + get: fn () => $this->status === 'resolved', + ); + } +} diff --git a/erp/app/Modules/Inventory/Policies/AssetPolicy.php b/erp/app/Modules/Inventory/Policies/AssetPolicy.php new file mode 100644 index 00000000000..a9f74a90c4b --- /dev/null +++ b/erp/app/Modules/Inventory/Policies/AssetPolicy.php @@ -0,0 +1,33 @@ +can('inventory.view'); + } + + public function view(User $user): bool + { + return $user->can('inventory.view'); + } + + public function create(User $user): bool + { + return $user->can('inventory.create'); + } + + public function update(User $user): bool + { + return $user->can('inventory.create'); + } + + public function delete(User $user): bool + { + return $user->can('inventory.delete'); + } +} diff --git a/erp/app/Modules/Inventory/Policies/BackorderPolicy.php b/erp/app/Modules/Inventory/Policies/BackorderPolicy.php new file mode 100644 index 00000000000..c96850e9f4e --- /dev/null +++ b/erp/app/Modules/Inventory/Policies/BackorderPolicy.php @@ -0,0 +1,33 @@ +hasPermissionTo('inventory.view'); + } + + public function view(User $user, $model): bool + { + return $user->hasPermissionTo('inventory.view'); + } + + public function create(User $user): bool + { + return $user->hasPermissionTo('inventory.create'); + } + + public function update(User $user, $model): bool + { + return $user->hasPermissionTo('inventory.create'); + } + + public function delete(User $user, $model): bool + { + return $user->hasPermissionTo('inventory.delete'); + } +} diff --git a/erp/app/Modules/Inventory/Policies/CostingPolicy.php b/erp/app/Modules/Inventory/Policies/CostingPolicy.php new file mode 100644 index 00000000000..fa9a0bd7cd2 --- /dev/null +++ b/erp/app/Modules/Inventory/Policies/CostingPolicy.php @@ -0,0 +1,33 @@ +can('inventory.view'); + } + + public function view(User $user): bool + { + return $user->can('inventory.view'); + } + + public function create(User $user): bool + { + return $user->can('inventory.create'); + } + + public function update(User $user): bool + { + return $user->can('inventory.create'); + } + + public function delete(User $user): bool + { + return $user->can('inventory.delete'); + } +} diff --git a/erp/app/Modules/Inventory/Policies/CycleCountPolicy.php b/erp/app/Modules/Inventory/Policies/CycleCountPolicy.php new file mode 100644 index 00000000000..63ab4356101 --- /dev/null +++ b/erp/app/Modules/Inventory/Policies/CycleCountPolicy.php @@ -0,0 +1,34 @@ +can('inventory.view'); + } + + public function view(User $user, CycleCount $cycleCount): bool + { + return $user->can('inventory.view'); + } + + public function create(User $user): bool + { + return $user->can('inventory.create'); + } + + public function update(User $user, CycleCount $cycleCount): bool + { + return $user->can('inventory.create'); + } + + public function delete(User $user, CycleCount $cycleCount): bool + { + return $user->can('inventory.delete'); + } +} diff --git a/erp/app/Modules/Inventory/Policies/ForecastPolicy.php b/erp/app/Modules/Inventory/Policies/ForecastPolicy.php new file mode 100644 index 00000000000..80f50b5169c --- /dev/null +++ b/erp/app/Modules/Inventory/Policies/ForecastPolicy.php @@ -0,0 +1,33 @@ +can('inventory.view'); + } + + public function view(User $user): bool + { + return $user->can('inventory.view'); + } + + public function create(User $user): bool + { + return $user->can('inventory.create'); + } + + public function update(User $user): bool + { + return $user->can('inventory.create'); + } + + public function delete(User $user): bool + { + return $user->can('inventory.delete'); + } +} diff --git a/erp/app/Modules/Inventory/Policies/GoodsReceiptPolicy.php b/erp/app/Modules/Inventory/Policies/GoodsReceiptPolicy.php new file mode 100644 index 00000000000..da510c1fd2e --- /dev/null +++ b/erp/app/Modules/Inventory/Policies/GoodsReceiptPolicy.php @@ -0,0 +1,49 @@ +can('inventory.view'); + } + + public function view(User $user, GoodsReceipt $goodsReceipt): bool + { + return $user->can('inventory.view'); + } + + public function create(User $user): bool + { + return $user->can('inventory.create'); + } + + public function update(User $user, GoodsReceipt $goodsReceipt): bool + { + return $user->can('inventory.create'); + } + + public function confirm(User $user, GoodsReceipt $goodsReceipt): bool + { + return $user->can('inventory.create'); + } + + public function post(User $user, GoodsReceipt $goodsReceipt): bool + { + return $user->can('inventory.create'); + } + + public function reject(User $user, GoodsReceipt $goodsReceipt): bool + { + return $user->can('inventory.delete'); + } + + public function delete(User $user, GoodsReceipt $goodsReceipt): bool + { + return $user->can('inventory.delete'); + } +} diff --git a/erp/app/Modules/Inventory/Policies/LotSerialPolicy.php b/erp/app/Modules/Inventory/Policies/LotSerialPolicy.php new file mode 100644 index 00000000000..9b1c81161c1 --- /dev/null +++ b/erp/app/Modules/Inventory/Policies/LotSerialPolicy.php @@ -0,0 +1,33 @@ +hasPermissionTo('inventory.view'); + } + + public function view(User $user, $model): bool + { + return $user->hasPermissionTo('inventory.view'); + } + + public function create(User $user): bool + { + return $user->hasPermissionTo('inventory.create'); + } + + public function update(User $user, $model): bool + { + return $user->hasPermissionTo('inventory.create'); + } + + public function delete(User $user, $model): bool + { + return $user->hasPermissionTo('inventory.delete'); + } +} diff --git a/erp/app/Modules/Inventory/Policies/PriceListPolicy.php b/erp/app/Modules/Inventory/Policies/PriceListPolicy.php new file mode 100644 index 00000000000..033740879ea --- /dev/null +++ b/erp/app/Modules/Inventory/Policies/PriceListPolicy.php @@ -0,0 +1,33 @@ +hasPermissionTo('inventory.view'); + } + + public function view(User $user, $model): bool + { + return $user->hasPermissionTo('inventory.view'); + } + + public function create(User $user): bool + { + return $user->hasPermissionTo('inventory.create'); + } + + public function update(User $user, $model): bool + { + return $user->hasPermissionTo('inventory.create'); + } + + public function delete(User $user, $model): bool + { + return $user->hasPermissionTo('inventory.delete'); + } +} diff --git a/erp/app/Modules/Inventory/Policies/ProductBundlePolicy.php b/erp/app/Modules/Inventory/Policies/ProductBundlePolicy.php new file mode 100644 index 00000000000..a3cd9cfa6be --- /dev/null +++ b/erp/app/Modules/Inventory/Policies/ProductBundlePolicy.php @@ -0,0 +1,34 @@ +hasPermissionTo('inventory.view'); + } + + public function view(User $user, ProductBundle $productBundle): bool + { + return $user->hasPermissionTo('inventory.view'); + } + + public function create(User $user): bool + { + return $user->hasPermissionTo('inventory.create'); + } + + public function update(User $user, ProductBundle $productBundle): bool + { + return $user->hasPermissionTo('inventory.create'); + } + + public function delete(User $user, ProductBundle $productBundle): bool + { + return $user->hasPermissionTo('inventory.delete'); + } +} diff --git a/erp/app/Modules/Inventory/Policies/ProductCategoryPolicy.php b/erp/app/Modules/Inventory/Policies/ProductCategoryPolicy.php new file mode 100644 index 00000000000..c53325c8063 --- /dev/null +++ b/erp/app/Modules/Inventory/Policies/ProductCategoryPolicy.php @@ -0,0 +1,34 @@ +can('inventory.view'); + } + + public function view(User $user, ProductCategory $productCategory): bool + { + return $user->can('inventory.view'); + } + + public function create(User $user): bool + { + return $user->can('inventory.create'); + } + + public function update(User $user, ProductCategory $productCategory): bool + { + return $user->can('inventory.create'); + } + + public function delete(User $user, ProductCategory $productCategory): bool + { + return $user->can('inventory.delete'); + } +} diff --git a/erp/app/Modules/Inventory/Policies/ProductPolicy.php b/erp/app/Modules/Inventory/Policies/ProductPolicy.php new file mode 100644 index 00000000000..ba88631448d --- /dev/null +++ b/erp/app/Modules/Inventory/Policies/ProductPolicy.php @@ -0,0 +1,34 @@ +can('inventory.view'); + } + + public function view(User $user, Product $product): bool + { + return $user->can('inventory.view'); + } + + public function create(User $user): bool + { + return $user->can('inventory.create'); + } + + public function update(User $user, Product $product): bool + { + return $user->can('inventory.update'); + } + + public function delete(User $user, Product $product): bool + { + return $user->can('inventory.delete'); + } +} diff --git a/erp/app/Modules/Inventory/Policies/ProductSubstitutePolicy.php b/erp/app/Modules/Inventory/Policies/ProductSubstitutePolicy.php new file mode 100644 index 00000000000..a9165197a12 --- /dev/null +++ b/erp/app/Modules/Inventory/Policies/ProductSubstitutePolicy.php @@ -0,0 +1,34 @@ +can('inventory.view'); + } + + public function view(User $user, ProductSubstitute $productSubstitute): bool + { + return $user->can('inventory.view'); + } + + public function create(User $user): bool + { + return $user->can('inventory.create'); + } + + public function update(User $user, ProductSubstitute $productSubstitute): bool + { + return $user->can('inventory.create'); + } + + public function delete(User $user, ProductSubstitute $productSubstitute): bool + { + return $user->can('inventory.delete'); + } +} diff --git a/erp/app/Modules/Inventory/Policies/ProductTagPolicy.php b/erp/app/Modules/Inventory/Policies/ProductTagPolicy.php new file mode 100644 index 00000000000..e19d9031e15 --- /dev/null +++ b/erp/app/Modules/Inventory/Policies/ProductTagPolicy.php @@ -0,0 +1,34 @@ +can('inventory.view'); + } + + public function view(User $user, ProductTag $productTag): bool + { + return $user->can('inventory.view'); + } + + public function create(User $user): bool + { + return $user->can('inventory.create'); + } + + public function update(User $user, ProductTag $productTag): bool + { + return $user->can('inventory.create'); + } + + public function delete(User $user, ProductTag $productTag): bool + { + return $user->can('inventory.delete'); + } +} diff --git a/erp/app/Modules/Inventory/Policies/ProductVariantPolicy.php b/erp/app/Modules/Inventory/Policies/ProductVariantPolicy.php new file mode 100644 index 00000000000..5f27dcb0b64 --- /dev/null +++ b/erp/app/Modules/Inventory/Policies/ProductVariantPolicy.php @@ -0,0 +1,17 @@ +hasPermissionTo('inventory.view'); } + public function view(User $user, $model): bool { return $user->hasPermissionTo('inventory.view'); } + public function create(User $user): bool { return $user->hasPermissionTo('inventory.create'); } + public function update(User $user, $model): bool { return $user->hasPermissionTo('inventory.create'); } + public function delete(User $user, $model): bool { return $user->hasPermissionTo('inventory.delete'); } +} diff --git a/erp/app/Modules/Inventory/Policies/PurchaseOrderPolicy.php b/erp/app/Modules/Inventory/Policies/PurchaseOrderPolicy.php new file mode 100644 index 00000000000..5bcd61bb944 --- /dev/null +++ b/erp/app/Modules/Inventory/Policies/PurchaseOrderPolicy.php @@ -0,0 +1,33 @@ +hasPermissionTo('inventory.view'); + } + + public function view(User $user, $model): bool + { + return $user->hasPermissionTo('inventory.view'); + } + + public function create(User $user): bool + { + return $user->hasPermissionTo('inventory.create'); + } + + public function update(User $user, $model): bool + { + return $user->hasPermissionTo('inventory.create'); + } + + public function delete(User $user, $model): bool + { + return $user->hasPermissionTo('inventory.delete'); + } +} diff --git a/erp/app/Modules/Inventory/Policies/PurchaseRequestPolicy.php b/erp/app/Modules/Inventory/Policies/PurchaseRequestPolicy.php new file mode 100644 index 00000000000..4dc1d68e938 --- /dev/null +++ b/erp/app/Modules/Inventory/Policies/PurchaseRequestPolicy.php @@ -0,0 +1,59 @@ +can('inventory.view'); + } + + public function view(User $user, PurchaseRequest $purchaseRequest): bool + { + return $user->can('inventory.view'); + } + + public function create(User $user): bool + { + return $user->can('inventory.create'); + } + + public function update(User $user, PurchaseRequest $purchaseRequest): bool + { + return $user->can('inventory.create'); + } + + public function submit(User $user, PurchaseRequest $purchaseRequest): bool + { + return $user->can('inventory.create'); + } + + public function approve(User $user, PurchaseRequest $purchaseRequest): bool + { + return $user->can('inventory.create'); + } + + public function markOrdered(User $user, PurchaseRequest $purchaseRequest): bool + { + return $user->can('inventory.create'); + } + + public function reject(User $user, PurchaseRequest $purchaseRequest): bool + { + return $user->can('inventory.delete'); + } + + public function cancel(User $user, PurchaseRequest $purchaseRequest): bool + { + return $user->can('inventory.delete'); + } + + public function delete(User $user, PurchaseRequest $purchaseRequest): bool + { + return $user->can('inventory.delete'); + } +} diff --git a/erp/app/Modules/Inventory/Policies/PurchaseRequisitionPolicy.php b/erp/app/Modules/Inventory/Policies/PurchaseRequisitionPolicy.php new file mode 100644 index 00000000000..70dbafd8c73 --- /dev/null +++ b/erp/app/Modules/Inventory/Policies/PurchaseRequisitionPolicy.php @@ -0,0 +1,39 @@ +can('inventory.view'); + } + + public function view(User $user, PurchaseRequisition $pr): bool + { + return $user->can('inventory.view'); + } + + public function create(User $user): bool + { + return $user->can('inventory.create'); + } + + public function update(User $user, PurchaseRequisition $pr): bool + { + return $user->can('inventory.create'); + } + + public function approve(User $user, PurchaseRequisition $pr): bool + { + return $user->hasRole(['super-admin', 'admin', 'manager']); + } + + public function delete(User $user, PurchaseRequisition $pr): bool + { + return $user->can('inventory.delete'); + } +} diff --git a/erp/app/Modules/Inventory/Policies/PutAwayRulePolicy.php b/erp/app/Modules/Inventory/Policies/PutAwayRulePolicy.php new file mode 100644 index 00000000000..02da08be2fe --- /dev/null +++ b/erp/app/Modules/Inventory/Policies/PutAwayRulePolicy.php @@ -0,0 +1,34 @@ +can('inventory.view'); + } + + public function view(User $user, PutAwayRule $rule): bool + { + return $user->can('inventory.view'); + } + + public function create(User $user): bool + { + return $user->can('inventory.create'); + } + + public function update(User $user, PutAwayRule $rule): bool + { + return $user->can('inventory.create'); + } + + public function delete(User $user, PutAwayRule $rule): bool + { + return $user->can('inventory.delete'); + } +} diff --git a/erp/app/Modules/Inventory/Policies/QcPolicy.php b/erp/app/Modules/Inventory/Policies/QcPolicy.php new file mode 100644 index 00000000000..1d1af424623 --- /dev/null +++ b/erp/app/Modules/Inventory/Policies/QcPolicy.php @@ -0,0 +1,34 @@ +can('inventory.view'); + } + + public function view(User $user, Model $model): bool + { + return $user->can('inventory.view'); + } + + public function create(User $user): bool + { + return $user->can('inventory.create'); + } + + public function update(User $user, Model $model): bool + { + return $user->can('inventory.create'); + } + + public function delete(User $user, Model $model): bool + { + return $user->can('inventory.delete'); + } +} diff --git a/erp/app/Modules/Inventory/Policies/QualityAlertPolicy.php b/erp/app/Modules/Inventory/Policies/QualityAlertPolicy.php new file mode 100644 index 00000000000..1f4880f5c52 --- /dev/null +++ b/erp/app/Modules/Inventory/Policies/QualityAlertPolicy.php @@ -0,0 +1,48 @@ +can('inventory.view'); + } + + public function view(User $user): bool + { + return $user->can('inventory.view'); + } + + public function create(User $user): bool + { + return $user->can('inventory.create'); + } + + public function update(User $user): bool + { + return $user->can('inventory.create'); + } + + public function investigate(User $user): bool + { + return $user->can('inventory.create'); + } + + public function resolve(User $user): bool + { + return $user->can('inventory.create'); + } + + public function close(User $user): bool + { + return $user->can('inventory.create'); + } + + public function delete(User $user): bool + { + return $user->can('inventory.delete'); + } +} diff --git a/erp/app/Modules/Inventory/Policies/ReorderRulePolicy.php b/erp/app/Modules/Inventory/Policies/ReorderRulePolicy.php new file mode 100644 index 00000000000..ee441afc0b5 --- /dev/null +++ b/erp/app/Modules/Inventory/Policies/ReorderRulePolicy.php @@ -0,0 +1,49 @@ +hasPermissionTo('inventory.view'); + } + + public function view(User $user, ReorderRule $r): bool + { + return $user->hasPermissionTo('inventory.view'); + } + + public function create(User $user): bool + { + return $user->hasPermissionTo('inventory.create'); + } + + public function update(User $user, ReorderRule $r): bool + { + return $user->hasPermissionTo('inventory.create'); + } + + public function delete(User $user, ReorderRule $r): bool + { + return $user->hasPermissionTo('inventory.delete'); + } + + public function trigger(User $user, ReorderRule $r): bool + { + return $user->hasPermissionTo('inventory.create'); + } + + public function pause(User $user, ReorderRule $r): bool + { + return $user->hasPermissionTo('inventory.create'); + } + + public function resume(User $user, ReorderRule $r): bool + { + return $user->hasPermissionTo('inventory.create'); + } +} diff --git a/erp/app/Modules/Inventory/Policies/ReplenishmentOrderPolicy.php b/erp/app/Modules/Inventory/Policies/ReplenishmentOrderPolicy.php new file mode 100644 index 00000000000..3f9f0b6aec6 --- /dev/null +++ b/erp/app/Modules/Inventory/Policies/ReplenishmentOrderPolicy.php @@ -0,0 +1,54 @@ +can('inventory.view'); + } + + public function view(User $user, ReplenishmentOrder $replenishmentOrder): bool + { + return $user->can('inventory.view'); + } + + public function create(User $user): bool + { + return $user->can('inventory.create'); + } + + public function update(User $user, ReplenishmentOrder $replenishmentOrder): bool + { + return $user->can('inventory.create'); + } + + public function confirm(User $user, ReplenishmentOrder $replenishmentOrder): bool + { + return $user->can('inventory.create'); + } + + public function markInProgress(User $user, ReplenishmentOrder $replenishmentOrder): bool + { + return $user->can('inventory.create'); + } + + public function complete(User $user, ReplenishmentOrder $replenishmentOrder): bool + { + return $user->can('inventory.create'); + } + + public function cancel(User $user, ReplenishmentOrder $replenishmentOrder): bool + { + return $user->can('inventory.delete'); + } + + public function delete(User $user, ReplenishmentOrder $replenishmentOrder): bool + { + return $user->can('inventory.delete'); + } +} diff --git a/erp/app/Modules/Inventory/Policies/RmaRequestPolicy.php b/erp/app/Modules/Inventory/Policies/RmaRequestPolicy.php new file mode 100644 index 00000000000..64d180848ce --- /dev/null +++ b/erp/app/Modules/Inventory/Policies/RmaRequestPolicy.php @@ -0,0 +1,59 @@ +can('inventory.view'); + } + + public function view(User $user, RmaRequest $rmaRequest): bool + { + return $user->can('inventory.view'); + } + + public function create(User $user): bool + { + return $user->can('inventory.create'); + } + + public function update(User $user, RmaRequest $rmaRequest): bool + { + return $user->can('inventory.create'); + } + + public function approve(User $user, RmaRequest $rmaRequest): bool + { + return $user->can('inventory.create'); + } + + public function receive(User $user, RmaRequest $rmaRequest): bool + { + return $user->can('inventory.create'); + } + + public function inspect(User $user, RmaRequest $rmaRequest): bool + { + return $user->can('inventory.create'); + } + + public function close(User $user, RmaRequest $rmaRequest): bool + { + return $user->can('inventory.create'); + } + + public function reject(User $user, RmaRequest $rmaRequest): bool + { + return $user->can('inventory.delete'); + } + + public function delete(User $user, RmaRequest $rmaRequest): bool + { + return $user->can('inventory.delete'); + } +} diff --git a/erp/app/Modules/Inventory/Policies/SalesOrderPolicy.php b/erp/app/Modules/Inventory/Policies/SalesOrderPolicy.php new file mode 100644 index 00000000000..77fe4a902fa --- /dev/null +++ b/erp/app/Modules/Inventory/Policies/SalesOrderPolicy.php @@ -0,0 +1,33 @@ +hasPermissionTo('inventory.view'); + } + + public function view(User $user, $model): bool + { + return $user->hasPermissionTo('inventory.view'); + } + + public function create(User $user): bool + { + return $user->hasPermissionTo('inventory.create'); + } + + public function update(User $user, $model): bool + { + return $user->hasPermissionTo('inventory.create'); + } + + public function delete(User $user, $model): bool + { + return $user->hasPermissionTo('inventory.delete'); + } +} diff --git a/erp/app/Modules/Inventory/Policies/ShipmentPolicy.php b/erp/app/Modules/Inventory/Policies/ShipmentPolicy.php new file mode 100644 index 00000000000..3bfad79fcb2 --- /dev/null +++ b/erp/app/Modules/Inventory/Policies/ShipmentPolicy.php @@ -0,0 +1,49 @@ +can('inventory.view'); + } + + public function view(User $user, Shipment $shipment): bool + { + return $user->can('inventory.view'); + } + + public function create(User $user): bool + { + return $user->can('inventory.create'); + } + + public function update(User $user, Shipment $shipment): bool + { + return $user->can('inventory.create'); + } + + public function dispatch(User $user, Shipment $shipment): bool + { + return $user->can('inventory.create'); + } + + public function deliver(User $user, Shipment $shipment): bool + { + return $user->can('inventory.create'); + } + + public function cancel(User $user, Shipment $shipment): bool + { + return $user->can('inventory.delete'); + } + + public function delete(User $user, Shipment $shipment): bool + { + return $user->can('inventory.delete'); + } +} diff --git a/erp/app/Modules/Inventory/Policies/StockAdjustmentPolicy.php b/erp/app/Modules/Inventory/Policies/StockAdjustmentPolicy.php new file mode 100644 index 00000000000..f206c922163 --- /dev/null +++ b/erp/app/Modules/Inventory/Policies/StockAdjustmentPolicy.php @@ -0,0 +1,34 @@ +can('inventory.view'); + } + + public function view(User $user, StockAdjustment $adj): bool + { + return $user->can('inventory.view'); + } + + public function create(User $user): bool + { + return $user->can('inventory.create'); + } + + public function update(User $user, StockAdjustment $adj): bool + { + return $user->can('inventory.create'); + } + + public function delete(User $user, StockAdjustment $adj): bool + { + return $user->can('inventory.delete'); + } +} diff --git a/erp/app/Modules/Inventory/Policies/StockPickingPolicy.php b/erp/app/Modules/Inventory/Policies/StockPickingPolicy.php new file mode 100644 index 00000000000..4622ffdd4c8 --- /dev/null +++ b/erp/app/Modules/Inventory/Policies/StockPickingPolicy.php @@ -0,0 +1,49 @@ +can('inventory.view'); + } + + public function view(User $user, StockPicking $stockPicking): bool + { + return $user->can('inventory.view'); + } + + public function create(User $user): bool + { + return $user->can('inventory.create'); + } + + public function update(User $user, StockPicking $stockPicking): bool + { + return $user->can('inventory.create'); + } + + public function confirm(User $user, StockPicking $stockPicking): bool + { + return $user->can('inventory.create'); + } + + public function validate(User $user, StockPicking $stockPicking): bool + { + return $user->can('inventory.create'); + } + + public function cancel(User $user, StockPicking $stockPicking): bool + { + return $user->can('inventory.delete'); + } + + public function delete(User $user, StockPicking $stockPicking): bool + { + return $user->can('inventory.delete'); + } +} diff --git a/erp/app/Modules/Inventory/Policies/StockReservationPolicy.php b/erp/app/Modules/Inventory/Policies/StockReservationPolicy.php new file mode 100644 index 00000000000..72e5d569114 --- /dev/null +++ b/erp/app/Modules/Inventory/Policies/StockReservationPolicy.php @@ -0,0 +1,48 @@ +hasPermissionTo('inventory.view'); + } + + public function view(User $user, $model): bool + { + return $user->hasPermissionTo('inventory.view'); + } + + public function create(User $user): bool + { + return $user->hasPermissionTo('inventory.create'); + } + + public function update(User $user, $model): bool + { + return $user->hasPermissionTo('inventory.create'); + } + + public function fulfill(User $user, $model): bool + { + return $user->hasPermissionTo('inventory.create'); + } + + public function cancel(User $user, $model): bool + { + return $user->hasPermissionTo('inventory.create'); + } + + public function expire(User $user, $model): bool + { + return $user->hasPermissionTo('inventory.create'); + } + + public function delete(User $user, $model): bool + { + return $user->hasPermissionTo('inventory.delete'); + } +} diff --git a/erp/app/Modules/Inventory/Policies/StockTransferPolicy.php b/erp/app/Modules/Inventory/Policies/StockTransferPolicy.php new file mode 100644 index 00000000000..dccd4b4401b --- /dev/null +++ b/erp/app/Modules/Inventory/Policies/StockTransferPolicy.php @@ -0,0 +1,33 @@ +can('inventory.view'); + } + + public function view(User $user): bool + { + return $user->can('inventory.view'); + } + + public function create(User $user): bool + { + return $user->can('inventory.create'); + } + + public function update(User $user): bool + { + return $user->can('inventory.create'); + } + + public function delete(User $user): bool + { + return $user->can('inventory.delete'); + } +} diff --git a/erp/app/Modules/Inventory/Policies/SupplierReviewPolicy.php b/erp/app/Modules/Inventory/Policies/SupplierReviewPolicy.php new file mode 100644 index 00000000000..44f6196f999 --- /dev/null +++ b/erp/app/Modules/Inventory/Policies/SupplierReviewPolicy.php @@ -0,0 +1,33 @@ +hasPermissionTo('inventory.view'); + } + + public function view(User $user, $model): bool + { + return $user->hasPermissionTo('inventory.view'); + } + + public function create(User $user): bool + { + return $user->hasPermissionTo('inventory.create'); + } + + public function update(User $user, $model): bool + { + return $user->hasPermissionTo('inventory.create'); + } + + public function delete(User $user, $model): bool + { + return $user->hasPermissionTo('inventory.delete'); + } +} diff --git a/erp/app/Modules/Inventory/Policies/SupplierScorecardPolicy.php b/erp/app/Modules/Inventory/Policies/SupplierScorecardPolicy.php new file mode 100644 index 00000000000..c57308ed5da --- /dev/null +++ b/erp/app/Modules/Inventory/Policies/SupplierScorecardPolicy.php @@ -0,0 +1,38 @@ +hasPermissionTo('inventory.view'); + } + + public function view(User $user, $model): bool + { + return $user->hasPermissionTo('inventory.view'); + } + + public function create(User $user): bool + { + return $user->hasPermissionTo('inventory.create'); + } + + public function update(User $user, $model): bool + { + return $user->hasPermissionTo('inventory.create'); + } + + public function publish(User $user, $model): bool + { + return $user->hasPermissionTo('inventory.create'); + } + + public function delete(User $user, $model): bool + { + return $user->hasPermissionTo('inventory.delete'); + } +} diff --git a/erp/app/Modules/Inventory/Policies/UnitOfMeasurePolicy.php b/erp/app/Modules/Inventory/Policies/UnitOfMeasurePolicy.php new file mode 100644 index 00000000000..7c099241e5d --- /dev/null +++ b/erp/app/Modules/Inventory/Policies/UnitOfMeasurePolicy.php @@ -0,0 +1,33 @@ +hasPermissionTo('inventory.view'); + } + + public function view(User $user, $model): bool + { + return $user->hasPermissionTo('inventory.view'); + } + + public function create(User $user): bool + { + return $user->hasPermissionTo('inventory.create'); + } + + public function update(User $user, $model): bool + { + return $user->hasPermissionTo('inventory.create'); + } + + public function delete(User $user, $model): bool + { + return $user->hasPermissionTo('inventory.delete'); + } +} diff --git a/erp/app/Modules/Inventory/Policies/VehiclePolicy.php b/erp/app/Modules/Inventory/Policies/VehiclePolicy.php new file mode 100644 index 00000000000..aba9fd558ef --- /dev/null +++ b/erp/app/Modules/Inventory/Policies/VehiclePolicy.php @@ -0,0 +1,33 @@ +hasPermissionTo('inventory.view'); + } + + public function view(User $user, $model): bool + { + return $user->hasPermissionTo('inventory.view'); + } + + public function create(User $user): bool + { + return $user->hasPermissionTo('inventory.create'); + } + + public function update(User $user, $model): bool + { + return $user->hasPermissionTo('inventory.create'); + } + + public function delete(User $user, $model): bool + { + return $user->hasPermissionTo('inventory.delete'); + } +} diff --git a/erp/app/Modules/Inventory/Policies/WarehouseBinPolicy.php b/erp/app/Modules/Inventory/Policies/WarehouseBinPolicy.php new file mode 100644 index 00000000000..5d7215d5714 --- /dev/null +++ b/erp/app/Modules/Inventory/Policies/WarehouseBinPolicy.php @@ -0,0 +1,33 @@ +can('inventory.view'); + } + + public function view(User $user): bool + { + return $user->can('inventory.view'); + } + + public function create(User $user): bool + { + return $user->can('inventory.create'); + } + + public function update(User $user): bool + { + return $user->can('inventory.create'); + } + + public function delete(User $user): bool + { + return $user->can('inventory.delete'); + } +} diff --git a/erp/app/Modules/Inventory/Policies/WarehouseTransferPolicy.php b/erp/app/Modules/Inventory/Policies/WarehouseTransferPolicy.php new file mode 100644 index 00000000000..a14fb38bc19 --- /dev/null +++ b/erp/app/Modules/Inventory/Policies/WarehouseTransferPolicy.php @@ -0,0 +1,29 @@ +can('inventory.view'); + } + + public function view(User $user, WarehouseTransfer $transfer): bool + { + return $user->can('inventory.view'); + } + + public function create(User $user): bool + { + return $user->can('inventory.create'); + } + + public function delete(User $user, WarehouseTransfer $transfer): bool + { + return $user->can('inventory.delete'); + } +} diff --git a/erp/app/Modules/Inventory/Policies/WarrantyPolicy.php b/erp/app/Modules/Inventory/Policies/WarrantyPolicy.php new file mode 100644 index 00000000000..a5926d2f256 --- /dev/null +++ b/erp/app/Modules/Inventory/Policies/WarrantyPolicy.php @@ -0,0 +1,50 @@ +can('inventory.view'); + } + + public function view(User $user, ProductWarranty|WarrantyClaim $model): bool + { + return $user->can('inventory.view'); + } + + public function create(User $user): bool + { + return $user->can('inventory.create'); + } + + public function update(User $user, ProductWarranty|WarrantyClaim $model): bool + { + return $user->can('inventory.create'); + } + + public function approve(User $user, WarrantyClaim $claim): bool + { + return $user->can('inventory.create'); + } + + public function reject(User $user, WarrantyClaim $claim): bool + { + return $user->can('inventory.create'); + } + + public function resolve(User $user, WarrantyClaim $claim): bool + { + return $user->can('inventory.create'); + } + + public function delete(User $user, ProductWarranty|WarrantyClaim $model): bool + { + return $user->can('inventory.delete'); + } +} diff --git a/erp/app/Modules/Inventory/Providers/InventoryServiceProvider.php b/erp/app/Modules/Inventory/Providers/InventoryServiceProvider.php new file mode 100644 index 00000000000..993574f8bd4 --- /dev/null +++ b/erp/app/Modules/Inventory/Providers/InventoryServiceProvider.php @@ -0,0 +1,167 @@ +loadRoutesFrom(__DIR__ . '/../routes/inventory.php'); + + Gate::policy(Product::class, ProductPolicy::class); + Gate::policy(ProductCategory::class, ProductCategoryPolicy::class); + Gate::policy(WarehouseTransfer::class, WarehouseTransferPolicy::class); + Gate::policy(StockAdjustment::class, StockAdjustmentPolicy::class); + Gate::policy(PurchaseRequisition::class, PurchaseRequisitionPolicy::class); + Gate::policy(Asset::class, AssetPolicy::class); + Gate::policy(AssetMaintenance::class, AssetPolicy::class); + Gate::policy(WarehouseStock::class, StockTransferPolicy::class); + Gate::policy(StockTransfer::class, StockTransferPolicy::class); + Gate::policy(StockTransferItem::class, StockTransferPolicy::class); + Gate::policy(QcChecklist::class, QcPolicy::class); + Gate::policy(QcChecklistItem::class, QcPolicy::class); + Gate::policy(QcInspection::class, QcPolicy::class); + Gate::policy(QcInspectionResult::class, QcPolicy::class); + Gate::policy(CostingLayer::class, CostingPolicy::class); + Gate::policy(ProductCostSnapshot::class, CostingPolicy::class); + Gate::policy(DemandForecast::class, ForecastPolicy::class); + Gate::policy(ForecastAlert::class, ForecastPolicy::class); + Gate::policy(WarehouseBin::class, WarehouseBinPolicy::class); + Gate::policy(WarehouseZone::class, WarehouseBinPolicy::class); + Gate::policy(BinStockLocation::class, WarehouseBinPolicy::class); + Gate::policy(ProductAttribute::class, ProductVariantPolicy::class); + Gate::policy(Vehicle::class, VehiclePolicy::class); + Gate::policy(VehicleLog::class, VehiclePolicy::class); + Gate::policy(ProductVariant::class, ProductVariantPolicy::class); + Gate::policy(ProductVariantValue::class, ProductVariantPolicy::class); + Gate::policy(SupplierReview::class, SupplierReviewPolicy::class); + Gate::policy(SupplierContract::class, SupplierReviewPolicy::class); + Gate::policy(LotNumber::class, LotSerialPolicy::class); + Gate::policy(SerialNumber::class, LotSerialPolicy::class); + Gate::policy(PurchaseOrder::class, PurchaseOrderPolicy::class); + Gate::policy(PurchaseOrderItem::class, PurchaseOrderPolicy::class); + Gate::policy(SalesOrder::class, SalesOrderPolicy::class); + Gate::policy(SalesOrderItem::class, SalesOrderPolicy::class); + Gate::policy(PriceList::class, PriceListPolicy::class); + Gate::policy(PriceListItem::class, PriceListPolicy::class); + Gate::policy(CustomerDiscount::class, PriceListPolicy::class); + Gate::policy(UnitOfMeasure::class, UnitOfMeasurePolicy::class); + Gate::policy(CycleCount::class, CycleCountPolicy::class); + Gate::policy(ProductTag::class, ProductTagPolicy::class); + Gate::policy(ProductSubstitute::class, ProductSubstitutePolicy::class); + Gate::policy(Backorder::class, BackorderPolicy::class); + Gate::policy(ProductBundle::class, ProductBundlePolicy::class); + Gate::policy(ProductBundleItem::class, ProductBundlePolicy::class); + Gate::policy(ReorderRule::class, ReorderRulePolicy::class); + Gate::policy(SupplierScorecard::class, SupplierScorecardPolicy::class); + Gate::policy(QualityAlert::class, QualityAlertPolicy::class); + Gate::policy(StockReservation::class, StockReservationPolicy::class); + Gate::policy(PurchaseRequest::class, PurchaseRequestPolicy::class); + Gate::policy(GoodsReceipt::class, GoodsReceiptPolicy::class); + Gate::policy(GoodsReceiptItem::class, GoodsReceiptPolicy::class); + Gate::policy(Shipment::class, ShipmentPolicy::class); + Gate::policy(ShipmentItem::class, ShipmentPolicy::class); + Gate::policy(RmaRequest::class, RmaRequestPolicy::class); + Gate::policy(RmaRequestItem::class, RmaRequestPolicy::class); + Gate::policy(StockPicking::class, StockPickingPolicy::class); + Gate::policy(StockPickingLine::class, StockPickingPolicy::class); + Gate::policy(ReplenishmentOrder::class, ReplenishmentOrderPolicy::class); + } +} diff --git a/erp/app/Modules/Inventory/routes/inventory.php b/erp/app/Modules/Inventory/routes/inventory.php new file mode 100644 index 00000000000..319e12cb603 --- /dev/null +++ b/erp/app/Modules/Inventory/routes/inventory.php @@ -0,0 +1,403 @@ +prefix('inventory')->name('inventory.')->group(function() { + Route::get('dashboard', [InventoryDashboardController::class, 'index'])->name('dashboard'); +}); + +Route::middleware(['web', 'auth', 'verified'])->prefix('inventory')->name('inventory.')->group(function () { + + // Products + Route::resource('products', ProductController::class) + ->names('products'); + + // Product Categories (flat, colour-coded) + Route::resource('product-categories', ProductCategoryController::class) + ->except(['create', 'edit', 'show']) + ->names('product-categories'); + + // Legacy Categories (hierarchical - index + inline CRUD via back()) + Route::get('categories', [CategoryController::class, 'index'])->name('categories.index'); + Route::post('categories', [CategoryController::class, 'store'])->name('categories.store'); + Route::put('categories/{category}', [CategoryController::class, 'update'])->name('categories.update'); + Route::delete('categories/{category}', [CategoryController::class, 'destroy'])->name('categories.destroy'); + + // Warehouses (index + inline CRUD) + Route::get('warehouses', [WarehouseController::class, 'index'])->name('warehouses.index'); + Route::post('warehouses', [WarehouseController::class, 'store'])->name('warehouses.store'); + Route::put('warehouses/{warehouse}', [WarehouseController::class, 'update'])->name('warehouses.update'); + Route::delete('warehouses/{warehouse}', [WarehouseController::class, 'destroy'])->name('warehouses.destroy'); + + // Suppliers + Route::resource('suppliers', SupplierController::class) + ->except(['show']) + ->names('suppliers'); + + // Stock Movements + Route::get('stock-movements', [StockMovementController::class, 'index'])->name('stock-movements.index'); + Route::post('stock-movements', [StockMovementController::class, 'store'])->name('stock-movements.store'); + + // Purchase Orders + Route::get('purchase-orders', [PurchaseOrderController::class, 'index'])->name('purchase-orders.index'); + Route::get('purchase-orders/create', [PurchaseOrderController::class, 'create'])->name('purchase-orders.create'); + Route::post('purchase-orders', [PurchaseOrderController::class, 'store'])->name('purchase-orders.store'); + Route::get('purchase-orders/{purchaseOrder}', [PurchaseOrderController::class, 'show'])->name('purchase-orders.show'); + Route::get('purchase-orders/{purchaseOrder}/receive', [PurchaseOrderController::class, 'receiveForm'])->name('purchase-orders.receive-form'); + Route::patch('purchase-orders/{purchaseOrder}/transition', [PurchaseOrderController::class, 'transition'])->name('purchase-orders.transition'); + Route::post('purchase-orders/{purchaseOrder}/submit', [PurchaseOrderController::class, 'submit'])->name('purchase-orders.submit'); + Route::post('purchase-orders/{purchaseOrder}/approve', [PurchaseOrderController::class, 'approve'])->name('purchase-orders.approve'); + Route::post('purchase-orders/{purchaseOrder}/receive', [PurchaseOrderController::class, 'receive'])->name('purchase-orders.receive'); + Route::post('purchase-orders/{purchaseOrder}/cancel', [PurchaseOrderController::class, 'cancel'])->name('purchase-orders.cancel'); + Route::post('purchase-orders/{purchaseOrder}/send', [PurchaseOrderController::class, 'send'])->name('purchase-orders.send'); + Route::delete('purchase-orders/{purchaseOrder}', [PurchaseOrderController::class, 'destroy'])->name('purchase-orders.destroy'); + + // Reorder Suggestions + Route::get('reorder', [ReorderController::class, 'index'])->name('reorder.index'); + Route::post('reorder/purchase-order', [ReorderController::class, 'createPurchaseOrder'])->name('reorder.create-po'); + + // Warehouse Transfers + Route::resource('warehouse-transfers', WarehouseTransferController::class)->only(['index', 'create', 'store']); + + // Stock Adjustments + Route::resource('stock-adjustments', StockAdjustmentController::class)->except(['edit', 'update']); + Route::post('stock-adjustments/{stockAdjustment}/confirm', [StockAdjustmentController::class, 'confirm'])->name('stock-adjustments.confirm'); + Route::post('stock-adjustments/{stockAdjustment}/cancel', [StockAdjustmentController::class, 'cancel'])->name('stock-adjustments.cancel'); + + // Purchase Requisitions + Route::resource('purchase-requisitions', PurchaseRequisitionController::class)->except(['edit', 'update']); + Route::post('purchase-requisitions/{purchaseRequisition}/submit', [PurchaseRequisitionController::class, 'submit'])->name('purchase-requisitions.submit'); + Route::post('purchase-requisitions/{purchaseRequisition}/approve', [PurchaseRequisitionController::class, 'approve'])->name('purchase-requisitions.approve'); + Route::post('purchase-requisitions/{purchaseRequisition}/reject', [PurchaseRequisitionController::class, 'reject'])->name('purchase-requisitions.reject'); + + // Assets + Route::post('assets/{asset}/dispose', [AssetController::class, 'dispose'])->name('assets.dispose'); + Route::patch('assets/{asset}/assign', [AssetController::class, 'assign'])->name('assets.assign'); + Route::resource('assets', AssetController::class)->except(['edit', 'update']); + + // Asset Maintenances + Route::patch('asset-maintenances/{assetMaintenance}/complete', [AssetMaintenanceController::class, 'complete'])->name('asset-maintenances.complete'); + Route::resource('asset-maintenances', AssetMaintenanceController::class)->except(['edit', 'update']); + + // Product Bundles + Route::post('product-bundles/{productBundle}/items', [ProductBundleController::class, 'addItem'])->name('product-bundles.items.add'); + Route::delete('product-bundles/{productBundle}/items/{item}', [ProductBundleController::class, 'removeItem'])->name('product-bundles.items.remove'); + Route::resource('product-bundles', ProductBundleController::class)->only(['index', 'store', 'show', 'destroy']); + + // Warehouse Stock + Route::resource('warehouse-stock', WarehouseStockController::class)->only(['index', 'show', 'update']); + + // Stock Transfers + Route::post('stock-transfers/{stockTransfer}/complete', [StockTransferController::class, 'complete'])->name('stock-transfers.complete'); + Route::post('stock-transfers/{stockTransfer}/cancel', [StockTransferController::class, 'cancel'])->name('stock-transfers.cancel'); + Route::resource('stock-transfers', StockTransferController::class)->except(['edit', 'update']); + + // QC Checklists + Route::resource('qc-checklists', QcChecklistController::class)->except(['edit', 'update']); + + // QC Inspections + Route::patch('qc-inspections/{qcInspection}/results/{result}', [QcInspectionController::class, 'updateResult'])->name('qc-inspections.results.update'); + Route::post('qc-inspections/{qcInspection}/complete', [QcInspectionController::class, 'complete'])->name('qc-inspections.complete'); + Route::resource('qc-inspections', QcInspectionController::class)->except(['edit', 'update']); + + // Inventory Costing + Route::get('costing', [CostingController::class, 'index'])->name('costing.index'); + Route::get('costing/report', [CostingController::class, 'report'])->name('costing.report'); + Route::get('costing/{product}/layers', [CostingController::class, 'layers'])->name('costing.layers'); + Route::post('costing/add-layer', [CostingController::class, 'addLayer'])->name('costing.add-layer'); + Route::post('costing/snapshot', [CostingController::class, 'snapshot'])->name('costing.snapshot'); + + // Warehouse Zones + Route::get('warehouse-zones', [WarehouseZoneController::class, 'index'])->name('warehouse-zones.index'); + Route::post('warehouse-zones', [WarehouseZoneController::class, 'store'])->name('warehouse-zones.store'); + Route::delete('warehouse-zones/{warehouseZone}', [WarehouseZoneController::class, 'destroy'])->name('warehouse-zones.destroy'); + + // Warehouse Bins + Route::post('warehouse-bins/{warehouseBin}/stock', [WarehouseBinController::class, 'addStock'])->name('warehouse-bins.stock.add'); + Route::delete('warehouse-bins/{warehouseBin}/stock/{location}', [WarehouseBinController::class, 'removeStock'])->name('warehouse-bins.stock.remove'); + Route::resource('warehouse-bins', WarehouseBinController::class)->except(['edit', 'update']); + + // Product Attributes + Route::resource('product-attributes', ProductAttributeController::class)->except(['show', 'create', 'edit']); + + // Product Variants + Route::patch('product-variants/{productVariant}/adjust-stock', [ProductVariantController::class, 'adjustStock'])->name('product-variants.adjust-stock'); + Route::resource('product-variants', ProductVariantController::class)->except(['edit', 'update']); +}); + +// Demand Forecasting - custom actions BEFORE resource +Route::middleware(['web', 'auth', 'verified'])->prefix('inventory')->name('inventory.')->group(function () { + Route::get('demand-forecasts/alerts', [DemandForecastController::class, 'alerts'])->name('demand-forecasts.alerts'); + Route::post('demand-forecasts/generate', [DemandForecastController::class, 'generateForecast'])->name('demand-forecasts.generate'); + Route::post('demand-forecasts/alerts/{alert}/resolve', [DemandForecastController::class, 'resolveAlert'])->name('demand-forecasts.alerts.resolve'); + Route::resource('demand-forecasts', DemandForecastController::class)->except(['edit']); +}); + +// Fleet/Vehicle Management - custom actions BEFORE resource +use App\Modules\Inventory\Http\Controllers\VehicleController; +Route::middleware(['web', 'auth', 'verified'])->prefix('inventory')->name('inventory.')->group(function () { + Route::patch('vehicles/{vehicle}/assign', [VehicleController::class, 'assign'])->name('vehicles.assign'); + Route::patch('vehicles/{vehicle}/unassign', [VehicleController::class, 'unassign'])->name('vehicles.unassign'); + Route::post('vehicles/{vehicle}/retire', [VehicleController::class, 'retire'])->name('vehicles.retire'); + Route::post('vehicles/{vehicle}/logs', [VehicleController::class, 'addLog'])->name('vehicles.logs.add'); + Route::resource('vehicles', VehicleController::class)->except(['edit', 'update']); +}); + +// Supplier Performance Tracking +use App\Modules\Inventory\Http\Controllers\SupplierReviewController; +use App\Modules\Inventory\Http\Controllers\SupplierContractController; +Route::middleware(['web', 'auth', 'verified'])->prefix('inventory')->name('inventory.')->group(function () { + // Supplier Reviews + Route::resource('supplier-reviews', SupplierReviewController::class)->only(['index', 'store', 'destroy']); + + // Supplier Contracts - terminate BEFORE resource + Route::post('supplier-contracts/{supplierContract}/terminate', [SupplierContractController::class, 'terminate'])->name('supplier-contracts.terminate'); + Route::resource('supplier-contracts', SupplierContractController::class)->only(['index', 'store', 'destroy']); + + // Supplier show + Route::get('suppliers/{supplier}', [SupplierController::class, 'show'])->name('suppliers.show'); +}); + +// Lot & Serial Number Tracking - custom actions BEFORE resource +use App\Modules\Inventory\Http\Controllers\LotNumberController; +use App\Modules\Inventory\Http\Controllers\SerialNumberController; +Route::middleware(['web', 'auth', 'verified'])->prefix('inventory')->name('inventory.')->group(function () { + // Lot Numbers + Route::post('lot-numbers/{lotNumber}/quarantine', [LotNumberController::class, 'quarantine'])->name('lot-numbers.quarantine'); + Route::post('lot-numbers/{lotNumber}/consume', [LotNumberController::class, 'consume'])->name('lot-numbers.consume'); + Route::resource('lot-numbers', LotNumberController::class)->only(['index', 'store', 'show']); + + // Serial Numbers + Route::post('serial-numbers/{serialNumber}/sell', [SerialNumberController::class, 'sell'])->name('serial-numbers.sell'); + Route::post('serial-numbers/{serialNumber}/scrap', [SerialNumberController::class, 'scrap'])->name('serial-numbers.scrap'); + Route::resource('serial-numbers', SerialNumberController::class)->only(['index', 'store', 'show']); +}); + + +// Sales Order Management — custom actions BEFORE resource +use App\Modules\Inventory\Http\Controllers\SalesOrderController; +Route::middleware(['web', 'auth', 'verified'])->prefix('inventory')->name('inventory.')->group(function () { + Route::post('sales-orders/{salesOrder}/confirm', [SalesOrderController::class, 'confirm'])->name('sales-orders.confirm'); + Route::post('sales-orders/{salesOrder}/ship', [SalesOrderController::class, 'ship'])->name('sales-orders.ship'); + Route::post('sales-orders/{salesOrder}/deliver', [SalesOrderController::class, 'deliver'])->name('sales-orders.deliver'); + Route::post('sales-orders/{salesOrder}/cancel', [SalesOrderController::class, 'cancel'])->name('sales-orders.cancel'); + Route::resource('sales-orders', SalesOrderController::class)->only(['index', 'create', 'store', 'show', 'destroy']); +}); + +// Price Lists & Customer Discounts +use App\Modules\Inventory\Http\Controllers\PriceListController; +use App\Modules\Inventory\Http\Controllers\CustomerDiscountController; +Route::middleware(['web', 'auth', 'verified'])->prefix('inventory')->name('inventory.')->group(function () { + Route::post('price-lists/{priceList}/items', [PriceListController::class, 'addItem'])->name('price-lists.items.store'); + Route::delete('price-lists/{priceList}/items/{priceListItem}', [PriceListController::class, 'removeItem'])->name('price-lists.items.destroy'); + Route::resource('price-lists', PriceListController::class)->only(['index', 'store', 'show', 'destroy']); + Route::resource('customer-discounts', CustomerDiscountController::class)->only(['index', 'store', 'destroy']); +}); + +// Units of Measure +use App\Modules\Inventory\Http\Controllers\UnitOfMeasureController; +Route::middleware(['web', 'auth', 'verified'])->prefix('inventory')->name('inventory.')->group(function () { + Route::resource('units-of-measure', UnitOfMeasureController::class)->names('units-of-measure'); +}); + +// Cycle Counts — custom actions BEFORE resource +use App\Modules\Inventory\Http\Controllers\CycleCountController; +Route::middleware(['web', 'auth', 'verified'])->prefix('inventory')->name('inventory.')->group(function () { + Route::post('cycle-counts/{cycleCount}/start', [CycleCountController::class, 'start'])->name('cycle-counts.start'); + Route::post('cycle-counts/{cycleCount}/complete', [CycleCountController::class, 'complete'])->name('cycle-counts.complete'); + Route::post('cycle-counts/{cycleCount}/cancel', [CycleCountController::class, 'cancel'])->name('cycle-counts.cancel'); + Route::post('cycle-counts/{cycleCount}/counts', [CycleCountController::class, 'updateCounts'])->name('cycle-counts.counts.update'); + Route::resource('cycle-counts', CycleCountController::class)->names('cycle-counts'); +}); + +// Product Tags +use App\Modules\Inventory\Http\Controllers\ProductTagController; +use App\Modules\Inventory\Http\Controllers\ProductTagAssignmentController; +Route::middleware(['web', 'auth', 'verified'])->prefix('inventory')->name('inventory.')->group(function () { + Route::resource('product-tags', ProductTagController::class)->except(['create', 'edit', 'show']); + Route::post('products/{product}/tags', [ProductTagAssignmentController::class, 'attach'])->name('products.tags.attach'); + Route::delete('products/{product}/tags/{productTag}', [ProductTagAssignmentController::class, 'detach'])->name('products.tags.detach'); +}); + +// Product Substitutes (nested under products) +use App\Modules\Inventory\Http\Controllers\ProductSubstituteController; +Route::middleware(['web', 'auth', 'verified'])->prefix('inventory')->name('inventory.')->group(function () { + Route::get( 'products/{product}/substitutes', [ProductSubstituteController::class, 'index'])->name('products.substitutes.index'); + Route::post( 'products/{product}/substitutes', [ProductSubstituteController::class, 'store'])->name('products.substitutes.store'); + Route::patch( 'products/{product}/substitutes/{productSubstitute}', [ProductSubstituteController::class, 'update'])->name('products.substitutes.update'); + Route::delete('products/{product}/substitutes/{productSubstitute}', [ProductSubstituteController::class, 'destroy'])->name('products.substitutes.destroy'); +}); + +// Backorders — custom actions BEFORE resource +use App\Modules\Inventory\Http\Controllers\BackorderController; +Route::middleware(['web', 'auth', 'verified'])->prefix('inventory')->name('inventory.')->group(function () { + Route::post('backorders/{backorder}/fulfill', [BackorderController::class, 'fulfill'])->name('backorders.fulfill'); + Route::post('backorders/{backorder}/cancel', [BackorderController::class, 'cancel'])->name('backorders.cancel'); + Route::resource('backorders', BackorderController::class)->only(['index', 'store', 'show', 'destroy']); +}); + +// Reorder Rules +use App\Modules\Inventory\Http\Controllers\ReorderRuleController; +Route::middleware(['web', 'auth', 'verified'])->prefix('inventory')->name('inventory.')->group(function () { + Route::post('reorder-rules/{reorder_rule}/trigger', [ReorderRuleController::class, 'trigger'])->name('reorder-rules.trigger'); + Route::post('reorder-rules/{reorder_rule}/pause', [ReorderRuleController::class, 'pause'])->name('reorder-rules.pause'); + Route::post('reorder-rules/{reorder_rule}/resume', [ReorderRuleController::class, 'resume'])->name('reorder-rules.resume'); + Route::resource('reorder-rules', ReorderRuleController::class); +}); + +// Supplier Scorecards +use App\Modules\Inventory\Http\Controllers\SupplierScorecardController; +Route::middleware(['web', 'auth', 'verified'])->prefix('inventory')->name('inventory.')->group(function () { + Route::post('supplier-scorecards/{supplier_scorecard}/publish', [SupplierScorecardController::class, 'publish'])->name('supplier-scorecards.publish'); + Route::resource('supplier-scorecards', SupplierScorecardController::class); +}); + +// Quality Alerts +use App\Modules\Inventory\Http\Controllers\QualityAlertController; +Route::middleware(['web', 'auth', 'verified'])->prefix('inventory')->name('inventory.')->group(function () { + Route::post('quality-alerts/{quality_alert}/investigate', [QualityAlertController::class, 'investigate'])->name('quality-alerts.investigate'); + Route::post('quality-alerts/{quality_alert}/resolve', [QualityAlertController::class, 'resolve'])->name('quality-alerts.resolve'); + Route::post('quality-alerts/{quality_alert}/close', [QualityAlertController::class, 'close'])->name('quality-alerts.close'); + Route::resource('quality-alerts', QualityAlertController::class); +}); + +// Stock Reservations +use App\Modules\Inventory\Http\Controllers\StockReservationController; +Route::middleware(['web', 'auth', 'verified'])->prefix('inventory')->name('inventory.')->group(function () { + Route::post('stock-reservations/{stock_reservation}/fulfill', [StockReservationController::class, 'fulfill'])->name('stock-reservations.fulfill'); + Route::post('stock-reservations/{stock_reservation}/cancel', [StockReservationController::class, 'cancel'])->name('stock-reservations.cancel'); + Route::post('stock-reservations/{stock_reservation}/expire', [StockReservationController::class, 'expire'])->name('stock-reservations.expire'); + Route::resource('stock-reservations', StockReservationController::class); +}); + +// Purchase Requests +use App\Modules\Inventory\Http\Controllers\PurchaseRequestController; +Route::middleware(['web', 'auth', 'verified'])->prefix('inventory')->name('inventory.')->group(function () { + Route::post('purchase-requests/{purchase_request}/submit', [PurchaseRequestController::class, 'submit'])->name('purchase-requests.submit'); + Route::post('purchase-requests/{purchase_request}/approve', [PurchaseRequestController::class, 'approve'])->name('purchase-requests.approve'); + Route::post('purchase-requests/{purchase_request}/reject', [PurchaseRequestController::class, 'reject'])->name('purchase-requests.reject'); + Route::post('purchase-requests/{purchase_request}/mark-ordered', [PurchaseRequestController::class, 'markOrdered'])->name('purchase-requests.mark-ordered'); + Route::post('purchase-requests/{purchase_request}/cancel', [PurchaseRequestController::class, 'cancel'])->name('purchase-requests.cancel'); + Route::resource('purchase-requests', PurchaseRequestController::class); +}); + +// Goods Receipts +use App\Modules\Inventory\Http\Controllers\GoodsReceiptController; +Route::middleware(['web', 'auth', 'verified'])->prefix('inventory')->name('inventory.')->group(function () { + Route::post('goods-receipts/{goods_receipt}/confirm', [GoodsReceiptController::class, 'confirm'])->name('goods-receipts.confirm'); + Route::post('goods-receipts/{goods_receipt}/post', [GoodsReceiptController::class, 'post'])->name('goods-receipts.post'); + Route::post('goods-receipts/{goods_receipt}/reject', [GoodsReceiptController::class, 'reject'])->name('goods-receipts.reject'); + Route::resource('goods-receipts', GoodsReceiptController::class); +}); + +// Shipments +use App\Modules\Inventory\Http\Controllers\ShipmentController; +Route::middleware(['web', 'auth', 'verified'])->prefix('inventory')->name('inventory.')->group(function () { + Route::post('shipments/{shipment}/dispatch', [ShipmentController::class, 'dispatch'])->name('shipments.dispatch'); + Route::post('shipments/{shipment}/deliver', [ShipmentController::class, 'deliver'])->name('shipments.deliver'); + Route::post('shipments/{shipment}/return', [ShipmentController::class, 'returnShipment'])->name('shipments.return'); + Route::post('shipments/{shipment}/cancel', [ShipmentController::class, 'cancel'])->name('shipments.cancel'); + Route::resource('shipments', ShipmentController::class); +}); + +// RMA Requests +use App\Modules\Inventory\Http\Controllers\RmaRequestController; +Route::middleware(['web', 'auth', 'verified'])->prefix('inventory')->name('inventory.')->group(function () { + Route::post('rma-requests/{rma_request}/approve', [RmaRequestController::class, 'approve'])->name('rma-requests.approve'); + Route::post('rma-requests/{rma_request}/receive', [RmaRequestController::class, 'receive'])->name('rma-requests.receive'); + Route::post('rma-requests/{rma_request}/inspect', [RmaRequestController::class, 'inspect'])->name('rma-requests.inspect'); + Route::post('rma-requests/{rma_request}/close', [RmaRequestController::class, 'close'])->name('rma-requests.close'); + Route::post('rma-requests/{rma_request}/reject', [RmaRequestController::class, 'reject'])->name('rma-requests.reject'); + Route::resource('rma-requests', RmaRequestController::class); +}); + +// Warranties +use App\Modules\Inventory\Http\Controllers\ProductWarrantyController; +Route::middleware(['web','auth','verified'])->prefix('inventory')->name('inventory.')->group(function() { + Route::resource('warranties', ProductWarrantyController::class); +}); + +// Warranty Claims +use App\Modules\Inventory\Http\Controllers\WarrantyClaimController; +Route::middleware(['web','auth','verified'])->prefix('inventory')->name('inventory.')->group(function() { + Route::post('warranty-claims/{warranty_claim}/approve', [WarrantyClaimController::class, 'approve'])->name('warranty-claims.approve'); + Route::post('warranty-claims/{warranty_claim}/reject', [WarrantyClaimController::class, 'reject'])->name('warranty-claims.reject'); + Route::post('warranty-claims/{warranty_claim}/resolve', [WarrantyClaimController::class, 'resolve'])->name('warranty-claims.resolve'); + Route::resource('warranty-claims', WarrantyClaimController::class)->except(['edit','update']); +}); + +// Put-Away Rules +use App\Modules\Inventory\Http\Controllers\PutAwayRuleController; +Route::middleware(['web','auth','verified'])->prefix('inventory')->name('inventory.')->group(function() { + Route::post('put-away-rules/{put_away_rule}/activate', [PutAwayRuleController::class,'activate'])->name('put-away-rules.activate'); + Route::post('put-away-rules/{put_away_rule}/deactivate', [PutAwayRuleController::class,'deactivate'])->name('put-away-rules.deactivate'); + Route::resource('put-away-rules', PutAwayRuleController::class); +}); + +// Stock Pickings +use App\Modules\Inventory\Http\Controllers\StockPickingController; +Route::middleware(['web','auth','verified'])->prefix('inventory')->name('inventory.')->group(function() { + Route::post('stock-pickings/{stock_picking}/confirm', [StockPickingController::class,'confirm'])->name('stock-pickings.confirm'); + Route::post('stock-pickings/{stock_picking}/start', [StockPickingController::class,'startProcessing'])->name('stock-pickings.start'); + Route::post('stock-pickings/{stock_picking}/validate', [StockPickingController::class,'validate'])->name('stock-pickings.validate'); + Route::post('stock-pickings/{stock_picking}/cancel', [StockPickingController::class,'cancel'])->name('stock-pickings.cancel'); + Route::resource('stock-pickings', StockPickingController::class); +}); + +// Replenishment Orders +use App\Modules\Inventory\Http\Controllers\ReplenishmentOrderController; +Route::middleware(['web','auth','verified'])->prefix('inventory')->name('inventory.')->group(function() { + Route::post('replenishments/{replenishment}/confirm', [ReplenishmentOrderController::class,'confirm'])->name('replenishments.confirm'); + Route::post('replenishments/{replenishment}/start', [ReplenishmentOrderController::class,'markInProgress'])->name('replenishments.start'); + Route::post('replenishments/{replenishment}/complete', [ReplenishmentOrderController::class,'complete'])->name('replenishments.complete'); + Route::post('replenishments/{replenishment}/cancel', [ReplenishmentOrderController::class,'cancel'])->name('replenishments.cancel'); + Route::resource('replenishments', ReplenishmentOrderController::class)->except(['edit','update']); +}); + +// Traceability +use App\Modules\Inventory\Http\Controllers\TraceabilityController; +Route::middleware(['web','auth','verified'])->prefix('inventory')->name('inventory.')->group(function() { + Route::get('traceability', [TraceabilityController::class,'index'])->name('traceability.index'); +}); + +// Inventory Reports +use App\Modules\Inventory\Http\Controllers\InventoryReportController; +Route::middleware(['web','auth','verified'])->prefix('inventory/reports')->name('inventory.reports.')->group(function() { + Route::get('stock-valuation', [InventoryReportController::class, 'stockValuation'])->name('stock-valuation'); + Route::get('stock-movement', [InventoryReportController::class, 'stockMovement'])->name('stock-movement'); + Route::get('low-stock', [InventoryReportController::class, 'lowStock'])->name('low-stock'); + Route::get('abc-analysis', [InventoryReportController::class, 'abcAnalysis'])->name('abc-analysis'); +}); + +// Multi-Warehouse Overview +use App\Modules\Inventory\Http\Controllers\MultiWarehouseController; +Route::middleware(['web','auth','verified'])->prefix('inventory')->name('inventory.')->group(function() { + Route::get('multi-warehouse', [MultiWarehouseController::class, 'index'])->name('multi-warehouse.index'); +}); diff --git a/erp/app/Modules/KnowledgeBase/Http/Controllers/KnowledgeBaseController.php b/erp/app/Modules/KnowledgeBase/Http/Controllers/KnowledgeBaseController.php new file mode 100644 index 00000000000..54682a4682f --- /dev/null +++ b/erp/app/Modules/KnowledgeBase/Http/Controllers/KnowledgeBaseController.php @@ -0,0 +1,184 @@ +when($request->search, function ($q, $search) { + $q->where(function ($q) use ($search) { + $q->where('title', 'like', "%{$search}%") + ->orWhere('content', 'like', "%{$search}%"); + }); + }) + ->when($request->category_id, fn ($q) => $q->where('category_id', $request->category_id)) + ->orderByDesc('created_at') + ->paginate(20) + ->withQueryString(); + + $categories = KbCategory::with('children')->whereNull('parent_id')->orderBy('sequence')->get(); + + return Inertia::render('KnowledgeBase/Index', [ + 'articles' => $articles, + 'categories' => $categories, + 'filters' => $request->only(['search', 'category_id']), + ]); + } + + public function categories(): Response + { + $categories = KbCategory::with('children') + ->whereNull('parent_id') + ->orderBy('sequence') + ->get() + ->map(function (KbCategory $category) { + return array_merge($category->toArray(), [ + 'article_count' => $category->articleCount(), + ]); + }); + + return Inertia::render('KnowledgeBase/Categories', [ + 'categories' => $categories, + ]); + } + + public function show(KbArticle $article): Response + { + $article->incrementViews(); + $article->load(['category', 'author']); + + return Inertia::render('KnowledgeBase/Show', [ + 'article' => $article, + ]); + } + + public function store(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'title' => 'required|string|max:255', + 'content' => 'required|string', + 'category_id' => 'nullable|exists:kb_categories,id', + 'excerpt' => 'nullable|string', + 'tags' => 'nullable|string', + 'status' => 'nullable|in:draft,published,archived', + ]); + + $article = new KbArticle(); + $article->tenant_id = auth()->user()->tenant_id; + $article->author_id = auth()->id(); + $article->title = $validated['title']; + $article->content = $validated['content']; + $article->category_id = $validated['category_id'] ?? null; + $article->excerpt = $validated['excerpt'] ?? null; + $article->status = $validated['status'] ?? 'draft'; + + // Handle tags: convert comma-separated string to array + if (!empty($validated['tags'])) { + $article->tags = array_map('trim', explode(',', $validated['tags'])); + } + + $article->slug = $article->generateSlug($validated['title']); + $article->save(); + + return redirect()->route('kb.show', $article)->with('success', 'Article created.'); + } + + public function update(Request $request, KbArticle $article): RedirectResponse + { + $validated = $request->validate([ + 'title' => 'sometimes|required|string|max:255', + 'content' => 'sometimes|required|string', + 'category_id' => 'nullable|exists:kb_categories,id', + 'excerpt' => 'nullable|string', + 'tags' => 'nullable|string', + 'status' => 'nullable|in:draft,published,archived', + ]); + + if (isset($validated['tags']) && is_string($validated['tags'])) { + $validated['tags'] = array_map('trim', explode(',', $validated['tags'])); + } + + $article->update($validated); + + return redirect()->route('kb.show', $article)->with('success', 'Article updated.'); + } + + public function destroy(KbArticle $article): RedirectResponse + { + $article->delete(); + + return redirect()->route('kb.index')->with('success', 'Article deleted.'); + } + + public function publish(KbArticle $article): RedirectResponse + { + $article->publish(); + + return redirect()->back()->with('success', 'Article published.'); + } + + public function archive(KbArticle $article): RedirectResponse + { + $article->archive(); + + return redirect()->back()->with('success', 'Article archived.'); + } + + public function storeCategory(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'description' => 'nullable|string', + 'parent_id' => 'nullable|exists:kb_categories,id', + 'sequence' => 'nullable|integer', + ]); + + $slug = \Illuminate\Support\Str::slug($validated['name']); + $original = $slug; + $count = 1; + while (KbCategory::withoutGlobalScopes()->where('slug', $slug)->where('tenant_id', auth()->user()->tenant_id)->exists()) { + $slug = $original . '-' . $count; + $count++; + } + + KbCategory::create([ + ...$validated, + 'tenant_id' => auth()->user()->tenant_id, + 'slug' => $slug, + ]); + + return redirect()->back()->with('success', 'Category created.'); + } + + public function search(Request $request): Response + { + $query = $request->get('q', ''); + + $articles = KbArticle::with(['category', 'author']) + ->where('status', 'published') + ->when($query, function ($q) use ($query) { + $q->where(function ($q) use ($query) { + $q->where('title', 'like', "%{$query}%") + ->orWhere('content', 'like', "%{$query}%"); + }); + }) + ->orderByDesc('published_at') + ->paginate(20) + ->withQueryString(); + + return Inertia::render('KnowledgeBase/Search', [ + 'articles' => $articles, + 'query' => $query, + ]); + } +} diff --git a/erp/app/Modules/KnowledgeBase/Models/KbArticle.php b/erp/app/Modules/KnowledgeBase/Models/KbArticle.php new file mode 100644 index 00000000000..45e06854642 --- /dev/null +++ b/erp/app/Modules/KnowledgeBase/Models/KbArticle.php @@ -0,0 +1,89 @@ + 'array', + 'published_at' => 'datetime', + ]; + + public function category(): BelongsTo + { + return $this->belongsTo(KbCategory::class, 'category_id'); + } + + public function author(): BelongsTo + { + return $this->belongsTo(User::class, 'author_id'); + } + + public function publish(): void + { + $this->status = 'published'; + $this->published_at = now(); + $this->save(); + } + + public function archive(): void + { + $this->status = 'archived'; + $this->save(); + } + + public function incrementViews(): void + { + $this->increment('views'); + } + + public function generateSlug(string $title): string + { + $slug = Str::slug($title); + $original = $slug; + $count = 1; + + while ( + static::withoutGlobalScopes() + ->where('slug', $slug) + ->where('tenant_id', $this->tenant_id) + ->where('id', '!=', $this->id ?? 0) + ->exists() + ) { + $slug = $original . '-' . $count; + $count++; + } + + return $slug; + } + + public function isPublished(): bool + { + return $this->status === 'published'; + } +} diff --git a/erp/app/Modules/KnowledgeBase/Models/KbCategory.php b/erp/app/Modules/KnowledgeBase/Models/KbCategory.php new file mode 100644 index 00000000000..f1e41d39bd9 --- /dev/null +++ b/erp/app/Modules/KnowledgeBase/Models/KbCategory.php @@ -0,0 +1,45 @@ +belongsTo(KbCategory::class, 'parent_id'); + } + + public function children(): HasMany + { + return $this->hasMany(KbCategory::class, 'parent_id')->orderBy('sequence'); + } + + public function articles(): HasMany + { + return $this->hasMany(KbArticle::class, 'category_id'); + } + + public function articleCount(): int + { + return $this->articles()->count(); + } +} diff --git a/erp/app/Modules/KnowledgeBase/Providers/KnowledgeBaseServiceProvider.php b/erp/app/Modules/KnowledgeBase/Providers/KnowledgeBaseServiceProvider.php new file mode 100644 index 00000000000..c8650104681 --- /dev/null +++ b/erp/app/Modules/KnowledgeBase/Providers/KnowledgeBaseServiceProvider.php @@ -0,0 +1,16 @@ +loadRoutesFrom(__DIR__ . '/../routes/knowledge_base.php'); + $this->loadMigrationsFrom(__DIR__ . '/../../../database/migrations'); + } +} diff --git a/erp/app/Modules/KnowledgeBase/routes/knowledge_base.php b/erp/app/Modules/KnowledgeBase/routes/knowledge_base.php new file mode 100644 index 00000000000..3dcb49d0f14 --- /dev/null +++ b/erp/app/Modules/KnowledgeBase/routes/knowledge_base.php @@ -0,0 +1,17 @@ +prefix('kb')->name('kb.')->group(function () { + Route::get('categories', [KnowledgeBaseController::class, 'categories'])->name('categories'); + Route::post('categories', [KnowledgeBaseController::class, 'storeCategory'])->name('categories.store'); + Route::get('search', [KnowledgeBaseController::class, 'search'])->name('search'); + Route::post('{article}/publish', [KnowledgeBaseController::class, 'publish'])->name('publish'); + Route::post('{article}/archive', [KnowledgeBaseController::class, 'archive'])->name('archive'); + Route::get('', [KnowledgeBaseController::class, 'index'])->name('index'); + Route::post('', [KnowledgeBaseController::class, 'store'])->name('store'); + Route::get('{article}', [KnowledgeBaseController::class, 'show'])->name('show'); + Route::patch('{article}', [KnowledgeBaseController::class, 'update'])->name('update'); + Route::delete('{article}', [KnowledgeBaseController::class, 'destroy'])->name('destroy'); +}); diff --git a/erp/app/Modules/LiveChat/Http/Controllers/ChatWidgetController.php b/erp/app/Modules/LiveChat/Http/Controllers/ChatWidgetController.php new file mode 100644 index 00000000000..12130a933c6 --- /dev/null +++ b/erp/app/Modules/LiveChat/Http/Controllers/ChatWidgetController.php @@ -0,0 +1,72 @@ +validate([ + 'channel_id' => 'required|exists:chat_channels,id', + 'visitor_name' => 'nullable|string|max:255', + 'visitor_email' => 'nullable|email|max:255', + 'source_url' => 'nullable|string|max:500', + ]); + + $session = ChatSession::create([ + 'tenant_id' => \App\Modules\LiveChat\Models\ChatChannel::find($validated['channel_id'])->tenant_id, + 'channel_id' => $validated['channel_id'], + 'visitor_name' => $validated['visitor_name'] ?? null, + 'visitor_email'=> $validated['visitor_email'] ?? null, + 'source_url' => $validated['source_url'] ?? null, + 'status' => 'open', + 'started_at' => now(), + ]); + + $token = sha1($session->id . $session->created_at); + + return response()->json([ + 'session_id' => $session->id, + 'token' => $token, + ]); + } + + public function sendVisitorMessage(Request $request): JsonResponse + { + $validated = $request->validate([ + 'session_id' => 'required|exists:chat_sessions,id', + 'message' => 'required|string', + ]); + + $session = ChatSession::findOrFail($validated['session_id']); + + ChatMessage::create([ + 'tenant_id' => $session->tenant_id, + 'session_id' => $session->id, + 'sender_type' => 'visitor', + 'message' => $validated['message'], + ]); + + $session->update(['last_message_at' => now()]); + + return response()->json(['success' => true]); + } + + public function getMessages(Request $request): JsonResponse + { + $validated = $request->validate([ + 'session_id' => 'required|exists:chat_sessions,id', + ]); + + $session = ChatSession::findOrFail($validated['session_id']); + $messages = $session->messages()->orderBy('created_at')->get(); + + return response()->json($messages); + } +} diff --git a/erp/app/Modules/LiveChat/Http/Controllers/LiveChatController.php b/erp/app/Modules/LiveChat/Http/Controllers/LiveChatController.php new file mode 100644 index 00000000000..182ea7ab31f --- /dev/null +++ b/erp/app/Modules/LiveChat/Http/Controllers/LiveChatController.php @@ -0,0 +1,137 @@ +id; + $today = now()->startOfDay(); + + return Inertia::render('LiveChat/Dashboard', [ + 'stats' => [ + 'open_sessions' => ChatSession::withoutGlobalScopes()->where('tenant_id', $tenantId)->where('status', 'open')->count(), + 'assigned_sessions'=> ChatSession::withoutGlobalScopes()->where('tenant_id', $tenantId)->where('status', 'assigned')->count(), + 'resolved_today' => ChatSession::withoutGlobalScopes()->where('tenant_id', $tenantId)->where('status', 'resolved')->where('ended_at', '>=', $today)->count(), + 'missed_today' => ChatSession::withoutGlobalScopes()->where('tenant_id', $tenantId)->where('status', 'missed')->where('ended_at', '>=', $today)->count(), + 'channels_count' => ChatChannel::withoutGlobalScopes()->where('tenant_id', $tenantId)->count(), + 'avg_rating' => round((float) ChatSession::withoutGlobalScopes()->where('tenant_id', $tenantId)->whereNotNull('rating')->avg('rating'), 1), + ], + ]); + } + + public function channels(): Response + { + $channels = ChatChannel::withoutGlobalScopes() + ->where('tenant_id', app('tenant')->id) + ->withCount('sessions') + ->orderBy('name') + ->get(); + + return Inertia::render('LiveChat/Channels/Index', ['channels' => $channels]); + } + + public function storeChannel(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'widget_color' => 'nullable|string|max:20', + 'welcome_message' => 'nullable|string', + 'offline_message' => 'nullable|string', + ]); + + ChatChannel::create(['tenant_id' => app('tenant')->id] + $validated); + + return redirect()->back()->with('success', 'Channel created.'); + } + + public function sessions(): Response + { + $status = request('status'); + + $query = ChatSession::withoutGlobalScopes() + ->where('tenant_id', app('tenant')->id) + ->with(['channel', 'agent']) + ->orderByDesc('last_message_at'); + + if ($status) { + $query->where('status', $status); + } + + $sessions = $query->paginate(20); + + return Inertia::render('LiveChat/Sessions/Index', [ + 'sessions' => $sessions, + 'active_status' => $status, + ]); + } + + public function show(ChatSession $session): Response + { + $session->load(['messages.agent', 'channel', 'agent']); + + return Inertia::render('LiveChat/Sessions/Show', ['session' => $session]); + } + + public function assign(Request $request, ChatSession $session): RedirectResponse + { + $validated = $request->validate([ + 'agent_id' => 'required|exists:users,id', + ]); + + $session->assign((int) $validated['agent_id']); + + return redirect()->back()->with('success', 'Session assigned.'); + } + + public function resolve(ChatSession $session): RedirectResponse + { + $session->resolve(); + + return redirect()->back()->with('success', 'Session resolved.'); + } + + public function sendMessage(Request $request, ChatSession $session): RedirectResponse + { + $validated = $request->validate([ + 'message' => 'required|string', + ]); + + $msg = ChatMessage::create([ + 'tenant_id' => $session->tenant_id, + 'session_id' => $session->id, + 'sender_type' => 'agent', + 'agent_id' => auth()->id(), + 'message' => $validated['message'], + ]); + + $session->update(['last_message_at' => now()]); + + broadcast(new NewChatMessage($msg))->toOthers(); + + return redirect()->back()->with('success', 'Message sent.'); + } + + public function rate(Request $request, ChatSession $session): RedirectResponse + { + $validated = $request->validate([ + 'rating' => 'required|integer|min:1|max:5', + 'rating_note' => 'nullable|string', + ]); + + $session->update($validated); + + return redirect()->back()->with('success', 'Rating saved.'); + } +} diff --git a/erp/app/Modules/LiveChat/Models/ChatChannel.php b/erp/app/Modules/LiveChat/Models/ChatChannel.php new file mode 100644 index 00000000000..c7a984d7159 --- /dev/null +++ b/erp/app/Modules/LiveChat/Models/ChatChannel.php @@ -0,0 +1,34 @@ + 'boolean', + 'assigned_agents' => 'array', + ]; + + public function sessions(): HasMany + { + return $this->hasMany(ChatSession::class, 'channel_id'); + } +} diff --git a/erp/app/Modules/LiveChat/Models/ChatMessage.php b/erp/app/Modules/LiveChat/Models/ChatMessage.php new file mode 100644 index 00000000000..eda6d378988 --- /dev/null +++ b/erp/app/Modules/LiveChat/Models/ChatMessage.php @@ -0,0 +1,40 @@ + 'boolean', + 'read_at' => 'datetime', + ]; + + public function session(): BelongsTo + { + return $this->belongsTo(ChatSession::class, 'session_id'); + } + + public function agent(): BelongsTo + { + return $this->belongsTo(User::class, 'agent_id'); + } +} diff --git a/erp/app/Modules/LiveChat/Models/ChatSession.php b/erp/app/Modules/LiveChat/Models/ChatSession.php new file mode 100644 index 00000000000..a33e81368f8 --- /dev/null +++ b/erp/app/Modules/LiveChat/Models/ChatSession.php @@ -0,0 +1,94 @@ + 'datetime', + 'ended_at' => 'datetime', + 'last_message_at' => 'datetime', + 'rating' => 'integer', + ]; + + public function channel(): BelongsTo + { + return $this->belongsTo(ChatChannel::class, 'channel_id'); + } + + public function messages(): HasMany + { + return $this->hasMany(ChatMessage::class, 'session_id'); + } + + public function agent(): BelongsTo + { + return $this->belongsTo(User::class, 'assigned_agent_id'); + } + + public function assign(int $agentId): void + { + $this->update([ + 'status' => 'assigned', + 'assigned_agent_id' => $agentId, + ]); + } + + public function resolve(): void + { + $this->update([ + 'status' => 'resolved', + 'ended_at' => now(), + ]); + } + + public function markMissed(): void + { + $this->update([ + 'status' => 'missed', + 'ended_at' => now(), + ]); + } + + public function unreadCount(): int + { + return $this->messages() + ->where('is_read', false) + ->where('sender_type', 'visitor') + ->count(); + } + + public function duration(): ?int + { + if ($this->started_at && $this->ended_at) { + return (int) $this->started_at->diffInMinutes($this->ended_at); + } + + return null; + } +} diff --git a/erp/app/Modules/LiveChat/Providers/LiveChatServiceProvider.php b/erp/app/Modules/LiveChat/Providers/LiveChatServiceProvider.php new file mode 100644 index 00000000000..e7cd9a67c87 --- /dev/null +++ b/erp/app/Modules/LiveChat/Providers/LiveChatServiceProvider.php @@ -0,0 +1,15 @@ +loadRoutesFrom(__DIR__ . '/../routes/livechat.php'); + } +} diff --git a/erp/app/Modules/LiveChat/routes/livechat.php b/erp/app/Modules/LiveChat/routes/livechat.php new file mode 100644 index 00000000000..1a3f47f7559 --- /dev/null +++ b/erp/app/Modules/LiveChat/routes/livechat.php @@ -0,0 +1,25 @@ +prefix('live-chat')->name('live-chat.')->group(function () { + Route::get('dashboard', [LiveChatController::class, 'dashboard'])->name('dashboard'); + Route::get('channels', [LiveChatController::class, 'channels'])->name('channels'); + Route::post('channels', [LiveChatController::class, 'storeChannel'])->name('channels.store'); + Route::get('sessions', [LiveChatController::class, 'sessions'])->name('sessions'); + Route::get('sessions/{session}', [LiveChatController::class, 'show'])->name('sessions.show'); + Route::post('sessions/{session}/assign', [LiveChatController::class, 'assign'])->name('sessions.assign'); + Route::post('sessions/{session}/resolve', [LiveChatController::class, 'resolve'])->name('sessions.resolve'); + Route::post('sessions/{session}/messages', [LiveChatController::class, 'sendMessage'])->name('sessions.messages.store'); + Route::post('sessions/{session}/rate', [LiveChatController::class, 'rate'])->name('sessions.rate'); +}); + +// Public widget API (no auth) +Route::prefix('chat-widget')->name('chat-widget.')->group(function () { + Route::post('session', [ChatWidgetController::class, 'createSession'])->name('session.create'); + Route::post('message', [ChatWidgetController::class, 'sendVisitorMessage'])->name('message.send'); + Route::get('messages', [ChatWidgetController::class, 'getMessages'])->name('messages.get'); +}); diff --git a/erp/app/Modules/Lunch/Http/Controllers/LunchController.php b/erp/app/Modules/Lunch/Http/Controllers/LunchController.php new file mode 100644 index 00000000000..378a35883cb --- /dev/null +++ b/erp/app/Modules/Lunch/Http/Controllers/LunchController.php @@ -0,0 +1,132 @@ +toDateString(); + + $stats = [ + 'today_orders' => LunchOrder::whereDate('order_date', $today)->count(), + 'pending_count' => LunchOrder::whereDate('order_date', $today)->where('status', 'pending')->count(), + 'confirmed_count' => LunchOrder::whereDate('order_date', $today)->where('status', 'confirmed')->count(), + 'delivered_count' => LunchOrder::whereDate('order_date', $today)->where('status', 'delivered')->count(), + 'total_spend_today' => (float) LunchOrder::whereDate('order_date', $today)->sum('total_price'), + ]; + + return Inertia::render('Lunch/Dashboard', compact('stats')); + } + + public function suppliers(): Response + { + $suppliers = LunchSupplier::withCount('products')->paginate(20); + + return Inertia::render('Lunch/Suppliers/Index', ['suppliers' => $suppliers]); + } + + public function storeSupplier(Request $request): RedirectResponse + { + $data = $request->validate([ + 'name' => 'required|string|max:255', + 'address' => 'nullable|string', + 'phone' => 'nullable|string|max:50', + 'email' => 'nullable|email|max:255', + 'description' => 'nullable|string', + 'is_active' => 'nullable|boolean', + ]); + + LunchSupplier::create(array_merge($data, [ + 'tenant_id' => app('tenant')->id, + ])); + + return redirect()->back()->with('success', 'Supplier created successfully.'); + } + + public function products(): Response + { + $products = LunchProduct::with('supplier')->paginate(20); + + return Inertia::render('Lunch/Products/Index', ['products' => $products]); + } + + public function storeProduct(Request $request): RedirectResponse + { + $data = $request->validate([ + 'lunch_supplier_id' => 'required|exists:lunch_suppliers,id', + 'name' => 'required|string|max:255', + 'description' => 'nullable|string', + 'price' => 'required|numeric|min:0', + 'category' => 'nullable|string|max:255', + 'is_available' => 'nullable|boolean', + 'image_url' => 'nullable|string|max:255', + ]); + + LunchProduct::create(array_merge($data, [ + 'tenant_id' => app('tenant')->id, + ])); + + return redirect()->back()->with('success', 'Product created successfully.'); + } + + public function orders(Request $request): Response + { + $date = $request->input('date', today()->toDateString()); + + $orders = LunchOrder::with('product.supplier') + ->whereDate('order_date', $date) + ->paginate(20); + + return Inertia::render('Lunch/Orders/Index', [ + 'orders' => $orders, + 'date' => $date, + ]); + } + + public function placeOrder(Request $request): JsonResponse + { + $data = $request->validate([ + 'lunch_product_id' => 'required|exists:lunch_products,id', + 'quantity' => 'required|integer|min:1|max:10', + 'order_date' => 'required|date', + 'notes' => 'nullable|string', + ]); + + $product = LunchProduct::findOrFail($data['lunch_product_id']); + + LunchOrder::create([ + 'tenant_id' => app('tenant')->id, + 'employee_id' => auth()->id(), + 'lunch_product_id' => $data['lunch_product_id'], + 'quantity' => $data['quantity'], + 'order_date' => $data['order_date'], + 'notes' => $data['notes'] ?? null, + 'total_price' => $product->price * $data['quantity'], + 'status' => 'pending', + ]); + + return response()->json(['success' => true]); + } + + public function updateStatus(Request $request, LunchOrder $order): JsonResponse + { + $data = $request->validate([ + 'status' => 'required|in:confirmed,delivered,cancelled', + ]); + + $order->update(['status' => $data['status']]); + + return response()->json(['success' => true]); + } +} diff --git a/erp/app/Modules/Lunch/Models/LunchOrder.php b/erp/app/Modules/Lunch/Models/LunchOrder.php new file mode 100644 index 00000000000..22d36191423 --- /dev/null +++ b/erp/app/Modules/Lunch/Models/LunchOrder.php @@ -0,0 +1,50 @@ + 'date', + 'total_price' => 'decimal:2', + ]; + + public function product(): BelongsTo + { + return $this->belongsTo(LunchProduct::class, 'lunch_product_id'); + } + + public function confirm(): bool + { + return $this->update(['status' => 'confirmed']); + } + + public function deliver(): bool + { + return $this->update(['status' => 'delivered']); + } + + public function cancel(): bool + { + return $this->update(['status' => 'cancelled']); + } +} diff --git a/erp/app/Modules/Lunch/Models/LunchProduct.php b/erp/app/Modules/Lunch/Models/LunchProduct.php new file mode 100644 index 00000000000..5980a2b83c7 --- /dev/null +++ b/erp/app/Modules/Lunch/Models/LunchProduct.php @@ -0,0 +1,46 @@ + 'decimal:2', + 'is_available' => 'boolean', + ]; + + public function supplier(): BelongsTo + { + return $this->belongsTo(LunchSupplier::class, 'lunch_supplier_id'); + } + + public function orders(): HasMany + { + return $this->hasMany(LunchOrder::class); + } + + public function subtotal(): float + { + return (float) $this->price; + } +} diff --git a/erp/app/Modules/Lunch/Models/LunchSupplier.php b/erp/app/Modules/Lunch/Models/LunchSupplier.php new file mode 100644 index 00000000000..04dba132803 --- /dev/null +++ b/erp/app/Modules/Lunch/Models/LunchSupplier.php @@ -0,0 +1,33 @@ + 'boolean', + ]; + + public function products(): HasMany + { + return $this->hasMany(LunchProduct::class); + } +} diff --git a/erp/app/Modules/Lunch/Providers/LunchServiceProvider.php b/erp/app/Modules/Lunch/Providers/LunchServiceProvider.php new file mode 100644 index 00000000000..c69fcf256d7 --- /dev/null +++ b/erp/app/Modules/Lunch/Providers/LunchServiceProvider.php @@ -0,0 +1,15 @@ +loadRoutesFrom(__DIR__ . '/../routes/lunch.php'); + } +} diff --git a/erp/app/Modules/Lunch/routes/lunch.php b/erp/app/Modules/Lunch/routes/lunch.php new file mode 100644 index 00000000000..0ccd17c69dd --- /dev/null +++ b/erp/app/Modules/Lunch/routes/lunch.php @@ -0,0 +1,15 @@ +prefix('lunch')->name('lunch.')->group(function () { + Route::get('dashboard', [LunchController::class, 'dashboard'])->name('dashboard'); + Route::get('suppliers', [LunchController::class, 'suppliers'])->name('suppliers'); + Route::post('suppliers', [LunchController::class, 'storeSupplier'])->name('suppliers.store'); + Route::get('products', [LunchController::class, 'products'])->name('products'); + Route::post('products', [LunchController::class, 'storeProduct'])->name('products.store'); + Route::get('orders', [LunchController::class, 'orders'])->name('orders'); + Route::post('orders', [LunchController::class, 'placeOrder'])->name('orders.store'); + Route::patch('orders/{order}/status', [LunchController::class, 'updateStatus'])->name('orders.status'); +}); diff --git a/erp/app/Modules/Maintenance/Http/Controllers/MaintenanceController.php b/erp/app/Modules/Maintenance/Http/Controllers/MaintenanceController.php new file mode 100644 index 00000000000..135e88084ae --- /dev/null +++ b/erp/app/Modules/Maintenance/Http/Controllers/MaintenanceController.php @@ -0,0 +1,131 @@ +id; + return Inertia::render('Maintenance/Dashboard', [ + 'stats' => [ + 'total_equipment' => Equipment::withoutGlobalScopes()->where('tenant_id', $tenantId)->count(), + 'operational' => Equipment::withoutGlobalScopes()->where('tenant_id', $tenantId)->where('status', 'operational')->count(), + 'open_orders' => MaintenanceOrder::withoutGlobalScopes()->where('tenant_id', $tenantId)->whereIn('status', ['open', 'in_progress'])->count(), + 'overdue_plans' => MaintenancePlan::withoutGlobalScopes()->where('tenant_id', $tenantId)->where('is_active', true)->where('next_due_at', '<', now())->count(), + ], + ]); + } + + public function equipment(): Response + { + $equipment = Equipment::withoutGlobalScopes() + ->where('tenant_id', app('tenant')->id) + ->with('assignedUser') + ->orderBy('name') + ->paginate(20); + return Inertia::render('Maintenance/Equipment/Index', ['equipment' => $equipment]); + } + + public function storeEquipment(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'code' => 'nullable|string|max:100', + 'category' => 'required|in:machinery,electrical,hvac,vehicle,it,other', + 'location' => 'nullable|string|max:255', + 'serial_number' => 'nullable|string|max:255', + 'manufacturer' => 'nullable|string|max:255', + 'model' => 'nullable|string|max:255', + 'purchase_date' => 'nullable|date', + 'warranty_expiry' => 'nullable|date', + 'status' => 'sometimes|in:operational,under_maintenance,out_of_service,retired', + 'assigned_to' => 'nullable|exists:users,id', + ]); + Equipment::create(['tenant_id' => app('tenant')->id] + $validated); + return redirect()->route('maintenance.equipment')->with('success', 'Equipment added.'); + } + + public function orders(): Response + { + $orders = MaintenanceOrder::withoutGlobalScopes() + ->where('tenant_id', app('tenant')->id) + ->with(['equipment', 'assignedUser']) + ->orderByDesc('created_at') + ->paginate(20); + return Inertia::render('Maintenance/Orders/Index', ['orders' => $orders]); + } + + public function storeOrder(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'equipment_id' => 'required|exists:equipment,id', + 'type' => 'required|in:preventive,corrective,emergency', + 'priority' => 'required|in:low,medium,high,critical', + 'title' => 'required|string|max:255', + 'description' => 'nullable|string', + 'scheduled_date' => 'nullable|date', + 'estimated_hours' => 'nullable|numeric|min:0', + 'assigned_to' => 'nullable|exists:users,id', + ]); + $validated['order_number'] = MaintenanceOrder::generateOrderNumber(app('tenant')->id); + MaintenanceOrder::create(['tenant_id' => app('tenant')->id, 'reported_by' => auth()->id()] + $validated); + return redirect()->route('maintenance.orders')->with('success', 'Order created.'); + } + + public function startOrder(MaintenanceOrder $order): RedirectResponse + { + $order->start(); + return redirect()->back()->with('success', 'Order started.'); + } + + public function completeOrder(Request $request, MaintenanceOrder $order): RedirectResponse + { + $validated = $request->validate([ + 'resolution' => 'required|string', + 'actual_hours' => 'required|numeric|min:0', + 'cost' => 'nullable|numeric|min:0', + ]); + $order->complete($validated['resolution'], (float) $validated['actual_hours']); + if (isset($validated['cost'])) { + $order->update(['cost' => $validated['cost']]); + } + if ($order->plan_id) { + $order->plan?->markPerformed(); + } + return redirect()->back()->with('success', 'Order completed.'); + } + + public function plans(): Response + { + $plans = MaintenancePlan::withoutGlobalScopes() + ->where('tenant_id', app('tenant')->id) + ->with('equipment') + ->orderBy('next_due_at') + ->get(); + return Inertia::render('Maintenance/Plans/Index', ['plans' => $plans]); + } + + public function storePlan(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'equipment_id' => 'required|exists:equipment,id', + 'name' => 'required|string|max:255', + 'frequency' => 'required|in:daily,weekly,monthly,quarterly,annual,as_needed', + 'estimated_duration_hours' => 'nullable|numeric|min:0', + 'description' => 'nullable|string', + ]); + $plan = MaintenancePlan::create(['tenant_id' => app('tenant')->id] + $validated); + $plan->next_due_at = $plan->calculateNextDue(); + $plan->save(); + return redirect()->route('maintenance.plans')->with('success', 'Maintenance plan created.'); + } +} diff --git a/erp/app/Modules/Maintenance/Models/Equipment.php b/erp/app/Modules/Maintenance/Models/Equipment.php new file mode 100644 index 00000000000..17530ede19a --- /dev/null +++ b/erp/app/Modules/Maintenance/Models/Equipment.php @@ -0,0 +1,57 @@ + 'date', + 'warranty_expiry' => 'date', + ]; + + public function plans(): HasMany + { + return $this->hasMany(MaintenancePlan::class); + } + + public function orders(): HasMany + { + return $this->hasMany(MaintenanceOrder::class); + } + + public function assignedUser(): BelongsTo + { + return $this->belongsTo(User::class, 'assigned_to'); + } + + public function isWarrantyExpired(): bool + { + return $this->warranty_expiry && $this->warranty_expiry->isPast(); + } + + public function retire(): void + { + $this->update(['status' => 'retired']); + } + + public function openOrdersCount(): int + { + return $this->orders()->whereIn('status', ['open', 'in_progress'])->count(); + } +} diff --git a/erp/app/Modules/Maintenance/Models/MaintenanceOrder.php b/erp/app/Modules/Maintenance/Models/MaintenanceOrder.php new file mode 100644 index 00000000000..890aaa77aa3 --- /dev/null +++ b/erp/app/Modules/Maintenance/Models/MaintenanceOrder.php @@ -0,0 +1,81 @@ + 'date', + 'started_at' => 'datetime', + 'completed_at' => 'datetime', + 'estimated_hours' => 'decimal:2', + 'actual_hours' => 'decimal:2', + 'cost' => 'decimal:2', + ]; + + public function equipment(): BelongsTo + { + return $this->belongsTo(Equipment::class); + } + + public function plan(): BelongsTo + { + return $this->belongsTo(MaintenancePlan::class); + } + + public function assignedUser(): BelongsTo + { + return $this->belongsTo(User::class, 'assigned_to'); + } + + public static function generateOrderNumber(int $tenantId): string + { + $count = static::withoutGlobalScopes()->where('tenant_id', $tenantId)->count(); + return 'MO-' . str_pad($count + 1, 5, '0', STR_PAD_LEFT); + } + + public function start(): void + { + $this->update([ + 'status' => 'in_progress', + 'started_at' => now(), + ]); + } + + public function complete(string $resolution, float $actualHours): void + { + $this->update([ + 'status' => 'completed', + 'completed_at' => now(), + 'resolution' => $resolution, + 'actual_hours' => $actualHours, + ]); + } + + public function cancel(): void + { + $this->update(['status' => 'cancelled']); + } + + public function isOverdue(): bool + { + return $this->scheduled_date + && $this->scheduled_date->isPast() + && !in_array($this->status, ['completed', 'cancelled']); + } +} diff --git a/erp/app/Modules/Maintenance/Models/MaintenancePlan.php b/erp/app/Modules/Maintenance/Models/MaintenancePlan.php new file mode 100644 index 00000000000..1aefe09bd2f --- /dev/null +++ b/erp/app/Modules/Maintenance/Models/MaintenancePlan.php @@ -0,0 +1,56 @@ + 'boolean', + 'last_performed_at' => 'datetime', + 'next_due_at' => 'datetime', + ]; + + public function equipment(): BelongsTo + { + return $this->belongsTo(Equipment::class); + } + + public function calculateNextDue(): Carbon + { + return match ($this->frequency) { + 'daily' => now()->addDay(), + 'weekly' => now()->addDays(7), + 'monthly' => now()->addMonth(), + 'quarterly' => now()->addMonths(3), + 'annual' => now()->addYear(), + 'as_needed' => now()->addMonth(), + default => now()->addMonth(), + }; + } + + public function markPerformed(): void + { + $this->update([ + 'last_performed_at' => now(), + 'next_due_at' => $this->calculateNextDue(), + ]); + } + + public function isDue(): bool + { + return $this->next_due_at && $this->next_due_at->isPast(); + } +} diff --git a/erp/app/Modules/Maintenance/Providers/MaintenanceServiceProvider.php b/erp/app/Modules/Maintenance/Providers/MaintenanceServiceProvider.php new file mode 100644 index 00000000000..12e2da156d1 --- /dev/null +++ b/erp/app/Modules/Maintenance/Providers/MaintenanceServiceProvider.php @@ -0,0 +1,15 @@ +loadRoutesFrom(__DIR__ . '/../routes/maintenance.php'); + } +} diff --git a/erp/app/Modules/Maintenance/routes/maintenance.php b/erp/app/Modules/Maintenance/routes/maintenance.php new file mode 100644 index 00000000000..2871aa7cde4 --- /dev/null +++ b/erp/app/Modules/Maintenance/routes/maintenance.php @@ -0,0 +1,22 @@ +prefix('maintenance')->name('maintenance.')->group(function () { + Route::get('dashboard', [MaintenanceController::class, 'dashboard'])->name('dashboard'); + + // Equipment + Route::get('equipment', [MaintenanceController::class, 'equipment'])->name('equipment'); + Route::post('equipment', [MaintenanceController::class, 'storeEquipment'])->name('equipment.store'); + + // Maintenance Plans + Route::get('plans', [MaintenanceController::class, 'plans'])->name('plans'); + Route::post('plans', [MaintenanceController::class, 'storePlan'])->name('plans.store'); + + // Maintenance Orders + Route::get('orders', [MaintenanceController::class, 'orders'])->name('orders'); + Route::post('orders', [MaintenanceController::class, 'storeOrder'])->name('orders.store'); + Route::post('orders/{order}/start', [MaintenanceController::class, 'startOrder'])->name('orders.start'); + Route::post('orders/{order}/complete', [MaintenanceController::class, 'completeOrder'])->name('orders.complete'); +}); diff --git a/erp/app/Modules/Manufacturing/Http/Controllers/BomController.php b/erp/app/Modules/Manufacturing/Http/Controllers/BomController.php new file mode 100644 index 00000000000..98c5edc74d3 --- /dev/null +++ b/erp/app/Modules/Manufacturing/Http/Controllers/BomController.php @@ -0,0 +1,143 @@ +authorize('viewAny', BillOfMaterials::class); + + $boms = BillOfMaterials::with('product') + ->withCount('lines') + ->when($request->search, fn ($q) => $q->where('name', 'like', "%{$request->search}%") + ->orWhere('code', 'like', "%{$request->search}%")) + ->orderBy('name') + ->paginate(25) + ->withQueryString(); + + return Inertia::render('Manufacturing/BillsOfMaterials/Index', [ + 'boms' => $boms, + 'filters' => $request->only(['search']), + ]); + } + + public function create(): Response + { + $this->authorize('create', BillOfMaterials::class); + + return Inertia::render('Manufacturing/BillsOfMaterials/Create', [ + 'products' => Product::orderBy('name')->get(['id', 'name', 'sku']), + ]); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', BillOfMaterials::class); + + $validated = $request->validate([ + 'product_id' => 'required|exists:products,id', + 'name' => 'required|string|max:255', + 'code' => 'nullable|string|max:100', + 'type' => 'required|in:manufacture,kit,subcontracting', + 'qty_per_bom' => 'required|numeric|min:0.0001', + 'uom' => 'nullable|string|max:50', + 'is_active' => 'boolean', + 'notes' => 'nullable|string', + 'lines' => 'array', + 'lines.*.component_id' => 'required|exists:products,id', + 'lines.*.quantity' => 'required|numeric|min:0.0001', + 'lines.*.uom' => 'nullable|string|max:50', + 'lines.*.sequence' => 'nullable|integer|min:1', + 'lines.*.is_optional' => 'boolean', + 'lines.*.notes' => 'nullable|string', + ]); + + $bom = BillOfMaterials::create([ + ...$validated, + 'tenant_id' => auth()->user()->tenant_id, + ]); + + foreach ($validated['lines'] ?? [] as $line) { + $bom->lines()->create($line); + } + + return redirect()->route('manufacturing.boms.index') + ->with('success', 'Bill of Materials created successfully.'); + } + + public function show(BillOfMaterials $bom): Response + { + $this->authorize('view', $bom); + + $bom->load(['product', 'lines.component']); + + return Inertia::render('Manufacturing/BillsOfMaterials/Show', [ + 'bom' => $bom, + ]); + } + + public function edit(BillOfMaterials $bom): Response + { + $this->authorize('update', $bom); + + $bom->load(['product', 'lines.component']); + + return Inertia::render('Manufacturing/BillsOfMaterials/Edit', [ + 'bom' => $bom, + 'products' => Product::orderBy('name')->get(['id', 'name', 'sku']), + ]); + } + + public function update(Request $request, BillOfMaterials $bom): RedirectResponse + { + $this->authorize('update', $bom); + + $validated = $request->validate([ + 'product_id' => 'required|exists:products,id', + 'name' => 'required|string|max:255', + 'code' => 'nullable|string|max:100', + 'type' => 'required|in:manufacture,kit,subcontracting', + 'qty_per_bom' => 'required|numeric|min:0.0001', + 'uom' => 'nullable|string|max:50', + 'is_active' => 'boolean', + 'notes' => 'nullable|string', + 'lines' => 'array', + 'lines.*.component_id' => 'required|exists:products,id', + 'lines.*.quantity' => 'required|numeric|min:0.0001', + 'lines.*.uom' => 'nullable|string|max:50', + 'lines.*.sequence' => 'nullable|integer|min:1', + 'lines.*.is_optional' => 'boolean', + 'lines.*.notes' => 'nullable|string', + ]); + + $bom->update($validated); + + // Sync lines: delete old, insert new + $bom->lines()->delete(); + foreach ($validated['lines'] ?? [] as $line) { + $bom->lines()->create($line); + } + + return redirect()->route('manufacturing.boms.show', $bom) + ->with('success', 'Bill of Materials updated successfully.'); + } + + public function destroy(BillOfMaterials $bom): RedirectResponse + { + $this->authorize('delete', $bom); + + $bom->delete(); + + return redirect()->route('manufacturing.boms.index') + ->with('success', 'Bill of Materials deleted successfully.'); + } +} diff --git a/erp/app/Modules/Manufacturing/Http/Controllers/ManufacturingDashboardController.php b/erp/app/Modules/Manufacturing/Http/Controllers/ManufacturingDashboardController.php new file mode 100644 index 00000000000..efc54ca6be4 --- /dev/null +++ b/erp/app/Modules/Manufacturing/Http/Controllers/ManufacturingDashboardController.php @@ -0,0 +1,64 @@ +copy()->startOfMonth(); + $startOfWeek = $now->copy()->startOfWeek(); + $endOfWeek = $now->copy()->endOfWeek(); + + $openMos = ManufacturingOrder::whereIn('status', ['draft', 'confirmed'])->count(); + $inProgressMos = ManufacturingOrder::where('status', 'in_progress')->count(); + + $doneMos = ManufacturingOrder::where('status', 'done') + ->where('finish_date', '>=', $startOfMonth->toDateString()) + ->count(); + + $scheduledThisWeek = ManufacturingOrder::whereBetween('scheduled_date', [ + $startOfWeek->toDateString(), $endOfWeek->toDateString(), + ])->count(); + + $efficiencyData = ManufacturingOrder::where('status', 'done') + ->where('finish_date', '>=', $startOfMonth->toDateString()) + ->where('qty_to_produce', '>', 0) + ->get(['qty_produced', 'qty_to_produce']); + + $efficiency = $efficiencyData->isNotEmpty() + ? round($efficiencyData->avg(fn ($mo) => ($mo->qty_produced / $mo->qty_to_produce) * 100), 1) + : 0; + + $recentMos = ManufacturingOrder::with('product') + ->orderByDesc('created_at') + ->limit(5) + ->get(['id', 'mo_number', 'product_id', 'status', 'qty_to_produce', 'scheduled_date']); + + $workCenterUtilization = WorkCenter::withCount([ + 'workOrders as open_work_orders_count' => fn ($q) => $q->whereIn('status', ['pending', 'in_progress']), + 'workOrders as done_work_orders_count' => fn ($q) => $q->where('status', 'done'), + ])->orderBy('name')->get(['id', 'name', 'code']); + + return Inertia::render('Manufacturing/Dashboard', [ + 'stats' => [ + 'openMos' => $openMos, + 'inProgressMos' => $inProgressMos, + 'doneMos' => $doneMos, + 'scheduledThisWeek' => $scheduledThisWeek, + 'efficiency' => $efficiency, + ], + 'recentMos' => $recentMos, + 'workCenterUtilization' => $workCenterUtilization, + ]); + } +} diff --git a/erp/app/Modules/Manufacturing/Http/Controllers/ManufacturingOrderController.php b/erp/app/Modules/Manufacturing/Http/Controllers/ManufacturingOrderController.php new file mode 100644 index 00000000000..a8bd8d4ef99 --- /dev/null +++ b/erp/app/Modules/Manufacturing/Http/Controllers/ManufacturingOrderController.php @@ -0,0 +1,181 @@ +authorize('viewAny', ManufacturingOrder::class); + + $orders = ManufacturingOrder::with(['product', 'bom']) + ->when($request->search, fn ($q) => $q->where('mo_number', 'like', "%{$request->search}%")) + ->when($request->status, fn ($q) => $q->where('status', $request->status)) + ->orderByDesc('created_at') + ->paginate(25) + ->withQueryString(); + + return Inertia::render('Manufacturing/ManufacturingOrders/Index', [ + 'orders' => $orders, + 'filters' => $request->only(['search', 'status']), + ]); + } + + public function create(): Response + { + $this->authorize('create', ManufacturingOrder::class); + + return Inertia::render('Manufacturing/ManufacturingOrders/Create', [ + 'products' => Product::orderBy('name')->get(['id', 'name', 'sku']), + 'boms' => BillOfMaterials::where('is_active', true)->with('product')->orderBy('name')->get(['id', 'name', 'product_id', 'type']), + 'warehouses' => Warehouse::orderBy('name')->get(['id', 'name']), + 'users' => User::orderBy('name')->get(['id', 'name']), + ]); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', ManufacturingOrder::class); + + $validated = $request->validate([ + 'product_id' => 'required|exists:products,id', + 'bom_id' => 'nullable|exists:bills_of_materials,id', + 'qty_to_produce' => 'required|numeric|min:0.0001', + 'scheduled_date' => 'nullable|date', + 'warehouse_id' => 'nullable|exists:warehouses,id', + 'origin' => 'nullable|string|max:255', + 'notes' => 'nullable|string', + 'responsible_id' => 'nullable|exists:users,id', + ]); + + $mo = ManufacturingOrder::create([ + ...$validated, + 'tenant_id' => auth()->user()->tenant_id, + 'created_by' => auth()->id(), + ]); + + // Auto-populate components from BOM lines if bom_id provided + if ($validated['bom_id'] ?? null) { + $bom = BillOfMaterials::with('lines')->find($validated['bom_id']); + $scaleFactor = $validated['qty_to_produce'] / max($bom->qty_per_bom, 1); + + foreach ($bom->lines as $line) { + $mo->components()->create([ + 'product_id' => $line->component_id, + 'qty_required' => $line->quantity * $scaleFactor, + 'uom' => $line->uom, + ]); + } + } + + return redirect()->route('manufacturing.manufacturing-orders.index') + ->with('success', 'Manufacturing Order created successfully.'); + } + + public function show(ManufacturingOrder $manufacturingOrder): Response + { + $this->authorize('view', $manufacturingOrder); + + $manufacturingOrder->load([ + 'product', 'bom', 'components.product', + 'workOrders.workCenter', 'warehouse', 'responsible', + ]); + + return Inertia::render('Manufacturing/ManufacturingOrders/Show', [ + 'order' => $manufacturingOrder, + ]); + } + + public function edit(ManufacturingOrder $manufacturingOrder): Response + { + $this->authorize('update', $manufacturingOrder); + + $manufacturingOrder->load(['product', 'bom', 'components.product']); + + return Inertia::render('Manufacturing/ManufacturingOrders/Edit', [ + 'order' => $manufacturingOrder, + 'products' => Product::orderBy('name')->get(['id', 'name', 'sku']), + 'boms' => BillOfMaterials::where('is_active', true)->with('product')->orderBy('name')->get(['id', 'name', 'product_id', 'type']), + 'warehouses' => Warehouse::orderBy('name')->get(['id', 'name']), + 'users' => User::orderBy('name')->get(['id', 'name']), + ]); + } + + public function update(Request $request, ManufacturingOrder $manufacturingOrder): RedirectResponse + { + $this->authorize('update', $manufacturingOrder); + + $validated = $request->validate([ + 'product_id' => 'required|exists:products,id', + 'bom_id' => 'nullable|exists:bills_of_materials,id', + 'qty_to_produce' => 'required|numeric|min:0.0001', + 'scheduled_date' => 'nullable|date', + 'warehouse_id' => 'nullable|exists:warehouses,id', + 'origin' => 'nullable|string|max:255', + 'notes' => 'nullable|string', + 'responsible_id' => 'nullable|exists:users,id', + ]); + + $manufacturingOrder->update($validated); + + return redirect()->route('manufacturing.manufacturing-orders.show', $manufacturingOrder) + ->with('success', 'Manufacturing Order updated successfully.'); + } + + public function destroy(ManufacturingOrder $manufacturingOrder): RedirectResponse + { + $this->authorize('delete', $manufacturingOrder); + + $manufacturingOrder->delete(); + + return redirect()->route('manufacturing.manufacturing-orders.index') + ->with('success', 'Manufacturing Order deleted successfully.'); + } + + public function confirm(ManufacturingOrder $manufacturingOrder): RedirectResponse + { + $this->authorize('confirm', $manufacturingOrder); + + $manufacturingOrder->confirm(); + + return redirect()->back()->with('success', 'Manufacturing Order confirmed.'); + } + + public function start(ManufacturingOrder $manufacturingOrder): RedirectResponse + { + $this->authorize('startProduction', $manufacturingOrder); + + $manufacturingOrder->startProduction(); + + return redirect()->back()->with('success', 'Production started.'); + } + + public function complete(ManufacturingOrder $manufacturingOrder): RedirectResponse + { + $this->authorize('complete', $manufacturingOrder); + + $manufacturingOrder->complete(); + + return redirect()->back()->with('success', 'Manufacturing Order completed.'); + } + + public function cancel(ManufacturingOrder $manufacturingOrder): RedirectResponse + { + $this->authorize('cancel', $manufacturingOrder); + + $manufacturingOrder->cancel(); + + return redirect()->back()->with('success', 'Manufacturing Order cancelled.'); + } +} diff --git a/erp/app/Modules/Manufacturing/Http/Controllers/ManufacturingReportController.php b/erp/app/Modules/Manufacturing/Http/Controllers/ManufacturingReportController.php new file mode 100644 index 00000000000..1edee2312e3 --- /dev/null +++ b/erp/app/Modules/Manufacturing/Http/Controllers/ManufacturingReportController.php @@ -0,0 +1,64 @@ +from ?? now()->startOfMonth()->toDateString(); + $to = $request->to ?? now()->toDateString(); + + $orders = ManufacturingOrder::with('product') + ->where('status', 'done') + ->whereBetween('finish_date', [$from, $to]) + ->get(); + + $byProduct = $orders->groupBy('product_id')->map(function ($group) { + $first = $group->first(); + return [ + 'product_id' => $first->product_id, + 'product_name' => $first->product?->name ?? 'Unknown', + 'mo_count' => $group->count(), + 'total_qty' => $group->sum('qty_produced'), + 'avg_qty_per_mo' => round($group->avg('qty_produced'), 4), + ]; + })->values(); + + return Inertia::render('Manufacturing/Reports/ProductionOutput', [ + 'rows' => $byProduct, + 'totalMos' => $orders->count(), + 'totalQty' => $orders->sum('qty_produced'), + 'filters' => compact('from', 'to'), + ]); + } + + public function bomCost(Request $request): Response + { + $boms = BillOfMaterials::with(['product', 'lines.component'])->get(); + + $rows = $boms->map(function (BillOfMaterials $bom) { + $cost = $bom->lines->sum( + fn ($line) => (float) ($line->component?->cost_price ?? 0) * $line->quantity + ); + return [ + 'id' => $bom->id, + 'product_name' => $bom->product?->name ?? 'Unknown', + 'bom_name' => $bom->name, + 'component_count' => $bom->lines->count(), + 'estimated_cost' => round($cost, 4), + ]; + }); + + return Inertia::render('Manufacturing/Reports/BomCost', [ + 'rows' => $rows, + ]); + } +} diff --git a/erp/app/Modules/Manufacturing/Http/Controllers/SchedulingController.php b/erp/app/Modules/Manufacturing/Http/Controllers/SchedulingController.php new file mode 100644 index 00000000000..a31a9eda08d --- /dev/null +++ b/erp/app/Modules/Manufacturing/Http/Controllers/SchedulingController.php @@ -0,0 +1,143 @@ +start + ? \Carbon\Carbon::parse($request->start)->startOfDay() + : now()->startOfWeek(\Carbon\Carbon::MONDAY); + + $endOfWeek = $startOfWeek->copy()->endOfWeek(\Carbon\Carbon::SUNDAY)->endOfDay(); + + $schedules = ProductionSchedule::with(['workOrder.manufacturingOrder', 'workCenter']) + ->whereBetween('scheduled_start', [$startOfWeek, $endOfWeek]) + ->orWhereBetween('scheduled_end', [$startOfWeek, $endOfWeek]) + ->get() + ->groupBy('work_center_id'); + + $workCenters = WorkCenter::where('is_active', true)->orderBy('name')->get(['id', 'name']); + + return Inertia::render('Manufacturing/Scheduling/Gantt', [ + 'schedules' => $schedules, + 'workCenters' => $workCenters, + 'workOrders' => WorkOrder::with('manufacturingOrder')->get(['id', 'operation_name', 'manufacturing_order_id']), + 'weekStart' => $startOfWeek->toDateString(), + 'weekEnd' => $endOfWeek->toDateString(), + ]); + } + + public function schedule(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'work_order_id' => 'required|exists:work_orders,id', + 'work_center_id' => 'required|exists:work_centers,id', + 'scheduled_start' => 'required|date', + 'scheduled_end' => 'required|date|after:scheduled_start', + 'notes' => 'nullable|string', + ]); + + $schedule = new ProductionSchedule($validated); + + if ($schedule->overlapsWithWorkCenter()) { + abort(422, 'Work center not available at this time.'); + } + + $schedule->save(); + + return redirect()->route('manufacturing.scheduling.gantt') + ->with('success', 'Production schedule created.'); + } + + public function confirm(ProductionSchedule $schedule): RedirectResponse + { + $schedule->confirm(); + + return back()->with('success', 'Schedule confirmed.'); + } + + public function start(ProductionSchedule $schedule): RedirectResponse + { + $schedule->start(); + + return back()->with('success', 'Schedule started.'); + } + + public function complete(ProductionSchedule $schedule): RedirectResponse + { + $schedule->complete(); + + // Update linked WorkOrder actual times + $workOrder = $schedule->workOrder; + if ($workOrder) { + if (! $workOrder->actual_start) { + $workOrder->actual_start = $schedule->scheduled_start; + } + $workOrder->actual_finish = now(); + $workOrder->save(); + } + + return back()->with('success', 'Schedule completed.'); + } + + public function destroy(ProductionSchedule $schedule): RedirectResponse + { + $schedule->delete(); + + return redirect()->route('manufacturing.scheduling.gantt') + ->with('success', 'Schedule deleted.'); + } + + public function capacity(Request $request): Response + { + $capacities = WorkCenterCapacity::with('workCenter')->orderBy('work_center_id')->orderBy('day_of_week')->get(); + $workCenters = WorkCenter::where('is_active', true)->orderBy('name')->get(['id', 'name']); + + return Inertia::render('Manufacturing/Scheduling/Capacity', [ + 'capacities' => $capacities, + 'workCenters' => $workCenters, + ]); + } + + public function storeCapacity(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'work_center_id' => 'required|exists:work_centers,id', + 'day_of_week' => 'required|integer|min:0|max:6', + 'start_time' => 'required|date_format:H:i', + 'end_time' => 'required|date_format:H:i|after:start_time', + 'capacity_hours' => 'nullable|numeric|min:0', + ]); + + if (! isset($validated['capacity_hours'])) { + [$sh, $sm] = array_map('intval', explode(':', $validated['start_time'])); + [$eh, $em] = array_map('intval', explode(':', $validated['end_time'])); + $validated['capacity_hours'] = (($eh * 60 + $em) - ($sh * 60 + $sm)) / 60; + } + + WorkCenterCapacity::create($validated); + + return redirect()->route('manufacturing.scheduling.capacity') + ->with('success', 'Capacity added.'); + } + + public function destroyCapacity(WorkCenterCapacity $capacity): RedirectResponse + { + $capacity->delete(); + + return redirect()->route('manufacturing.scheduling.capacity') + ->with('success', 'Capacity removed.'); + } +} diff --git a/erp/app/Modules/Manufacturing/Http/Controllers/ScrapController.php b/erp/app/Modules/Manufacturing/Http/Controllers/ScrapController.php new file mode 100644 index 00000000000..2fa3e88851b --- /dev/null +++ b/erp/app/Modules/Manufacturing/Http/Controllers/ScrapController.php @@ -0,0 +1,56 @@ +when($request->manufacturing_order_id, fn ($q) => $q->where('manufacturing_order_id', $request->manufacturing_order_id)) + ->orderByDesc('scrapped_at') + ->paginate(25) + ->withQueryString(); + + return Inertia::render('Manufacturing/Scrap/Index', [ + 'scrapOrders' => $scrapOrders, + 'products' => Product::orderBy('name')->get(['id', 'name', 'sku']), + 'manufacturingOrders' => ManufacturingOrder::orderByDesc('created_at')->get(['id', 'mo_number']), + 'filters' => $request->only(['manufacturing_order_id']), + ]); + } + + public function store(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'product_id' => 'required|exists:products,id', + 'quantity' => 'required|numeric|min:0.0001', + 'uom' => 'nullable|string|max:50', + 'manufacturing_order_id' => 'nullable|exists:manufacturing_orders,id', + 'reason' => 'nullable|string', + ]); + + $scrap = ScrapOrder::create($validated); + $scrap->scrap(); + + return redirect()->route('manufacturing.scrap.index') + ->with('success', 'Scrap order recorded.'); + } + + public function destroy(ScrapOrder $scrapOrder): RedirectResponse + { + $scrapOrder->delete(); + + return redirect()->route('manufacturing.scrap.index') + ->with('success', 'Scrap order deleted.'); + } +} diff --git a/erp/app/Modules/Manufacturing/Http/Controllers/ShopFloorController.php b/erp/app/Modules/Manufacturing/Http/Controllers/ShopFloorController.php new file mode 100644 index 00000000000..88c8c631654 --- /dev/null +++ b/erp/app/Modules/Manufacturing/Http/Controllers/ShopFloorController.php @@ -0,0 +1,44 @@ + fn ($q) => $q->orderByDesc('scheduled_start')->limit(1), + ]) + ->where('status', 'in_progress') + ->orderByDesc('start_date') + ->get(); + + return Inertia::render('Manufacturing/ShopFloor/Index', [ + 'orders' => $orders, + ]); + } + + public function startWorkOrder(WorkOrder $workOrder): RedirectResponse + { + $workOrder->start(); + + return back()->with('success', 'Work order started.'); + } + + public function finishWorkOrder(WorkOrder $workOrder): RedirectResponse + { + $workOrder->finish(); + + return back()->with('success', 'Work order finished.'); + } +} diff --git a/erp/app/Modules/Manufacturing/Http/Controllers/WorkCenterController.php b/erp/app/Modules/Manufacturing/Http/Controllers/WorkCenterController.php new file mode 100644 index 00000000000..2dbee3d7507 --- /dev/null +++ b/erp/app/Modules/Manufacturing/Http/Controllers/WorkCenterController.php @@ -0,0 +1,109 @@ +authorize('viewAny', WorkCenter::class); + + $workCenters = WorkCenter::when( + $request->search, + fn ($q) => $q->where('name', 'like', "%{$request->search}%") + ->orWhere('code', 'like', "%{$request->search}%") + ) + ->orderBy('name') + ->paginate(25) + ->withQueryString(); + + return Inertia::render('Manufacturing/WorkCenters/Index', [ + 'workCenters' => $workCenters, + 'filters' => $request->only(['search']), + ]); + } + + public function create(): Response + { + $this->authorize('create', WorkCenter::class); + + return Inertia::render('Manufacturing/WorkCenters/Create'); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', WorkCenter::class); + + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'code' => 'nullable|string|max:100', + 'capacity' => 'nullable|numeric|min:0', + 'efficiency_factor' => 'nullable|numeric|min:0|max:999', + 'time_efficiency' => 'nullable|numeric|min:0|max:999', + 'hourly_cost' => 'nullable|numeric|min:0', + 'is_active' => 'boolean', + 'description' => 'nullable|string', + ]); + + WorkCenter::create([...$validated, 'tenant_id' => auth()->user()->tenant_id]); + + return redirect()->route('manufacturing.work-centers.index') + ->with('success', 'Work Center created successfully.'); + } + + public function show(WorkCenter $workCenter): Response + { + $this->authorize('view', $workCenter); + + return Inertia::render('Manufacturing/WorkCenters/Show', [ + 'workCenter' => $workCenter, + ]); + } + + public function edit(WorkCenter $workCenter): Response + { + $this->authorize('update', $workCenter); + + return Inertia::render('Manufacturing/WorkCenters/Edit', [ + 'workCenter' => $workCenter, + ]); + } + + public function update(Request $request, WorkCenter $workCenter): RedirectResponse + { + $this->authorize('update', $workCenter); + + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'code' => 'nullable|string|max:100', + 'capacity' => 'nullable|numeric|min:0', + 'efficiency_factor' => 'nullable|numeric|min:0|max:999', + 'time_efficiency' => 'nullable|numeric|min:0|max:999', + 'hourly_cost' => 'nullable|numeric|min:0', + 'is_active' => 'boolean', + 'description' => 'nullable|string', + ]); + + $workCenter->update($validated); + + return redirect()->route('manufacturing.work-centers.index') + ->with('success', 'Work Center updated successfully.'); + } + + public function destroy(WorkCenter $workCenter): RedirectResponse + { + $this->authorize('delete', $workCenter); + + $workCenter->delete(); + + return redirect()->route('manufacturing.work-centers.index') + ->with('success', 'Work Center deleted successfully.'); + } +} diff --git a/erp/app/Modules/Manufacturing/Http/Controllers/WorkOrderController.php b/erp/app/Modules/Manufacturing/Http/Controllers/WorkOrderController.php new file mode 100644 index 00000000000..88cec66ef6c --- /dev/null +++ b/erp/app/Modules/Manufacturing/Http/Controllers/WorkOrderController.php @@ -0,0 +1,126 @@ +authorize('viewAny', WorkOrder::class); + + $manufacturingOrder->load(['workOrders.workCenter', 'product']); + + return Inertia::render('Manufacturing/WorkOrders/Index', [ + 'order' => $manufacturingOrder, + 'workOrders' => $manufacturingOrder->workOrders, + ]); + } + + public function create(ManufacturingOrder $manufacturingOrder): Response + { + $this->authorize('create', WorkOrder::class); + + return Inertia::render('Manufacturing/WorkOrders/Create', [ + 'order' => $manufacturingOrder, + 'workCenters' => WorkCenter::where('is_active', true)->orderBy('name')->get(['id', 'name', 'code']), + ]); + } + + public function store(Request $request, ManufacturingOrder $manufacturingOrder): RedirectResponse + { + $this->authorize('create', WorkOrder::class); + + $validated = $request->validate([ + 'work_center_id' => 'nullable|exists:work_centers,id', + 'operation_name' => 'required|string|max:255', + 'sequence' => 'nullable|integer|min:1', + 'duration_expected' => 'nullable|numeric|min:0', + 'scheduled_start' => 'nullable|date', + 'notes' => 'nullable|string', + ]); + + $manufacturingOrder->workOrders()->create($validated); + + return redirect()->route('manufacturing.manufacturing-orders.work-orders.index', $manufacturingOrder) + ->with('success', 'Work Order created successfully.'); + } + + public function edit(ManufacturingOrder $manufacturingOrder, WorkOrder $workOrder): Response + { + $this->authorize('update', $workOrder); + + return Inertia::render('Manufacturing/WorkOrders/Edit', [ + 'order' => $manufacturingOrder, + 'workOrder' => $workOrder->load('workCenter'), + 'workCenters' => WorkCenter::where('is_active', true)->orderBy('name')->get(['id', 'name', 'code']), + ]); + } + + public function update(Request $request, ManufacturingOrder $manufacturingOrder, WorkOrder $workOrder): RedirectResponse + { + $this->authorize('update', $workOrder); + + $validated = $request->validate([ + 'work_center_id' => 'nullable|exists:work_centers,id', + 'operation_name' => 'required|string|max:255', + 'sequence' => 'nullable|integer|min:1', + 'duration_expected' => 'nullable|numeric|min:0', + 'scheduled_start' => 'nullable|date', + 'notes' => 'nullable|string', + ]); + + $workOrder->update($validated); + + return redirect()->route('manufacturing.manufacturing-orders.work-orders.index', $manufacturingOrder) + ->with('success', 'Work Order updated successfully.'); + } + + public function destroy(ManufacturingOrder $manufacturingOrder, WorkOrder $workOrder): RedirectResponse + { + $this->authorize('delete', $workOrder); + + $workOrder->delete(); + + return redirect()->route('manufacturing.manufacturing-orders.work-orders.index', $manufacturingOrder) + ->with('success', 'Work Order deleted successfully.'); + } + + public function start(ManufacturingOrder $manufacturingOrder, WorkOrder $workOrder): RedirectResponse + { + $this->authorize('update', $workOrder); + + $workOrder->start(); + + return redirect()->route('manufacturing.manufacturing-orders.work-orders.index', $manufacturingOrder) + ->with('success', 'Work Order started.'); + } + + public function finish(ManufacturingOrder $manufacturingOrder, WorkOrder $workOrder): RedirectResponse + { + $this->authorize('update', $workOrder); + + $workOrder->finish(); + + return redirect()->route('manufacturing.manufacturing-orders.work-orders.index', $manufacturingOrder) + ->with('success', 'Work Order finished.'); + } + + public function cancel(ManufacturingOrder $manufacturingOrder, WorkOrder $workOrder): RedirectResponse + { + $this->authorize('update', $workOrder); + + $workOrder->cancel(); + + return redirect()->route('manufacturing.manufacturing-orders.work-orders.index', $manufacturingOrder) + ->with('success', 'Work Order cancelled.'); + } +} diff --git a/erp/app/Modules/Manufacturing/Models/BillOfMaterials.php b/erp/app/Modules/Manufacturing/Models/BillOfMaterials.php new file mode 100644 index 00000000000..f089777604f --- /dev/null +++ b/erp/app/Modules/Manufacturing/Models/BillOfMaterials.php @@ -0,0 +1,60 @@ + 'float', + 'is_active' => 'boolean', + 'version' => 'integer', + ]; + + protected $attributes = [ + 'type' => 'manufacture', + 'qty_per_bom' => 1, + 'is_active' => true, + 'version' => 1, + ]; + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + public function lines(): HasMany + { + return $this->hasMany(BomLine::class, 'bom_id')->orderBy('sequence'); + } + + protected function typeLabel(): Attribute + { + return Attribute::make(get: fn () => match ($this->type) { + 'kit' => 'Kit', + 'subcontracting' => 'Subcontracting', + default => 'Manufacture', + }); + } + + protected function componentCount(): Attribute + { + return Attribute::make(get: fn () => $this->lines()->count()); + } +} diff --git a/erp/app/Modules/Manufacturing/Models/BomLine.php b/erp/app/Modules/Manufacturing/Models/BomLine.php new file mode 100644 index 00000000000..fb490fcace9 --- /dev/null +++ b/erp/app/Modules/Manufacturing/Models/BomLine.php @@ -0,0 +1,39 @@ + 'float', + 'is_optional' => 'boolean', + 'sequence' => 'integer', + ]; + + protected $attributes = [ + 'quantity' => 1, + 'sequence' => 10, + 'is_optional' => false, + ]; + + public function bom(): BelongsTo + { + return $this->belongsTo(BillOfMaterials::class, 'bom_id'); + } + + public function component(): BelongsTo + { + return $this->belongsTo(Product::class, 'component_id'); + } +} diff --git a/erp/app/Modules/Manufacturing/Models/ManufacturingOrder.php b/erp/app/Modules/Manufacturing/Models/ManufacturingOrder.php new file mode 100644 index 00000000000..a58b5623e63 --- /dev/null +++ b/erp/app/Modules/Manufacturing/Models/ManufacturingOrder.php @@ -0,0 +1,159 @@ + 'float', + 'qty_produced' => 'float', + 'scheduled_date' => 'date', + 'start_date' => 'date', + 'finish_date' => 'date', + ]; + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + public function bom(): BelongsTo + { + return $this->belongsTo(BillOfMaterials::class, 'bom_id'); + } + + public function warehouse(): BelongsTo + { + return $this->belongsTo(Warehouse::class); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function responsible(): BelongsTo + { + return $this->belongsTo(User::class, 'responsible_id'); + } + + public function components(): HasMany + { + return $this->hasMany(MoComponent::class); + } + + public function workOrders(): HasMany + { + return $this->hasMany(WorkOrder::class)->orderBy('sequence'); + } + + public function confirm(): void + { + if (is_null($this->mo_number)) { + $this->mo_number = $this->generateMoNumber(); + } + $this->status = 'confirmed'; + $this->save(); + } + + public function startProduction(): void + { + $this->status = 'in_progress'; + $this->start_date = now()->toDateString(); + $this->save(); + } + + public function complete(float $qtyProduced = 0): void + { + $this->status = 'done'; + $this->qty_produced = $qtyProduced > 0 ? $qtyProduced : $this->qty_to_produce; + $this->finish_date = now()->toDateString(); + $this->save(); + event(new \App\Events\Manufacturing\ManufacturingOrderCompleted($this)); + } + + public function cancel(): void + { + $this->status = 'cancelled'; + $this->save(); + } + + public function generateMoNumber(): string + { + return 'MO-' . date('Y') . '-' . str_pad($this->id, 5, '0', STR_PAD_LEFT); + } + + protected function isDraft(): Attribute + { + return Attribute::make(get: fn () => $this->status === 'draft'); + } + + protected function isConfirmed(): Attribute + { + return Attribute::make(get: fn () => $this->status === 'confirmed'); + } + + protected function isInProgress(): Attribute + { + return Attribute::make(get: fn () => $this->status === 'in_progress'); + } + + protected function isDone(): Attribute + { + return Attribute::make(get: fn () => $this->status === 'done'); + } + + protected function progressPercentage(): Attribute + { + return Attribute::make(get: function () { + if ($this->qty_to_produce <= 0) { + return 0; + } + return round(($this->qty_produced / $this->qty_to_produce) * 100, 2); + }); + } + + public static function fromBom(BillOfMaterials $bom, float $qty, int $tenantId): self + { + $mo = self::create([ + 'tenant_id' => $tenantId, + 'product_id' => $bom->product_id, + 'bom_id' => $bom->id, + 'qty_to_produce' => $qty, + 'status' => 'draft', + ]); + + $scaleFactor = $qty / max($bom->qty_per_bom, 1); + + foreach ($bom->lines as $line) { + $mo->components()->create([ + 'product_id' => $line->component_id, + 'qty_required' => $line->quantity * $scaleFactor, + 'uom' => $line->uom, + ]); + } + + return $mo; + } +} diff --git a/erp/app/Modules/Manufacturing/Models/MoComponent.php b/erp/app/Modules/Manufacturing/Models/MoComponent.php new file mode 100644 index 00000000000..139e4dbbee4 --- /dev/null +++ b/erp/app/Modules/Manufacturing/Models/MoComponent.php @@ -0,0 +1,41 @@ + 'float', + 'qty_consumed' => 'float', + 'is_available' => 'boolean', + ]; + + public function manufacturingOrder(): BelongsTo + { + return $this->belongsTo(ManufacturingOrder::class); + } + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + protected function remainingQty(): Attribute + { + return Attribute::make( + get: fn () => $this->qty_required - $this->qty_consumed + ); + } +} diff --git a/erp/app/Modules/Manufacturing/Models/ProductionSchedule.php b/erp/app/Modules/Manufacturing/Models/ProductionSchedule.php new file mode 100644 index 00000000000..88cdf8693ba --- /dev/null +++ b/erp/app/Modules/Manufacturing/Models/ProductionSchedule.php @@ -0,0 +1,76 @@ + 'datetime', + 'scheduled_end' => 'datetime', + ]; + + public function workOrder(): BelongsTo + { + return $this->belongsTo(WorkOrder::class); + } + + public function workCenter(): BelongsTo + { + return $this->belongsTo(WorkCenter::class); + } + + public function durationHours(): float + { + return (float) $this->scheduled_start->diffInMinutes($this->scheduled_end) / 60; + } + + public function confirm(): void + { + $this->status = 'confirmed'; + $this->save(); + } + + public function start(): void + { + $this->status = 'in_progress'; + $this->save(); + } + + public function complete(): void + { + $this->status = 'done'; + $this->save(); + } + + public function overlapsWithWorkCenter(): bool + { + return static::where('work_center_id', $this->work_center_id) + ->where('id', '!=', $this->id ?? 0) + ->where('status', '!=', 'done') + ->where(function ($query) { + $query->where(function ($q) { + $q->where('scheduled_start', '<', $this->scheduled_end) + ->where('scheduled_end', '>', $this->scheduled_start); + }); + }) + ->exists(); + } +} diff --git a/erp/app/Modules/Manufacturing/Models/ScrapOrder.php b/erp/app/Modules/Manufacturing/Models/ScrapOrder.php new file mode 100644 index 00000000000..3ba80cd4dbe --- /dev/null +++ b/erp/app/Modules/Manufacturing/Models/ScrapOrder.php @@ -0,0 +1,55 @@ + 'datetime', + 'quantity' => 'float', + ]; + + public function manufacturingOrder(): BelongsTo + { + return $this->belongsTo(ManufacturingOrder::class); + } + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + public function scrappedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'scrapped_by'); + } + + public function scrap(): void + { + $this->scrapped_at = now(); + $this->scrapped_by = auth()->id(); + $this->save(); + } +} diff --git a/erp/app/Modules/Manufacturing/Models/WorkCenter.php b/erp/app/Modules/Manufacturing/Models/WorkCenter.php new file mode 100644 index 00000000000..2f7f39d9d53 --- /dev/null +++ b/erp/app/Modules/Manufacturing/Models/WorkCenter.php @@ -0,0 +1,42 @@ + 'float', + 'efficiency_factor' => 'float', + 'time_efficiency' => 'float', + 'hourly_cost' => 'float', + 'is_active' => 'boolean', + ]; + + public function workOrders(): HasMany + { + return $this->hasMany(WorkOrder::class); + } + + protected function effectiveHourlyCost(): Attribute + { + return Attribute::make( + get: fn () => $this->hourly_cost * ($this->efficiency_factor / 100) + ); + } +} diff --git a/erp/app/Modules/Manufacturing/Models/WorkCenterCapacity.php b/erp/app/Modules/Manufacturing/Models/WorkCenterCapacity.php new file mode 100644 index 00000000000..f44b316535d --- /dev/null +++ b/erp/app/Modules/Manufacturing/Models/WorkCenterCapacity.php @@ -0,0 +1,40 @@ +belongsTo(WorkCenter::class); + } + + public function availableHours(): float + { + // Parse start and end times to calculate difference in hours + [$sh, $sm] = array_map('intval', explode(':', $this->start_time)); + [$eh, $em] = array_map('intval', explode(':', $this->end_time)); + + $startMinutes = $sh * 60 + $sm; + $endMinutes = $eh * 60 + $em; + + return (float) (($endMinutes - $startMinutes) / 60); + } +} diff --git a/erp/app/Modules/Manufacturing/Models/WorkOrder.php b/erp/app/Modules/Manufacturing/Models/WorkOrder.php new file mode 100644 index 00000000000..c5da4e19c16 --- /dev/null +++ b/erp/app/Modules/Manufacturing/Models/WorkOrder.php @@ -0,0 +1,88 @@ + 'float', + 'duration_actual' => 'float', + 'sequence' => 'integer', + 'scheduled_start' => 'datetime', + 'actual_start' => 'datetime', + 'actual_finish' => 'datetime', + ]; + + public function manufacturingOrder(): BelongsTo + { + return $this->belongsTo(ManufacturingOrder::class); + } + + public function workCenter(): BelongsTo + { + return $this->belongsTo(WorkCenter::class); + } + + public function productionSchedules(): HasMany + { + return $this->hasMany(ProductionSchedule::class); + } + + public function start(): void + { + $this->status = 'in_progress'; + $this->actual_start = now(); + $this->save(); + } + + public function finish(): void + { + $this->status = 'done'; + $this->actual_finish = now(); + if ($this->actual_start) { + $this->duration_actual = $this->actual_start->diffInMinutes($this->actual_finish); + } + $this->save(); + } + + public function cancel(): void + { + $this->status = 'cancelled'; + $this->save(); + } + + protected function durationLabel(): Attribute + { + return Attribute::make(get: function () { + $minutes = (int) $this->duration_expected; + $hours = intdiv($minutes, 60); + $mins = $minutes % 60; + return $hours > 0 ? "{$hours}h {$mins}m" : "{$mins}m"; + }); + } + + protected function isOverdue(): Attribute + { + return Attribute::make(get: function () { + if ($this->status !== 'in_progress' || ! $this->scheduled_start) { + return false; + } + $deadline = $this->scheduled_start->copy()->addMinutes((int) $this->duration_expected); + return now()->gt($deadline); + }); + } +} diff --git a/erp/app/Modules/Manufacturing/Policies/BomPolicy.php b/erp/app/Modules/Manufacturing/Policies/BomPolicy.php new file mode 100644 index 00000000000..5f170e3eca2 --- /dev/null +++ b/erp/app/Modules/Manufacturing/Policies/BomPolicy.php @@ -0,0 +1,34 @@ +can('inventory.create'); + } + + public function update(User $user, BillOfMaterials $bom): bool + { + return $user->can('inventory.create'); + } + + public function delete(User $user, BillOfMaterials $bom): bool + { + return $user->can('inventory.delete'); + } +} diff --git a/erp/app/Modules/Manufacturing/Policies/ManufacturingOrderPolicy.php b/erp/app/Modules/Manufacturing/Policies/ManufacturingOrderPolicy.php new file mode 100644 index 00000000000..72a85f6b810 --- /dev/null +++ b/erp/app/Modules/Manufacturing/Policies/ManufacturingOrderPolicy.php @@ -0,0 +1,54 @@ +can('inventory.create'); + } + + public function update(User $user, ManufacturingOrder $mo): bool + { + return $user->can('inventory.create'); + } + + public function delete(User $user, ManufacturingOrder $mo): bool + { + return $user->can('inventory.delete'); + } + + public function confirm(User $user, ManufacturingOrder $mo): bool + { + return $user->can('inventory.create'); + } + + public function startProduction(User $user, ManufacturingOrder $mo): bool + { + return $user->can('inventory.create'); + } + + public function complete(User $user, ManufacturingOrder $mo): bool + { + return $user->can('inventory.create'); + } + + public function cancel(User $user, ManufacturingOrder $mo): bool + { + return $user->can('inventory.create'); + } +} diff --git a/erp/app/Modules/Manufacturing/Policies/WorkCenterPolicy.php b/erp/app/Modules/Manufacturing/Policies/WorkCenterPolicy.php new file mode 100644 index 00000000000..50dde95292f --- /dev/null +++ b/erp/app/Modules/Manufacturing/Policies/WorkCenterPolicy.php @@ -0,0 +1,34 @@ +can('inventory.create'); + } + + public function update(User $user, WorkCenter $workCenter): bool + { + return $user->can('inventory.create'); + } + + public function delete(User $user, WorkCenter $workCenter): bool + { + return $user->can('inventory.delete'); + } +} diff --git a/erp/app/Modules/Manufacturing/Policies/WorkOrderPolicy.php b/erp/app/Modules/Manufacturing/Policies/WorkOrderPolicy.php new file mode 100644 index 00000000000..a7b55ccd06b --- /dev/null +++ b/erp/app/Modules/Manufacturing/Policies/WorkOrderPolicy.php @@ -0,0 +1,34 @@ +can('inventory.create'); + } + + public function update(User $user, WorkOrder $workOrder): bool + { + return $user->can('inventory.create'); + } + + public function delete(User $user, WorkOrder $workOrder): bool + { + return $user->can('inventory.delete'); + } +} diff --git a/erp/app/Modules/Manufacturing/Providers/ManufacturingServiceProvider.php b/erp/app/Modules/Manufacturing/Providers/ManufacturingServiceProvider.php new file mode 100644 index 00000000000..6df539ea053 --- /dev/null +++ b/erp/app/Modules/Manufacturing/Providers/ManufacturingServiceProvider.php @@ -0,0 +1,33 @@ +loadRoutesFrom(__DIR__ . '/../routes/manufacturing.php'); + + Gate::policy(BillOfMaterials::class, BomPolicy::class); + Gate::policy(BomLine::class, BomPolicy::class); + Gate::policy(WorkCenter::class, WorkCenterPolicy::class); + Gate::policy(ManufacturingOrder::class, ManufacturingOrderPolicy::class); + Gate::policy(MoComponent::class, ManufacturingOrderPolicy::class); + Gate::policy(WorkOrder::class, WorkOrderPolicy::class); + } +} diff --git a/erp/app/Modules/Manufacturing/routes/manufacturing.php b/erp/app/Modules/Manufacturing/routes/manufacturing.php new file mode 100644 index 00000000000..039d62210cf --- /dev/null +++ b/erp/app/Modules/Manufacturing/routes/manufacturing.php @@ -0,0 +1,61 @@ +prefix('manufacturing')->name('manufacturing.')->group(function () { + // Dashboard + Route::get('dashboard', [ManufacturingDashboardController::class, 'index'])->name('dashboard'); + + // Bills of Materials + Route::resource('boms', BomController::class); + + // Work Centers + Route::resource('work-centers', WorkCenterController::class); + + // Manufacturing Orders — action routes BEFORE resource + Route::post('manufacturing-orders/{manufacturing_order}/confirm', [ManufacturingOrderController::class, 'confirm'])->name('manufacturing-orders.confirm'); + Route::post('manufacturing-orders/{manufacturing_order}/start', [ManufacturingOrderController::class, 'start'])->name('manufacturing-orders.start'); + Route::post('manufacturing-orders/{manufacturing_order}/complete', [ManufacturingOrderController::class, 'complete'])->name('manufacturing-orders.complete'); + Route::post('manufacturing-orders/{manufacturing_order}/cancel', [ManufacturingOrderController::class, 'cancel'])->name('manufacturing-orders.cancel'); + Route::resource('manufacturing-orders', ManufacturingOrderController::class); + + // Work Orders (nested) — action routes BEFORE resource + Route::post('manufacturing-orders/{manufacturing_order}/work-orders/{work_order}/start', [WorkOrderController::class, 'start'])->name('manufacturing-orders.work-orders.start'); + Route::post('manufacturing-orders/{manufacturing_order}/work-orders/{work_order}/finish', [WorkOrderController::class, 'finish'])->name('manufacturing-orders.work-orders.finish'); + Route::post('manufacturing-orders/{manufacturing_order}/work-orders/{work_order}/cancel', [WorkOrderController::class, 'cancel'])->name('manufacturing-orders.work-orders.cancel'); + Route::resource('manufacturing-orders.work-orders', WorkOrderController::class)->except(['show']); + + // Reports + Route::get('reports/production-output', [ManufacturingReportController::class, 'productionOutput'])->name('reports.production-output'); + Route::get('reports/bom-cost', [ManufacturingReportController::class, 'bomCost'])->name('reports.bom-cost'); + + // Scheduling + Route::get('scheduling/gantt', [SchedulingController::class, 'gantt'])->name('scheduling.gantt'); + Route::get('scheduling/capacity', [SchedulingController::class, 'capacity'])->name('scheduling.capacity'); + Route::post('scheduling', [SchedulingController::class, 'schedule'])->name('scheduling.store'); + Route::post('scheduling/{schedule}/confirm', [SchedulingController::class, 'confirm'])->name('scheduling.confirm'); + Route::post('scheduling/{schedule}/start', [SchedulingController::class, 'start'])->name('scheduling.start'); + Route::post('scheduling/{schedule}/complete', [SchedulingController::class, 'complete'])->name('scheduling.complete'); + Route::delete('scheduling/{schedule}', [SchedulingController::class, 'destroy'])->name('scheduling.destroy'); + Route::post('capacity', [SchedulingController::class, 'storeCapacity'])->name('capacity.store'); + Route::delete('capacity/{capacity}', [SchedulingController::class, 'destroyCapacity'])->name('capacity.destroy'); + + // Scrap + Route::get('scrap', [ScrapController::class, 'index'])->name('scrap.index'); + Route::post('scrap', [ScrapController::class, 'store'])->name('scrap.store'); + Route::delete('scrap/{scrapOrder}', [ScrapController::class, 'destroy'])->name('scrap.destroy'); + + // Shop Floor + Route::get('shop-floor', [ShopFloorController::class, 'index'])->name('shop-floor.index'); + Route::post('shop-floor/work-orders/{workOrder}/start', [ShopFloorController::class, 'startWorkOrder'])->name('shop-floor.work-orders.start'); + Route::post('shop-floor/work-orders/{workOrder}/finish', [ShopFloorController::class, 'finishWorkOrder'])->name('shop-floor.work-orders.finish'); +}); diff --git a/erp/app/Modules/Marketing/Http/Controllers/EmailCampaignController.php b/erp/app/Modules/Marketing/Http/Controllers/EmailCampaignController.php new file mode 100644 index 00000000000..ffc4ef3b0ca --- /dev/null +++ b/erp/app/Modules/Marketing/Http/Controllers/EmailCampaignController.php @@ -0,0 +1,152 @@ +orderByDesc('created_at') + ->get() + ->map(fn ($c) => [ + 'id' => $c->id, + 'name' => $c->name, + 'subject' => $c->subject, + 'status' => $c->status, + 'list_name' => $c->mailingList?->name, + 'total_recipients' => $c->total_recipients, + 'open_rate' => $c->openRate(), + 'click_rate' => $c->clickRate(), + 'sent_at' => $c->sent_at?->toDateTimeString(), + ]); + + return Inertia::render('Marketing/Campaigns/Index', [ + 'campaigns' => $campaigns, + ]); + } + + public function create(): Response + { + $mailingLists = MailingList::where('is_active', true) + ->orderBy('name') + ->get(['id', 'name']); + + return Inertia::render('Marketing/Campaigns/Create', [ + 'mailingLists' => $mailingLists, + ]); + } + + public function store(Request $request): RedirectResponse + { + $data = $request->validate([ + 'name' => 'required|string|max:255', + 'subject' => 'required|string|max:255', + 'preview_text' => 'nullable|string|max:255', + 'body_html' => 'required|string', + 'body_text' => 'nullable|string', + 'from_name' => 'nullable|string|max:255', + 'from_email' => 'nullable|email', + 'mailing_list_id' => 'nullable|exists:mailing_lists,id', + 'scheduled_at' => 'nullable|date', + ]); + + $data['tenant_id'] = auth()->user()->tenant_id; + $data['created_by'] = auth()->id(); + + $campaign = EmailCampaign::create($data); + + return redirect()->route('marketing.campaigns.show', $campaign) + ->with('success', 'Campaign created.'); + } + + public function show(EmailCampaign $campaign): Response + { + $sends = $campaign->sends() + ->with('subscriber') + ->orderByDesc('created_at') + ->limit(50) + ->get(); + + return Inertia::render('Marketing/Campaigns/Show', [ + 'campaign' => array_merge($campaign->toArray(), [ + 'open_rate' => $campaign->openRate(), + 'click_rate' => $campaign->clickRate(), + 'list_name' => $campaign->mailingList?->name, + ]), + 'sends' => $sends, + ]); + } + + public function edit(EmailCampaign $campaign): Response + { + $mailingLists = MailingList::where('is_active', true) + ->orderBy('name') + ->get(['id', 'name']); + + return Inertia::render('Marketing/Campaigns/Edit', [ + 'campaign' => $campaign, + 'mailingLists' => $mailingLists, + ]); + } + + public function update(Request $request, EmailCampaign $campaign): RedirectResponse + { + if ($campaign->status !== 'draft') { + return redirect()->back()->with('error', 'Only draft campaigns can be edited.'); + } + + $data = $request->validate([ + 'name' => 'required|string|max:255', + 'subject' => 'required|string|max:255', + 'preview_text' => 'nullable|string|max:255', + 'body_html' => 'required|string', + 'body_text' => 'nullable|string', + 'from_name' => 'nullable|string|max:255', + 'from_email' => 'nullable|email', + 'mailing_list_id' => 'nullable|exists:mailing_lists,id', + 'scheduled_at' => 'nullable|date', + ]); + + $campaign->update($data); + + return redirect()->route('marketing.campaigns.show', $campaign) + ->with('success', 'Campaign updated.'); + } + + public function destroy(EmailCampaign $campaign): RedirectResponse + { + if (!in_array($campaign->status, ['draft', 'cancelled'])) { + return redirect()->back()->with('error', 'Only draft or cancelled campaigns can be deleted.'); + } + + $campaign->delete(); + + return redirect()->route('marketing.campaigns.index') + ->with('success', 'Campaign deleted.'); + } + + public function send(EmailCampaign $campaign): RedirectResponse + { + $campaign->send(); + + return redirect()->route('marketing.campaigns.show', $campaign) + ->with('success', 'Campaign sent successfully.'); + } + + public function cancel(EmailCampaign $campaign): RedirectResponse + { + $campaign->cancel(); + + return redirect()->route('marketing.campaigns.show', $campaign) + ->with('success', 'Campaign cancelled.'); + } +} diff --git a/erp/app/Modules/Marketing/Http/Controllers/MailingListController.php b/erp/app/Modules/Marketing/Http/Controllers/MailingListController.php new file mode 100644 index 00000000000..30924af0eac --- /dev/null +++ b/erp/app/Modules/Marketing/Http/Controllers/MailingListController.php @@ -0,0 +1,117 @@ +orderByDesc('created_at') + ->get(); + + return Inertia::render('Marketing/MailingLists/Index', [ + 'lists' => $lists, + ]); + } + + public function create(): Response + { + return Inertia::render('Marketing/MailingLists/Create'); + } + + public function store(Request $request): RedirectResponse + { + $data = $request->validate([ + 'name' => 'required|string|max:255', + 'description' => 'nullable|string', + 'is_active' => 'boolean', + ]); + + $data['tenant_id'] = auth()->user()->tenant_id; + + MailingList::create($data); + + return redirect()->route('marketing.mailing-lists.index') + ->with('success', 'Mailing list created.'); + } + + public function show(MailingList $mailingList): Response + { + $subscribers = $mailingList->subscribers() + ->orderByDesc('mailing_list_subscriber.mailing_list_id') + ->paginate(25); + + return Inertia::render('Marketing/MailingLists/Show', [ + 'list' => $mailingList, + 'subscribers' => $subscribers, + ]); + } + + public function edit(MailingList $mailingList): Response + { + return Inertia::render('Marketing/MailingLists/Edit', [ + 'list' => $mailingList, + ]); + } + + public function update(Request $request, MailingList $mailingList): RedirectResponse + { + $data = $request->validate([ + 'name' => 'required|string|max:255', + 'description' => 'nullable|string', + 'is_active' => 'boolean', + ]); + + $mailingList->update($data); + + return redirect()->route('marketing.mailing-lists.index') + ->with('success', 'Mailing list updated.'); + } + + public function destroy(MailingList $mailingList): RedirectResponse + { + $mailingList->delete(); + + return redirect()->route('marketing.mailing-lists.index') + ->with('success', 'Mailing list deleted.'); + } + + public function addSubscriber(Request $request, MailingList $mailingList): RedirectResponse + { + $data = $request->validate([ + 'email' => 'required|email', + 'name' => 'nullable|string|max:255', + ]); + + $tenantId = auth()->user()->tenant_id; + + $subscriber = Subscriber::firstOrCreate( + ['tenant_id' => $tenantId, 'email' => $data['email']], + [ + 'name' => $data['name'] ?? null, + 'status' => 'subscribed', + 'subscribed_at' => now(), + ] + ); + + $mailingList->subscribers()->syncWithoutDetaching([$subscriber->id]); + + return redirect()->back()->with('success', 'Subscriber added.'); + } + + public function removeSubscriber(MailingList $mailingList, Subscriber $subscriber): RedirectResponse + { + $mailingList->subscribers()->detach($subscriber->id); + + return redirect()->back()->with('success', 'Subscriber removed.'); + } +} diff --git a/erp/app/Modules/Marketing/Http/Controllers/MarketingAnalyticsController.php b/erp/app/Modules/Marketing/Http/Controllers/MarketingAnalyticsController.php new file mode 100644 index 00000000000..a96eb2861e8 --- /dev/null +++ b/erp/app/Modules/Marketing/Http/Controllers/MarketingAnalyticsController.php @@ -0,0 +1,105 @@ +id; + $campaigns = EmailCampaign::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->withCount(['events as sent_count' => fn ($q) => $q->where('event_type', 'sent')]) + ->withCount(['events as open_count' => fn ($q) => $q->where('event_type', 'opened')]) + ->withCount(['events as click_count' => fn ($q) => $q->where('event_type', 'clicked')]) + ->orderByDesc('created_at') + ->get() + ->map(fn ($c) => [ + 'id' => $c->id, + 'name' => $c->name, + 'status' => $c->status ?? 'draft', + 'sent' => $c->sent_count, + 'opens' => $c->open_count, + 'clicks' => $c->click_count, + 'open_rate' => $c->sent_count > 0 ? round($c->open_count / $c->sent_count * 100, 1) : 0, + 'click_rate' => $c->sent_count > 0 ? round($c->click_count / $c->sent_count * 100, 1) : 0, + ]); + + return Inertia::render('Marketing/Analytics/Index', ['campaigns' => $campaigns]); + } + + public function campaignStats(EmailCampaign $campaign): JsonResponse + { + $tenantId = app('tenant')->id; + $events = CampaignEvent::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where('campaign_id', $campaign->id) + ->selectRaw('event_type, COUNT(*) as count') + ->groupBy('event_type') + ->pluck('count', 'event_type'); + + $sent = (int) ($events['sent'] ?? 0); + return response()->json([ + 'campaign_id' => $campaign->id, + 'stats' => [ + 'sent' => $sent, + 'opened' => (int) ($events['opened'] ?? 0), + 'clicked' => (int) ($events['clicked'] ?? 0), + 'bounced' => (int) ($events['bounced'] ?? 0), + 'unsubscribed' => (int) ($events['unsubscribed'] ?? 0), + 'open_rate' => $sent > 0 ? round(($events['opened'] ?? 0) / $sent * 100, 1) : 0, + 'click_rate' => $sent > 0 ? round(($events['clicked'] ?? 0) / $sent * 100, 1) : 0, + ], + ]); + } + + public function trackEvent(Request $request): JsonResponse + { + $validated = $request->validate([ + 'campaign_id' => 'required|exists:email_campaigns,id', + 'subscriber_email' => 'required|email', + 'event_type' => 'required|in:sent,opened,clicked,bounced,unsubscribed,complained', + 'metadata' => 'nullable|array', + ]); + + $event = CampaignEvent::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'occurred_at' => now(), + ] + $validated); + + return response()->json(['success' => true, 'event_id' => $event->id]); + } + + public function storeAbVariant(Request $request, EmailCampaign $campaign): JsonResponse + { + $validated = $request->validate([ + 'name' => 'required|string|max:100', + 'subject_line' => 'nullable|string|max:255', + 'preview_text' => 'nullable|string|max:255', + 'send_percentage' => 'required|integer|min:1|max:100', + ]); + + $variant = AbTestVariant::create([ + 'tenant_id' => app('tenant')->id, + 'campaign_id' => $campaign->id, + ] + $validated); + + return response()->json(['success' => true, 'variant' => $variant]); + } + + public function declareWinner(EmailCampaign $campaign, AbTestVariant $variant): JsonResponse + { + $variant->declareWinner(); + + return response()->json(['success' => true]); + } +} diff --git a/erp/app/Modules/Marketing/Http/Controllers/MarketingDashboardController.php b/erp/app/Modules/Marketing/Http/Controllers/MarketingDashboardController.php new file mode 100644 index 00000000000..af80c33000b --- /dev/null +++ b/erp/app/Modules/Marketing/Http/Controllers/MarketingDashboardController.php @@ -0,0 +1,64 @@ +count(); + $totalCampaigns = EmailCampaign::count(); + + $campaignsSentThisMonth = EmailCampaign::where('status', 'sent') + ->whereYear('sent_at', now()->year) + ->whereMonth('sent_at', now()->month) + ->count(); + + $sentCampaigns = EmailCampaign::where('status', 'sent') + ->where('sent_count', '>', 0) + ->get(); + + $avgOpenRate = $sentCampaigns->count() > 0 + ? round($sentCampaigns->avg(fn ($c) => $c->openRate()), 1) + : 0; + + $avgClickRate = $sentCampaigns->count() > 0 + ? round($sentCampaigns->avg(fn ($c) => $c->clickRate()), 1) + : 0; + + $recentCampaigns = EmailCampaign::with('mailingList') + ->orderByDesc('created_at') + ->limit(5) + ->get() + ->map(fn ($c) => [ + 'id' => $c->id, + 'name' => $c->name, + 'subject' => $c->subject, + 'status' => $c->status, + 'list_name' => $c->mailingList?->name, + 'total_recipients' => $c->total_recipients, + 'open_rate' => $c->openRate(), + 'click_rate' => $c->clickRate(), + 'sent_at' => $c->sent_at?->toDateTimeString(), + ]); + + return Inertia::render('Marketing/Dashboard', [ + 'stats' => [ + 'totalSubscribers' => $totalSubscribers, + 'activeSubscribers' => $activeSubscribers, + 'totalCampaigns' => $totalCampaigns, + 'campaignsSentThisMonth' => $campaignsSentThisMonth, + 'avgOpenRate' => $avgOpenRate, + 'avgClickRate' => $avgClickRate, + ], + 'recentCampaigns' => $recentCampaigns, + ]); + } +} diff --git a/erp/app/Modules/Marketing/Http/Controllers/SubscriberController.php b/erp/app/Modules/Marketing/Http/Controllers/SubscriberController.php new file mode 100644 index 00000000000..148e9b34b4a --- /dev/null +++ b/erp/app/Modules/Marketing/Http/Controllers/SubscriberController.php @@ -0,0 +1,118 @@ +orderByDesc('created_at'); + + if ($request->filled('status')) { + $query->where('status', $request->input('status')); + } + + if ($request->filled('search')) { + $search = $request->input('search'); + $query->where(function ($q) use ($search) { + $q->where('email', 'like', "%{$search}%") + ->orWhere('name', 'like', "%{$search}%"); + }); + } + + $subscribers = $query->paginate(25)->withQueryString(); + + return Inertia::render('Marketing/Subscribers/Index', [ + 'subscribers' => $subscribers, + 'filters' => $request->only(['status', 'search']), + ]); + } + + public function store(Request $request): RedirectResponse + { + $data = $request->validate([ + 'email' => 'required|email', + 'name' => 'nullable|string|max:255', + ]); + + $tenantId = auth()->user()->tenant_id; + + $subscriber = Subscriber::firstOrCreate( + ['tenant_id' => $tenantId, 'email' => $data['email']], + ['name' => $data['name'] ?? null] + ); + + $subscriber->subscribe(); + + return redirect()->back()->with('success', 'Subscriber added.'); + } + + public function unsubscribe(Subscriber $subscriber): RedirectResponse + { + $subscriber->unsubscribe(); + + return redirect()->back()->with('success', 'Subscriber unsubscribed.'); + } + + public function destroy(Subscriber $subscriber): RedirectResponse + { + $subscriber->delete(); + + return redirect()->back()->with('success', 'Subscriber deleted.'); + } + + public function import(Request $request): RedirectResponse + { + $request->validate([ + 'file' => 'required|file|mimes:csv,txt', + ]); + + $tenantId = auth()->user()->tenant_id; + $file = $request->file('file'); + $handle = fopen($file->getPathname(), 'r'); + + $count = 0; + $headers = null; + + while (($row = fgetcsv($handle)) !== false) { + if ($headers === null) { + $headers = array_map('strtolower', array_map('trim', $row)); + continue; + } + + $data = array_combine($headers, $row); + + $email = trim($data['email'] ?? ''); + if (!$email || !filter_var($email, FILTER_VALIDATE_EMAIL)) { + continue; + } + + $subscriber = Subscriber::firstOrCreate( + ['tenant_id' => $tenantId, 'email' => $email], + [ + 'name' => trim($data['name'] ?? ''), + 'status' => 'subscribed', + 'subscribed_at' => now(), + ] + ); + + if (!$subscriber->wasRecentlyCreated) { + $subscriber->subscribe(); + } + + $count++; + } + + fclose($handle); + + return redirect()->back()->with('success', "Imported {$count} subscribers."); + } +} diff --git a/erp/app/Modules/Marketing/Models/AbTestVariant.php b/erp/app/Modules/Marketing/Models/AbTestVariant.php new file mode 100644 index 00000000000..370f82cdc2c --- /dev/null +++ b/erp/app/Modules/Marketing/Models/AbTestVariant.php @@ -0,0 +1,61 @@ + 'boolean', + 'opens' => 'integer', + 'clicks' => 'integer', + 'sent' => 'integer', + 'send_percentage' => 'integer', + ]; + + public function campaign(): BelongsTo + { + return $this->belongsTo(EmailCampaign::class, 'campaign_id'); + } + + public function openRate(): float + { + return $this->sent > 0 ? round($this->opens / $this->sent * 100, 2) : 0; + } + + public function clickRate(): float + { + return $this->sent > 0 ? round($this->clicks / $this->sent * 100, 2) : 0; + } + + public function declareWinner(): void + { + // Update other variants of same campaign to is_winner = false + static::where('campaign_id', $this->campaign_id) + ->where('id', '!=', $this->id) + ->update(['is_winner' => false]); + + $this->is_winner = true; + $this->save(); + } +} diff --git a/erp/app/Modules/Marketing/Models/CampaignEvent.php b/erp/app/Modules/Marketing/Models/CampaignEvent.php new file mode 100644 index 00000000000..ab6d2fa4144 --- /dev/null +++ b/erp/app/Modules/Marketing/Models/CampaignEvent.php @@ -0,0 +1,40 @@ + 'array', + 'occurred_at' => 'datetime', + ]; + + public function campaign(): BelongsTo + { + return $this->belongsTo(EmailCampaign::class, 'campaign_id'); + } + + public function scopeByType(Builder $query, string $type): Builder + { + return $query->where('event_type', $type); + } +} diff --git a/erp/app/Modules/Marketing/Models/CampaignSend.php b/erp/app/Modules/Marketing/Models/CampaignSend.php new file mode 100644 index 00000000000..fb10abcabf2 --- /dev/null +++ b/erp/app/Modules/Marketing/Models/CampaignSend.php @@ -0,0 +1,54 @@ + 'datetime', + 'opened_at' => 'datetime', + 'clicked_at' => 'datetime', + ]; + + public function campaign(): BelongsTo + { + return $this->belongsTo(EmailCampaign::class, 'campaign_id'); + } + + public function subscriber(): BelongsTo + { + return $this->belongsTo(Subscriber::class, 'subscriber_id'); + } + + public function markOpened(): void + { + if (!in_array($this->status, ['opened', 'clicked'])) { + $this->status = 'opened'; + $this->opened_at = now(); + $this->save(); + $this->campaign()->increment('open_count'); + } + } + + public function markClicked(): void + { + $this->status = 'clicked'; + $this->clicked_at = now(); + $this->save(); + $this->campaign()->increment('click_count'); + } +} diff --git a/erp/app/Modules/Marketing/Models/EmailCampaign.php b/erp/app/Modules/Marketing/Models/EmailCampaign.php new file mode 100644 index 00000000000..9ed43632d4b --- /dev/null +++ b/erp/app/Modules/Marketing/Models/EmailCampaign.php @@ -0,0 +1,127 @@ + 'datetime', + 'sent_at' => 'datetime', + 'total_recipients' => 'integer', + 'sent_count' => 'integer', + 'open_count' => 'integer', + 'click_count' => 'integer', + 'bounce_count' => 'integer', + 'unsubscribe_count' => 'integer', + ]; + + public function mailingList(): BelongsTo + { + return $this->belongsTo(MailingList::class, 'mailing_list_id'); + } + + public function sends(): HasMany + { + return $this->hasMany(CampaignSend::class, 'campaign_id'); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function events(): HasMany + { + return $this->hasMany(CampaignEvent::class, 'campaign_id'); + } + + public function abVariants(): HasMany + { + return $this->hasMany(AbTestVariant::class, 'campaign_id'); + } + + public function totalSent(): int + { + return $this->events()->where('event_type', 'sent')->count(); + } + + public function openRate(): float + { + return $this->sent_count > 0 + ? round($this->open_count / $this->sent_count * 100, 1) + : 0; + } + + public function clickRate(): float + { + return $this->sent_count > 0 + ? round($this->click_count / $this->sent_count * 100, 1) + : 0; + } + + public function send(): void + { + $this->status = 'sending'; + $this->sent_at = now(); + $this->save(); + + $subscribers = collect(); + if ($this->mailing_list_id) { + $subscribers = $this->mailingList + ->subscribers() + ->where('status', 'subscribed') + ->get(); + } + + foreach ($subscribers as $subscriber) { + CampaignSend::firstOrCreate( + ['campaign_id' => $this->id, 'subscriber_id' => $subscriber->id], + ['status' => 'sent', 'sent_at' => now()] + ); + } + + $count = $this->sends()->count(); + $this->total_recipients = $count; + $this->sent_count = $count; + $this->status = 'sent'; + $this->save(); + } + + public function cancel(): void + { + $this->status = 'cancelled'; + $this->save(); + } +} diff --git a/erp/app/Modules/Marketing/Models/MailingList.php b/erp/app/Modules/Marketing/Models/MailingList.php new file mode 100644 index 00000000000..f2887485e11 --- /dev/null +++ b/erp/app/Modules/Marketing/Models/MailingList.php @@ -0,0 +1,36 @@ + 'boolean', + ]; + + public function subscribers(): BelongsToMany + { + return $this->belongsToMany(Subscriber::class, 'mailing_list_subscriber'); + } + + public function campaigns(): HasMany + { + return $this->hasMany(EmailCampaign::class, 'mailing_list_id'); + } +} diff --git a/erp/app/Modules/Marketing/Models/Subscriber.php b/erp/app/Modules/Marketing/Models/Subscriber.php new file mode 100644 index 00000000000..a7cac5c9c95 --- /dev/null +++ b/erp/app/Modules/Marketing/Models/Subscriber.php @@ -0,0 +1,60 @@ + 'datetime', + 'unsubscribed_at' => 'datetime', + ]; + + public function mailingLists(): BelongsToMany + { + return $this->belongsToMany(MailingList::class, 'mailing_list_subscriber'); + } + + public function sends(): HasMany + { + return $this->hasMany(CampaignSend::class, 'subscriber_id'); + } + + public function subscribe(): void + { + $this->status = 'subscribed'; + $this->subscribed_at = now(); + $this->unsubscribed_at = null; + $this->save(); + } + + public function unsubscribe(): void + { + $this->status = 'unsubscribed'; + $this->unsubscribed_at = now(); + $this->save(); + } + + public function isSubscribed(): bool + { + return $this->status === 'subscribed'; + } +} diff --git a/erp/app/Modules/Marketing/Policies/MarketingPolicy.php b/erp/app/Modules/Marketing/Policies/MarketingPolicy.php new file mode 100644 index 00000000000..ab3c4d216f3 --- /dev/null +++ b/erp/app/Modules/Marketing/Policies/MarketingPolicy.php @@ -0,0 +1,33 @@ +can('finance.create'); + } + + public function update(User $user): bool + { + return $user->can('finance.create'); + } + + public function delete(User $user): bool + { + return $user->can('finance.delete'); + } +} diff --git a/erp/app/Modules/Marketing/Providers/MarketingServiceProvider.php b/erp/app/Modules/Marketing/Providers/MarketingServiceProvider.php new file mode 100644 index 00000000000..f73f4a64f5c --- /dev/null +++ b/erp/app/Modules/Marketing/Providers/MarketingServiceProvider.php @@ -0,0 +1,25 @@ +loadRoutesFrom(__DIR__ . '/../routes/marketing.php'); + Gate::policy(MailingList::class, MarketingPolicy::class); + Gate::policy(Subscriber::class, MarketingPolicy::class); + Gate::policy(EmailCampaign::class, MarketingPolicy::class); + Gate::policy(CampaignSend::class, MarketingPolicy::class); + } +} diff --git a/erp/app/Modules/Marketing/routes/marketing.php b/erp/app/Modules/Marketing/routes/marketing.php new file mode 100644 index 00000000000..029e4daa0f0 --- /dev/null +++ b/erp/app/Modules/Marketing/routes/marketing.php @@ -0,0 +1,31 @@ +prefix('marketing')->name('marketing.')->group(function () { + Route::get('dashboard', [MarketingDashboardController::class, 'index'])->name('dashboard'); + + Route::post('mailing-lists/{mailing_list}/add-subscriber', [MailingListController::class, 'addSubscriber'])->name('mailing-lists.add-subscriber'); + Route::delete('mailing-lists/{mailing_list}/subscribers/{subscriber}', [MailingListController::class, 'removeSubscriber'])->name('mailing-lists.remove-subscriber'); + Route::resource('mailing-lists', MailingListController::class); + + Route::post('subscribers/{subscriber}/unsubscribe', [SubscriberController::class, 'unsubscribe'])->name('subscribers.unsubscribe'); + Route::post('subscribers/import', [SubscriberController::class, 'import'])->name('subscribers.import'); + Route::resource('subscribers', SubscriberController::class)->only(['index', 'store', 'destroy']); + + Route::post('campaigns/{campaign}/send', [EmailCampaignController::class, 'send'])->name('campaigns.send'); + Route::post('campaigns/{campaign}/cancel', [EmailCampaignController::class, 'cancel'])->name('campaigns.cancel'); + Route::resource('campaigns', EmailCampaignController::class); + + // Analytics + Route::get('analytics', [MarketingAnalyticsController::class, 'index'])->name('analytics'); + Route::get('analytics/{campaign}/stats', [MarketingAnalyticsController::class, 'campaignStats'])->name('analytics.campaign'); + Route::post('analytics/track', [MarketingAnalyticsController::class, 'trackEvent'])->name('analytics.track'); + Route::post('campaigns/{campaign}/ab-variants', [MarketingAnalyticsController::class, 'storeAbVariant'])->name('campaigns.ab-variants.store'); + Route::post('campaigns/{campaign}/ab-variants/{variant}/winner', [MarketingAnalyticsController::class, 'declareWinner'])->name('campaigns.ab-variants.winner'); +}); diff --git a/erp/app/Modules/PM/Http/Controllers/GanttController.php b/erp/app/Modules/PM/Http/Controllers/GanttController.php new file mode 100644 index 00000000000..cf0cc4f39f1 --- /dev/null +++ b/erp/app/Modules/PM/Http/Controllers/GanttController.php @@ -0,0 +1,46 @@ +load(['tasks' => function ($q) { + $q->select('id', 'title', 'status', 'assignee_id', 'sprint_id', 'start_date', 'due_date', 'estimated_hours', 'story_points', 'parent_task_id') + ->with('dependencies'); + }, 'milestones']); + + return Inertia::render('PM/Gantt', ['project' => $project]); + } + + public function data(Project $project): JsonResponse + { + $tasks = $project->tasks() + ->select('id', 'title', 'status', 'start_date', 'due_date', 'estimated_hours', 'story_points', 'parent_task_id', 'sprint_id') + ->with('dependencies:id,task_id,depends_on_id,dependency_type') + ->get() + ->map(fn ($t) => [ + 'id' => $t->id, + 'title' => $t->title, + 'status' => $t->status, + 'start' => $t->start_date?->toDateString(), + 'end' => $t->due_date?->toDateString(), + 'hours' => $t->estimated_hours, + 'points' => $t->story_points, + 'parent' => $t->parent_task_id, + 'dependencies' => $t->dependencies->map(fn ($d) => [ + 'task_id' => $d->depends_on_id, + 'type' => $d->dependency_type, + ]), + ]); + + return response()->json(['tasks' => $tasks, 'milestones' => $project->milestones]); + } +} diff --git a/erp/app/Modules/PM/Http/Controllers/MilestoneController.php b/erp/app/Modules/PM/Http/Controllers/MilestoneController.php new file mode 100644 index 00000000000..aa62c3ac42b --- /dev/null +++ b/erp/app/Modules/PM/Http/Controllers/MilestoneController.php @@ -0,0 +1,43 @@ +validate([ + 'name' => 'required|string|max:255', + 'description' => 'nullable|string', + 'due_date' => 'nullable|date', + ]); + + $project->milestones()->create([ + ...$validated, + 'tenant_id' => auth()->user()->tenant_id, + 'created_by' => auth()->id(), + ]); + + return redirect()->back()->with('success', 'Milestone created successfully.'); + } + + public function complete(Project $project, Milestone $milestone): RedirectResponse + { + $milestone->complete(); + + return redirect()->back()->with('success', 'Milestone completed.'); + } + + public function destroy(Project $project, Milestone $milestone): RedirectResponse + { + $milestone->delete(); + + return redirect()->back()->with('success', 'Milestone deleted.'); + } +} diff --git a/erp/app/Modules/PM/Http/Controllers/PMDashboardController.php b/erp/app/Modules/PM/Http/Controllers/PMDashboardController.php new file mode 100644 index 00000000000..cc7424a56ab --- /dev/null +++ b/erp/app/Modules/PM/Http/Controllers/PMDashboardController.php @@ -0,0 +1,47 @@ +with('manager')->get(); + + $stats = [ + 'total_projects' => $projects->count(), + 'draft_projects' => $projects->where('status', 'draft')->count(), + 'active_projects' => $projects->where('status', 'active')->count(), + 'on_hold_projects'=> $projects->where('status', 'on_hold')->count(), + 'completed_projects' => $projects->where('status', 'completed')->count(), + 'cancelled_projects' => $projects->where('status', 'cancelled')->count(), + 'total_tasks' => Task::count(), + 'overdue_tasks' => Task::whereNotNull('due_date') + ->where('due_date', '<', now()->toDateString()) + ->whereNotIn('status', ['done', 'cancelled']) + ->count(), + 'hours_this_month' => TimeEntry::whereYear('date', now()->year) + ->whereMonth('date', now()->month) + ->sum('hours'), + ]; + + $recentProjects = Project::with('manager') + ->withCount('tasks') + ->orderByDesc('created_at') + ->limit(10) + ->get(); + + return Inertia::render('PM/Dashboard', [ + 'stats' => $stats, + 'recentProjects' => $recentProjects, + ]); + } +} diff --git a/erp/app/Modules/PM/Http/Controllers/ProjectController.php b/erp/app/Modules/PM/Http/Controllers/ProjectController.php new file mode 100644 index 00000000000..9f7e1504295 --- /dev/null +++ b/erp/app/Modules/PM/Http/Controllers/ProjectController.php @@ -0,0 +1,137 @@ +authorize('viewAny', Project::class); + + $projects = Project::with(['manager']) + ->withCount(['tasks', 'members']) + ->when($request->search, fn ($q) => $q->where(function ($q) use ($request) { + $q->where('name', 'like', "%{$request->search}%") + ->orWhere('code', 'like', "%{$request->search}%"); + })) + ->when($request->status, fn ($q) => $q->where('status', $request->status)) + ->orderByDesc('created_at') + ->paginate(20) + ->withQueryString(); + + return Inertia::render('PM/Projects/Index', [ + 'projects' => $projects, + 'filters' => $request->only(['search', 'status']), + ]); + } + + public function create(): Response + { + $this->authorize('create', Project::class); + + return Inertia::render('PM/Projects/Create', [ + 'users' => User::orderBy('name')->get(['id', 'name']), + ]); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', Project::class); + + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'code' => 'nullable|string|max:100', + 'description' => 'nullable|string', + 'status' => 'nullable|in:draft,active,on_hold,completed,cancelled', + 'priority' => 'nullable|in:low,medium,high,critical', + 'budget' => 'nullable|numeric|min:0', + 'start_date' => 'nullable|date', + 'end_date' => 'nullable|date|after_or_equal:start_date', + 'client_name' => 'nullable|string|max:255', + 'manager_id' => 'nullable|exists:users,id', + ]); + + $project = Project::create([ + ...$validated, + 'tenant_id' => auth()->user()->tenant_id, + 'created_by' => auth()->id(), + ]); + + // Generate code if not provided + if (empty($project->code)) { + $project->code = $project->generateCode(); + $project->save(); + } + + return redirect()->route('pm.projects.show', $project) + ->with('success', 'Project created successfully.'); + } + + public function show(Project $project): Response + { + $this->authorize('view', $project); + + $project->load([ + 'manager', + 'tasks.assignee', + 'milestones', + 'members', + 'timeEntries.user', + ]); + + return Inertia::render('PM/Projects/Show', [ + 'project' => $project, + ]); + } + + public function edit(Project $project): Response + { + $this->authorize('update', $project); + + return Inertia::render('PM/Projects/Edit', [ + 'project' => $project, + 'users' => User::orderBy('name')->get(['id', 'name']), + ]); + } + + public function update(Request $request, Project $project): RedirectResponse + { + $this->authorize('update', $project); + + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'code' => 'nullable|string|max:100', + 'description' => 'nullable|string', + 'status' => 'nullable|in:draft,active,on_hold,completed,cancelled', + 'priority' => 'nullable|in:low,medium,high,critical', + 'budget' => 'nullable|numeric|min:0', + 'start_date' => 'nullable|date', + 'end_date' => 'nullable|date', + 'client_name' => 'nullable|string|max:255', + 'manager_id' => 'nullable|exists:users,id', + ]); + + $project->update($validated); + + return redirect()->route('pm.projects.show', $project) + ->with('success', 'Project updated successfully.'); + } + + public function destroy(Project $project): RedirectResponse + { + $this->authorize('delete', $project); + + $project->delete(); + + return redirect()->route('pm.projects.index') + ->with('success', 'Project deleted successfully.'); + } +} diff --git a/erp/app/Modules/PM/Http/Controllers/SprintController.php b/erp/app/Modules/PM/Http/Controllers/SprintController.php new file mode 100644 index 00000000000..3dfb78436c4 --- /dev/null +++ b/erp/app/Modules/PM/Http/Controllers/SprintController.php @@ -0,0 +1,53 @@ +load('sprints.tasks'); + return Inertia::render('PM/Sprints/Index', [ + 'project' => $project, + 'sprints' => $project->sprints, + ]); + } + + public function store(Request $request, Project $project): RedirectResponse + { + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'goal' => 'nullable|string', + 'start_date' => 'nullable|date', + 'end_date' => 'nullable|date|after_or_equal:start_date', + ]); + + $project->sprints()->create(['tenant_id' => app('tenant')->id] + $validated); + + return redirect()->back()->with('success', 'Sprint created.'); + } + + public function activate(Project $project, ProjectSprint $sprint): RedirectResponse + { + // Deactivate any currently active sprint first + $project->sprints()->where('status', 'active')->update(['status' => 'planning']); + $sprint->activate(); + + return redirect()->back()->with('success', 'Sprint activated.'); + } + + public function complete(Project $project, ProjectSprint $sprint): RedirectResponse + { + $sprint->complete(); + + return redirect()->back()->with('success', 'Sprint completed.'); + } +} diff --git a/erp/app/Modules/PM/Http/Controllers/TaskController.php b/erp/app/Modules/PM/Http/Controllers/TaskController.php new file mode 100644 index 00000000000..9416e5b2a38 --- /dev/null +++ b/erp/app/Modules/PM/Http/Controllers/TaskController.php @@ -0,0 +1,172 @@ +tasks() + ->with('assignee') + ->orderBy('sequence') + ->orderByDesc('created_at') + ->get(); + + return Inertia::render('PM/Tasks/Index', [ + 'project' => $project, + 'tasks' => $tasks, + ]); + } + + public function create(Project $project): Response + { + return Inertia::render('PM/Tasks/Create', [ + 'project' => $project, + 'users' => User::orderBy('name')->get(['id', 'name']), + ]); + } + + public function store(Request $request, Project $project): RedirectResponse + { + $validated = $request->validate([ + 'title' => 'required|string|max:255', + 'description' => 'nullable|string', + 'status' => 'nullable|in:todo,in_progress,review,done,cancelled', + 'priority' => 'nullable|in:low,medium,high,urgent', + 'assignee_id' => 'nullable|exists:users,id', + 'due_date' => 'nullable|date', + 'estimated_hours' => 'nullable|numeric|min:0', + ]); + + $project->tasks()->create([ + ...$validated, + 'tenant_id' => auth()->user()->tenant_id, + 'created_by' => auth()->id(), + ]); + + return redirect()->route('pm.projects.show', $project) + ->with('success', 'Task created successfully.'); + } + + public function show(Project $project, Task $task): Response + { + $task->load(['assignee', 'creator', 'timeEntries.user']); + + return Inertia::render('PM/Tasks/Show', [ + 'project' => $project, + 'task' => $task, + 'users' => User::orderBy('name')->get(['id', 'name']), + ]); + } + + public function edit(Project $project, Task $task): Response + { + return Inertia::render('PM/Tasks/Edit', [ + 'project' => $project, + 'task' => $task, + 'users' => User::orderBy('name')->get(['id', 'name']), + ]); + } + + public function update(Request $request, Project $project, Task $task): RedirectResponse + { + $validated = $request->validate([ + 'title' => 'required|string|max:255', + 'description' => 'nullable|string', + 'status' => 'nullable|in:todo,in_progress,review,done,cancelled', + 'priority' => 'nullable|in:low,medium,high,urgent', + 'assignee_id' => 'nullable|exists:users,id', + 'due_date' => 'nullable|date', + 'estimated_hours' => 'nullable|numeric|min:0', + ]); + + $task->update($validated); + + return redirect()->route('pm.projects.tasks.show', [$project, $task]) + ->with('success', 'Task updated successfully.'); + } + + public function destroy(Project $project, Task $task): RedirectResponse + { + $task->delete(); + + return redirect()->route('pm.projects.show', $project) + ->with('success', 'Task deleted successfully.'); + } + + public function complete(Project $project, Task $task): RedirectResponse + { + $task->complete(); + + return redirect()->back()->with('success', 'Task marked as done.'); + } + + public function kanban(Project $project): Response + { + $tasks = $project->tasks() + ->with('assignee') + ->orderBy('sequence') + ->get() + ->groupBy('status'); + + $columns = ['todo', 'in_progress', 'review', 'done', 'cancelled']; + $grouped = []; + foreach ($columns as $col) { + $grouped[$col] = $tasks->get($col, collect())->map(fn ($t) => [ + 'id' => $t->id, + 'title' => $t->title, + 'priority' => $t->priority, + 'due_date' => $t->due_date?->toDateString(), + 'assignee' => $t->assignee ? ['name' => $t->assignee->name] : null, + 'is_overdue' => $t->isOverdue(), + ])->values(); + } + + return Inertia::render('PM/Tasks/Kanban', [ + 'project' => ['id' => $project->id, 'name' => $project->name], + 'columns' => $grouped, + ]); + } + + public function moveStatus(Request $request, Project $project, Task $task): \Illuminate\Http\JsonResponse + { + $data = $request->validate(['status' => 'required|in:todo,in_progress,review,done,cancelled']); + $task->update(['status' => $data['status']]); + return response()->json(['ok' => true]); + } + + public function calendar(Request $request, Project $project): Response + { + $year = (int) ($request->year ?? now()->year); + $month = (int) ($request->month ?? now()->month); + + $tasks = $project->tasks() + ->whereNotNull('due_date') + ->whereYear('due_date', $year) + ->whereMonth('due_date', $month) + ->get(['id', 'title', 'status', 'priority', 'due_date']) + ->map(fn ($t) => [ + 'id' => $t->id, + 'title' => $t->title, + 'status' => $t->status, + 'priority' => $t->priority, + 'due_date' => $t->due_date->toDateString(), + ]); + + return Inertia::render('PM/Tasks/Calendar', [ + 'project' => ['id' => $project->id, 'name' => $project->name], + 'tasks' => $tasks, + 'year' => $year, + 'month' => $month, + ]); + } +} diff --git a/erp/app/Modules/PM/Http/Controllers/TimeEntryController.php b/erp/app/Modules/PM/Http/Controllers/TimeEntryController.php new file mode 100644 index 00000000000..5bc4f2d2fe5 --- /dev/null +++ b/erp/app/Modules/PM/Http/Controllers/TimeEntryController.php @@ -0,0 +1,54 @@ +where('user_id', auth()->id()) + ->orderByDesc('date') + ->orderByDesc('created_at') + ->paginate(25) + ->withQueryString(); + + return Inertia::render('PM/TimeEntries/Index', [ + 'entries' => $entries, + ]); + } + + public function store(Request $request, Task $task): RedirectResponse + { + $validated = $request->validate([ + 'hours' => 'required|numeric|min:0.01', + 'description' => 'nullable|string', + 'date' => 'required|date', + 'is_billable' => 'nullable|boolean', + ]); + + $task->timeEntries()->create([ + ...$validated, + 'tenant_id' => auth()->user()->tenant_id, + 'user_id' => auth()->id(), + 'created_by' => auth()->id(), + ]); + + return redirect()->back()->with('success', 'Time entry logged successfully.'); + } + + public function destroy(TimeEntry $entry): RedirectResponse + { + $entry->delete(); + + return redirect()->back()->with('success', 'Time entry deleted.'); + } +} diff --git a/erp/app/Modules/PM/Models/Milestone.php b/erp/app/Modules/PM/Models/Milestone.php new file mode 100644 index 00000000000..170408f2bf0 --- /dev/null +++ b/erp/app/Modules/PM/Models/Milestone.php @@ -0,0 +1,43 @@ + 'date', + 'completed_at' => 'datetime', + 'is_completed' => 'boolean', + ]; + + public function project(): BelongsTo + { + return $this->belongsTo(Project::class); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function complete(): void + { + $this->is_completed = true; + $this->completed_at = now(); + $this->save(); + } +} diff --git a/erp/app/Modules/PM/Models/Project.php b/erp/app/Modules/PM/Models/Project.php new file mode 100644 index 00000000000..7cf60bc2c45 --- /dev/null +++ b/erp/app/Modules/PM/Models/Project.php @@ -0,0 +1,95 @@ + 'date', + 'end_date' => 'date', + 'budget' => 'float', + 'spent_budget' => 'float', + ]; + + public function manager(): BelongsTo + { + return $this->belongsTo(User::class, 'manager_id'); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function members(): BelongsToMany + { + return $this->belongsToMany(User::class, 'project_members') + ->withPivot('role') + ->withTimestamps(); + } + + public function tasks(): HasMany + { + return $this->hasMany(Task::class); + } + + public function milestones(): HasMany + { + return $this->hasMany(Milestone::class); + } + + public function sprints(): HasMany + { + return $this->hasMany(ProjectSprint::class); + } + + public function activeSprint(): ?ProjectSprint + { + return $this->sprints()->where('status', 'active')->first(); + } + + public function timeEntries(): HasManyThrough + { + return $this->hasManyThrough(TimeEntry::class, Task::class); + } + + public function generateCode(): string + { + return 'PM-' . date('Y') . '-' . str_pad($this->id, 5, '0', STR_PAD_LEFT); + } + + public function totalHours(): float + { + return (float) $this->timeEntries()->sum('hours'); + } + + public function progressPercentage(): float + { + $total = $this->tasks()->count(); + if ($total === 0) { + return 0.0; + } + $done = $this->tasks()->where('status', 'done')->count(); + return round(($done / $total) * 100, 2); + } +} diff --git a/erp/app/Modules/PM/Models/ProjectSprint.php b/erp/app/Modules/PM/Models/ProjectSprint.php new file mode 100644 index 00000000000..4c80d561425 --- /dev/null +++ b/erp/app/Modules/PM/Models/ProjectSprint.php @@ -0,0 +1,70 @@ + 'date', + 'end_date' => 'date', + 'velocity' => 'integer', + ]; + + public function project(): BelongsTo + { + return $this->belongsTo(Project::class); + } + + public function tasks(): HasMany + { + return $this->hasMany(Task::class, 'sprint_id'); + } + + public function activate(): void + { + $this->status = 'active'; + $this->save(); + } + + public function complete(): void + { + $this->status = 'completed'; + $this->velocity = (int) $this->tasks()->where('status', 'done')->sum('story_points'); + $this->save(); + } + + public function isActive(): bool + { + return $this->status === 'active'; + } + + public function taskCount(): int + { + return $this->tasks()->count(); + } + + public function completedTaskCount(): int + { + return $this->tasks()->where('status', 'done')->count(); + } +} diff --git a/erp/app/Modules/PM/Models/Task.php b/erp/app/Modules/PM/Models/Task.php new file mode 100644 index 00000000000..b0b49e65e25 --- /dev/null +++ b/erp/app/Modules/PM/Models/Task.php @@ -0,0 +1,92 @@ + 'date', + 'due_date' => 'date', + 'estimated_hours' => 'float', + 'actual_hours' => 'float', + 'story_points' => 'integer', + ]; + + public function project(): BelongsTo + { + return $this->belongsTo(Project::class); + } + + public function assignee(): BelongsTo + { + return $this->belongsTo(User::class, 'assignee_id'); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function timeEntries(): HasMany + { + return $this->hasMany(TimeEntry::class); + } + + public function sprint(): BelongsTo + { + return $this->belongsTo(ProjectSprint::class, 'sprint_id'); + } + + public function dependencies(): HasMany + { + return $this->hasMany(TaskDependency::class, 'task_id'); + } + + public function blockedBy(): HasMany + { + return $this->hasMany(TaskDependency::class, 'depends_on_id'); + } + + public function addDependency(Task $depends_on, string $type = 'finish_to_start'): TaskDependency + { + return TaskDependency::create([ + 'tenant_id' => $this->tenant_id, + 'task_id' => $this->id, + 'depends_on_id' => $depends_on->id, + 'dependency_type' => $type, + ]); + } + + public function complete(): void + { + $this->status = 'done'; + $this->save(); + } + + public function isOverdue(): bool + { + if (is_null($this->due_date)) { + return false; + } + return $this->due_date->lt(now()->startOfDay()) + && !in_array($this->status, ['done', 'cancelled']); + } +} diff --git a/erp/app/Modules/PM/Models/TaskDependency.php b/erp/app/Modules/PM/Models/TaskDependency.php new file mode 100644 index 00000000000..23123636918 --- /dev/null +++ b/erp/app/Modules/PM/Models/TaskDependency.php @@ -0,0 +1,35 @@ + 'string', + ]; + + public function task(): BelongsTo + { + return $this->belongsTo(Task::class, 'task_id'); + } + + public function dependsOn(): BelongsTo + { + return $this->belongsTo(Task::class, 'depends_on_id'); + } +} diff --git a/erp/app/Modules/PM/Models/TimeEntry.php b/erp/app/Modules/PM/Models/TimeEntry.php new file mode 100644 index 00000000000..02fee45c347 --- /dev/null +++ b/erp/app/Modules/PM/Models/TimeEntry.php @@ -0,0 +1,41 @@ + 'date', + 'hours' => 'float', + 'is_billable' => 'boolean', + ]; + + public function task(): BelongsTo + { + return $this->belongsTo(Task::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } +} diff --git a/erp/app/Modules/PM/Policies/ProjectPolicy.php b/erp/app/Modules/PM/Policies/ProjectPolicy.php new file mode 100644 index 00000000000..7650e95c36b --- /dev/null +++ b/erp/app/Modules/PM/Policies/ProjectPolicy.php @@ -0,0 +1,33 @@ +can('hr.create'); + } + + public function update(User $user): bool + { + return $user->can('hr.create'); + } + + public function delete(User $user): bool + { + return $user->can('hr.delete'); + } +} diff --git a/erp/app/Modules/PM/Providers/PMServiceProvider.php b/erp/app/Modules/PM/Providers/PMServiceProvider.php new file mode 100644 index 00000000000..b3daadd5915 --- /dev/null +++ b/erp/app/Modules/PM/Providers/PMServiceProvider.php @@ -0,0 +1,26 @@ +loadRoutesFrom(__DIR__ . '/../routes/pm.php'); + + Gate::policy(Project::class, ProjectPolicy::class); + Gate::policy(Task::class, ProjectPolicy::class); + Gate::policy(Milestone::class, ProjectPolicy::class); + Gate::policy(TimeEntry::class, ProjectPolicy::class); + } +} diff --git a/erp/app/Modules/PM/routes/pm.php b/erp/app/Modules/PM/routes/pm.php new file mode 100644 index 00000000000..eda8422f939 --- /dev/null +++ b/erp/app/Modules/PM/routes/pm.php @@ -0,0 +1,52 @@ +prefix('pm')->name('pm.')->group(function () { + Route::get('dashboard', [PMDashboardController::class, 'index'])->name('dashboard'); + + // Task complete action BEFORE resource + Route::post('projects/{project}/tasks/{task}/complete', [TaskController::class, 'complete'])->name('tasks.complete'); + Route::get('projects/{project}/tasks/kanban', [TaskController::class, 'kanban'])->name('projects.tasks.kanban'); + Route::patch('projects/{project}/tasks/{task}/move-status', [TaskController::class, 'moveStatus'])->name('projects.tasks.move-status'); + Route::get('projects/{project}/tasks/calendar', [TaskController::class, 'calendar'])->name('projects.tasks.calendar'); + + // Kanban and Calendar views (must be before resource routes) + Route::get('projects/{project}/tasks/kanban', [TaskController::class, 'kanban'])->name('projects.tasks.kanban'); + Route::patch('projects/{project}/tasks/{task}/move-status', [TaskController::class, 'moveStatus'])->name('projects.tasks.move-status'); + Route::get('projects/{project}/tasks/calendar', [TaskController::class, 'calendar'])->name('projects.tasks.calendar'); + + // Milestone actions + Route::post('projects/{project}/milestones', [MilestoneController::class, 'store'])->name('milestones.store'); + Route::post('projects/{project}/milestones/{milestone}/complete', [MilestoneController::class, 'complete'])->name('milestones.complete'); + Route::delete('projects/{project}/milestones/{milestone}', [MilestoneController::class, 'destroy'])->name('milestones.destroy'); + + // Time entries + Route::post('tasks/{task}/time-entries', [TimeEntryController::class, 'store'])->name('time-entries.store'); + Route::delete('time-entries/{entry}', [TimeEntryController::class, 'destroy'])->name('time-entries.destroy'); + Route::get('time-entries', [TimeEntryController::class, 'index'])->name('time-entries.index'); + + // Nested task routes + Route::resource('projects.tasks', TaskController::class)->except(['index']); + Route::get('projects/{project}/tasks', [TaskController::class, 'index'])->name('projects.tasks.index'); + + // Project resource + Route::resource('projects', ProjectController::class); + + // Sprints + Route::get('projects/{project}/sprints', [SprintController::class, 'index'])->name('projects.sprints.index'); + Route::post('projects/{project}/sprints', [SprintController::class, 'store'])->name('projects.sprints.store'); + Route::post('projects/{project}/sprints/{sprint}/activate', [SprintController::class, 'activate'])->name('projects.sprints.activate'); + Route::post('projects/{project}/sprints/{sprint}/complete', [SprintController::class, 'complete'])->name('projects.sprints.complete'); + + // Gantt + Route::get('projects/{project}/gantt', [GanttController::class, 'show'])->name('projects.gantt'); + Route::get('projects/{project}/gantt/data', [GanttController::class, 'data'])->name('projects.gantt.data'); +}); diff --git a/erp/app/Modules/POS/Http/Controllers/PosDashboardController.php b/erp/app/Modules/POS/Http/Controllers/PosDashboardController.php new file mode 100644 index 00000000000..46ab7f9d9f0 --- /dev/null +++ b/erp/app/Modules/POS/Http/Controllers/PosDashboardController.php @@ -0,0 +1,53 @@ +count(); + + $todaySales = PosOrder::whereDate('created_at', today()) + ->where('status', 'completed') + ->sum('total'); + + $todayOrderCount = PosOrder::whereDate('created_at', today()) + ->where('status', 'completed') + ->count(); + + $avgOrderValue = $todayOrderCount > 0 + ? round($todaySales / $todayOrderCount, 2) + : 0; + + $recentOrders = PosOrder::with(['session', 'servedBy']) + ->latest() + ->take(10) + ->get() + ->map(fn ($o) => [ + 'id' => $o->id, + 'receipt_number' => $o->receipt_number, + 'customer_name' => $o->customer_name, + 'total' => $o->total, + 'payment_method' => $o->payment_method, + 'status' => $o->status, + 'created_at' => $o->created_at, + ]); + + return Inertia::render('POS/Dashboard', [ + 'stats' => [ + 'open_sessions' => $openSessions, + 'today_sales' => $todaySales, + 'today_orders' => $todayOrderCount, + 'avg_order_value' => $avgOrderValue, + ], + 'recentOrders' => $recentOrders, + ]); + } +} diff --git a/erp/app/Modules/POS/Http/Controllers/PosOrderController.php b/erp/app/Modules/POS/Http/Controllers/PosOrderController.php new file mode 100644 index 00000000000..4160e286b83 --- /dev/null +++ b/erp/app/Modules/POS/Http/Controllers/PosOrderController.php @@ -0,0 +1,199 @@ +when($request->status, fn ($q) => $q->where('status', $request->status)) + ->when($request->session_id, fn ($q) => $q->where('session_id', $request->session_id)) + ->latest() + ->paginate(25) + ->through(fn ($o) => [ + 'id' => $o->id, + 'receipt_number' => $o->receipt_number, + 'customer_name' => $o->customer_name, + 'session' => $o->session ? ['id' => $o->session->id, 'name' => $o->session->name] : null, + 'total' => $o->total, + 'payment_method' => $o->payment_method, + 'status' => $o->status, + 'created_at' => $o->created_at, + ]); + + return Inertia::render('POS/Orders/Index', [ + 'orders' => $orders, + 'filters' => $request->only(['status', 'session_id']), + ]); + } + + public function store(Request $request): Response + { + $data = $request->validate([ + 'session_id' => 'required|exists:pos_sessions,id', + 'customer_name' => 'nullable|string|max:255', + 'customer_email' => 'nullable|email|max:255', + 'discount_amount' => 'nullable|numeric|min:0', + 'tax_amount' => 'nullable|numeric|min:0', + 'amount_paid' => 'required|numeric|min:0', + 'payment_method' => 'required|in:cash,card,digital_wallet,split', + 'notes' => 'nullable|string', + 'items' => 'required|array|min:1', + 'items.*.product_id' => 'nullable|exists:products,id', + 'items.*.product_name' => 'required|string', + 'items.*.product_sku' => 'nullable|string', + 'items.*.quantity' => 'required|numeric|min:0.001', + 'items.*.unit_price' => 'required|numeric|min:0', + 'items.*.discount_percent' => 'nullable|numeric|min:0|max:100', + 'items.*.line_total' => 'required|numeric|min:0', + ]); + + $subtotal = collect($data['items'])->sum('line_total'); + $discountAmount = $data['discount_amount'] ?? 0; + $taxAmount = $data['tax_amount'] ?? 0; + $total = $subtotal - $discountAmount + $taxAmount; + $amountPaid = $data['amount_paid']; + $changeGiven = max(0, $amountPaid - $total); + + $order = PosOrder::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'session_id' => $data['session_id'], + 'customer_name' => $data['customer_name'] ?? null, + 'customer_email' => $data['customer_email'] ?? null, + 'subtotal' => $subtotal, + 'discount_amount' => $discountAmount, + 'tax_amount' => $taxAmount, + 'total' => $total, + 'amount_paid' => $amountPaid, + 'change_given' => $changeGiven, + 'payment_method' => $data['payment_method'], + 'status' => 'completed', + 'notes' => $data['notes'] ?? null, + 'served_by' => auth()->id(), + 'created_by' => auth()->id(), + ]); + + $order->receipt_number = $order->generateReceiptNumber(); + $order->save(); + + foreach ($data['items'] as $item) { + PosOrderItem::create([ + 'order_id' => $order->id, + 'product_id' => $item['product_id'] ?? null, + 'product_name' => $item['product_name'], + 'product_sku' => $item['product_sku'] ?? null, + 'quantity' => $item['quantity'], + 'unit_price' => $item['unit_price'], + 'discount_percent' => $item['discount_percent'] ?? 0, + 'line_total' => $item['line_total'], + ]); + } + + // Update session total_sales + $session = PosSession::find($data['session_id']); + if ($session) { + $session->total_sales += $total; + $session->save(); + } + + $order->load('items', 'session', 'servedBy'); + + return Inertia::render('POS/Orders/Receipt', [ + 'order' => [ + 'id' => $order->id, + 'receipt_number' => $order->receipt_number, + 'customer_name' => $order->customer_name, + 'customer_email' => $order->customer_email, + 'subtotal' => $order->subtotal, + 'discount_amount'=> $order->discount_amount, + 'tax_amount' => $order->tax_amount, + 'total' => $order->total, + 'amount_paid' => $order->amount_paid, + 'change_given' => $order->change_given, + 'payment_method' => $order->payment_method, + 'status' => $order->status, + 'created_at' => $order->created_at, + 'session' => $order->session ? ['id' => $order->session->id, 'name' => $order->session->name] : null, + 'items' => $order->items->map(fn ($i) => [ + 'id' => $i->id, + 'product_name' => $i->product_name, + 'product_sku' => $i->product_sku, + 'quantity' => $i->quantity, + 'unit_price' => $i->unit_price, + 'discount_percent' => $i->discount_percent, + 'line_total' => $i->line_total, + ]), + ], + ]); + } + + public function show(PosOrder $order): Response + { + $order->load(['items', 'session', 'servedBy']); + + return Inertia::render('POS/Orders/Receipt', [ + 'order' => [ + 'id' => $order->id, + 'receipt_number' => $order->receipt_number, + 'customer_name' => $order->customer_name, + 'customer_email' => $order->customer_email, + 'subtotal' => $order->subtotal, + 'discount_amount'=> $order->discount_amount, + 'tax_amount' => $order->tax_amount, + 'total' => $order->total, + 'amount_paid' => $order->amount_paid, + 'change_given' => $order->change_given, + 'payment_method' => $order->payment_method, + 'status' => $order->status, + 'created_at' => $order->created_at, + 'session' => $order->session ? ['id' => $order->session->id, 'name' => $order->session->name] : null, + 'items' => $order->items->map(fn ($i) => [ + 'id' => $i->id, + 'product_name' => $i->product_name, + 'product_sku' => $i->product_sku, + 'quantity' => $i->quantity, + 'unit_price' => $i->unit_price, + 'discount_percent' => $i->discount_percent, + 'line_total' => $i->line_total, + ]), + ], + ]); + } + + public function refund(PosOrder $order): \Illuminate\Http\RedirectResponse + { + $order->status = 'refunded'; + $order->save(); + + $session = $order->session; + if ($session) { + $session->total_refunds += $order->total; + $session->save(); + } + + return redirect()->back()->with('success', 'Order refunded successfully.'); + } + + public function pdf(PosOrder $order): HttpResponse + { + $order->load(['items', 'servedBy', 'session.warehouse']); + $session = $order->session; + $pdf = Pdf::loadView('pdf.receipt', compact('order', 'session')); + $pdf->setPaper([0, 0, 226.77, 600], 'portrait'); // 80mm width + return response($pdf->output(), 200, [ + 'Content-Type' => 'application/pdf', + 'Content-Disposition' => 'inline; filename="receipt-' . $order->receipt_number . '.pdf"', + ]); + } +} diff --git a/erp/app/Modules/POS/Http/Controllers/PosSessionController.php b/erp/app/Modules/POS/Http/Controllers/PosSessionController.php new file mode 100644 index 00000000000..7e4fad125db --- /dev/null +++ b/erp/app/Modules/POS/Http/Controllers/PosSessionController.php @@ -0,0 +1,164 @@ +latest() + ->paginate(20) + ->through(fn ($s) => [ + 'id' => $s->id, + 'name' => $s->name, + 'status' => $s->status, + 'warehouse' => $s->warehouse ? ['id' => $s->warehouse->id, 'name' => $s->warehouse->name] : null, + 'opened_by' => $s->openedBy ? ['id' => $s->openedBy->id, 'name' => $s->openedBy->name] : null, + 'closed_by' => $s->closedBy ? ['id' => $s->closedBy->id, 'name' => $s->closedBy->name] : null, + 'opened_at' => $s->opened_at, + 'closed_at' => $s->closed_at, + 'total_sales' => $s->total_sales, + ]); + + return Inertia::render('POS/Sessions/Index', [ + 'sessions' => $sessions, + ]); + } + + public function create(): Response + { + return Inertia::render('POS/Sessions/Create', [ + 'warehouses' => Warehouse::orderBy('name')->get(['id', 'name']), + ]); + } + + public function store(Request $request): RedirectResponse + { + $data = $request->validate([ + 'name' => 'nullable|string|max:255', + 'warehouse_id' => 'nullable|exists:warehouses,id', + 'opening_cash' => 'nullable|numeric|min:0', + ]); + + $session = PosSession::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'name' => $data['name'] ?? 'Register', + 'warehouse_id' => $data['warehouse_id'] ?? null, + 'opened_by' => auth()->id(), + 'status' => 'open', + 'opened_at' => now(), + 'opening_cash' => $data['opening_cash'] ?? 0, + ]); + + $session->name = $session->generateName(); + $session->save(); + + return redirect()->route('pos.sessions.show', $session); + } + + public function show(PosSession $session): Response + { + $session->load(['openedBy', 'closedBy', 'warehouse']); + + $orders = $session->orders() + ->with('servedBy') + ->latest() + ->take(20) + ->get() + ->map(fn ($o) => [ + 'id' => $o->id, + 'receipt_number' => $o->receipt_number, + 'customer_name' => $o->customer_name, + 'total' => $o->total, + 'payment_method' => $o->payment_method, + 'status' => $o->status, + 'created_at' => $o->created_at, + ]); + + $products = Product::where('is_active', true) + ->get(['id', 'name', 'sku', 'sale_price']); + + return Inertia::render('POS/Sessions/Show', [ + 'session' => [ + 'id' => $session->id, + 'name' => $session->name, + 'status' => $session->status, + 'opened_at' => $session->opened_at, + 'opening_cash' => $session->opening_cash, + 'total_sales' => $session->total_sales, + 'warehouse' => $session->warehouse ? ['id' => $session->warehouse->id, 'name' => $session->warehouse->name] : null, + 'opened_by' => $session->openedBy ? ['id' => $session->openedBy->id, 'name' => $session->openedBy->name] : null, + ], + 'products' => $products, + 'recentOrders' => $orders, + ]); + } + + public function close(Request $request, PosSession $session): RedirectResponse + { + $data = $request->validate([ + 'closing_cash' => 'required|numeric|min:0', + 'notes' => 'nullable|string', + ]); + + $session->closed_by = auth()->id(); + $session->save(); + + $session->close((float) $data['closing_cash'], $data['notes'] ?? ''); + + return redirect()->route('pos.sessions.index') + ->with('success', 'Session closed successfully.'); + } + + public function zReport(PosSession $session): Response + { + $session->load(['openedBy', 'closedBy', 'warehouse']); + + $orders = $session->orders() + ->where('status', 'completed') + ->with('payments') + ->get(); + + $paymentBreakdown = $session->orders() + ->where('status', 'completed') + ->selectRaw('payment_method, COUNT(*) as count, SUM(total) as total') + ->groupBy('payment_method') + ->get() + ->map(fn ($r) => [ + 'method' => $r->payment_method, + 'count' => $r->count, + 'total' => $r->total, + ]); + + return Inertia::render('POS/Sessions/ZReport', [ + 'session' => [ + 'id' => $session->id, + 'name' => $session->name, + 'status' => $session->status, + 'opened_at' => $session->opened_at, + 'closed_at' => $session->closed_at, + 'opening_cash' => $session->opening_cash, + 'closing_cash' => $session->closing_cash, + 'expected_cash' => $session->expected_cash, + 'total_sales' => $session->total_sales, + 'total_refunds' => $session->total_refunds, + 'notes' => $session->notes, + 'opened_by' => $session->openedBy ? ['name' => $session->openedBy->name] : null, + 'closed_by' => $session->closedBy ? ['name' => $session->closedBy->name] : null, + 'warehouse' => $session->warehouse ? ['name' => $session->warehouse->name] : null, + ], + 'orderCount' => $orders->count(), + 'paymentBreakdown' => $paymentBreakdown, + ]); + } +} diff --git a/erp/app/Modules/POS/Http/Controllers/PosTerminalController.php b/erp/app/Modules/POS/Http/Controllers/PosTerminalController.php new file mode 100644 index 00000000000..536f338b90e --- /dev/null +++ b/erp/app/Modules/POS/Http/Controllers/PosTerminalController.php @@ -0,0 +1,141 @@ +where('opened_by', auth()->id()) + ->latest() + ->first(); + + return Inertia::render('POS/Terminal', [ + 'session' => $session, + ]); + } + + public function products(Request $request): JsonResponse + { + $products = Product::where('tenant_id', auth()->user()->tenant_id) + ->when($request->search, fn ($q) => $q->where('name', 'like', "%{$request->search}%")) + ->where('is_active', true) + ->get(['id', 'name', 'sku', 'sale_price', 'cost_price']) + ->map(fn ($p) => [ + 'id' => $p->id, + 'name' => $p->name, + 'sku' => $p->sku, + 'price' => $p->sale_price ?? 0, + ]); + + return response()->json($products); + } + + public function checkout(Request $request): JsonResponse + { + $validated = $request->validate([ + 'session_id' => 'required|exists:pos_sessions,id', + 'items' => 'required|array|min:1', + 'items.*.product_id' => 'nullable|integer', + 'items.*.name' => 'required|string', + 'items.*.qty' => 'required|numeric|min:0.01', + 'items.*.price' => 'required|numeric|min:0', + 'payment_method' => 'required|in:cash,card,mobile,digital_wallet', + 'amount_paid' => 'required|numeric|min:0', + 'customer_name' => 'nullable|string|max:255', + 'customer_email' => 'nullable|email', + 'discount_amount' => 'nullable|numeric|min:0', + 'tax_amount' => 'nullable|numeric|min:0', + ]); + + $session = PosSession::findOrFail($validated['session_id']); + + $subtotal = collect($validated['items'])->sum(fn ($i) => $i['qty'] * $i['price']); + $discount = $validated['discount_amount'] ?? 0; + $tax = $validated['tax_amount'] ?? 0; + $total = $subtotal - $discount + $tax; + + $paymentMethod = $validated['payment_method'] === 'mobile' ? 'digital_wallet' : $validated['payment_method']; + + $order = PosOrder::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'session_id' => $session->id, + 'customer_name' => $validated['customer_name'] ?? null, + 'customer_email' => $validated['customer_email'] ?? null, + 'subtotal' => $subtotal, + 'discount_amount' => $discount, + 'tax_amount' => $tax, + 'total' => $total, + 'amount_paid' => $validated['amount_paid'], + 'change_given' => max(0, $validated['amount_paid'] - $total), + 'payment_method' => $paymentMethod, + 'status' => 'completed', + 'served_by' => auth()->id(), + 'created_by' => auth()->id(), + ]); + + $order->receipt_number = $order->generateReceiptNumber(); + $order->save(); + + foreach ($validated['items'] as $item) { + PosOrderItem::create([ + 'order_id' => $order->id, + 'product_id' => $item['product_id'], + 'product_name' => $item['name'], + 'quantity' => $item['qty'], + 'unit_price' => $item['price'], + 'line_total' => $item['qty'] * $item['price'], + ]); + } + + PosPayment::create([ + 'order_id' => $order->id, + 'method' => $paymentMethod, + 'amount' => $validated['amount_paid'], + ]); + + $session->total_sales = ($session->total_sales ?? 0) + $total; + $session->save(); + + return response()->json([ + 'order_id' => $order->id, + 'receipt_number' => $order->receipt_number, + 'total' => $total, + 'change_given' => $order->change_given, + ]); + } + + public function receipt(PosSession $session, PosOrder $order): Response + { + $order->load('items'); + + return Inertia::render('POS/Receipt', [ + 'order' => $order, + 'session' => $session, + ]); + } + + public function sessions(): Response + { + $sessions = PosSession::with(['openedBy', 'closedBy']) + ->orderByDesc('opened_at') + ->paginate(20) + ->withQueryString(); + + return Inertia::render('POS/Sessions', [ + 'sessions' => $sessions, + ]); + } +} diff --git a/erp/app/Modules/POS/Models/PosOrder.php b/erp/app/Modules/POS/Models/PosOrder.php new file mode 100644 index 00000000000..91767b6ba96 --- /dev/null +++ b/erp/app/Modules/POS/Models/PosOrder.php @@ -0,0 +1,80 @@ + 'float', + 'discount_amount' => 'float', + 'tax_amount' => 'float', + 'total' => 'float', + 'amount_paid' => 'float', + 'change_given' => 'float', + 'status' => 'string', + ]; + + public function session(): BelongsTo + { + return $this->belongsTo(PosSession::class, 'session_id'); + } + + public function items(): HasMany + { + return $this->hasMany(PosOrderItem::class, 'order_id'); + } + + public function payments(): HasMany + { + return $this->hasMany(PosPayment::class, 'order_id'); + } + + public function servedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'served_by'); + } + + public function generateReceiptNumber(): string + { + return 'REC-' . date('Y') . '-' . str_pad((string) $this->id, 5, '0', STR_PAD_LEFT); + } + + public function recalculate(): void + { + $subtotal = $this->items()->sum('line_total'); + $this->subtotal = $subtotal; + $this->total = $subtotal - $this->discount_amount + $this->tax_amount; + $this->save(); + } +} diff --git a/erp/app/Modules/POS/Models/PosOrderItem.php b/erp/app/Modules/POS/Models/PosOrderItem.php new file mode 100644 index 00000000000..f0d43a78e54 --- /dev/null +++ b/erp/app/Modules/POS/Models/PosOrderItem.php @@ -0,0 +1,40 @@ + 'float', + 'unit_price' => 'float', + 'discount_percent' => 'float', + 'line_total' => 'float', + ]; + + public function order(): BelongsTo + { + return $this->belongsTo(PosOrder::class, 'order_id'); + } + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class, 'product_id'); + } +} diff --git a/erp/app/Modules/POS/Models/PosPayment.php b/erp/app/Modules/POS/Models/PosPayment.php new file mode 100644 index 00000000000..077a7597d06 --- /dev/null +++ b/erp/app/Modules/POS/Models/PosPayment.php @@ -0,0 +1,27 @@ + 'float', + ]; + + public function order(): BelongsTo + { + return $this->belongsTo(PosOrder::class, 'order_id'); + } +} diff --git a/erp/app/Modules/POS/Models/PosSession.php b/erp/app/Modules/POS/Models/PosSession.php new file mode 100644 index 00000000000..8ebfca24667 --- /dev/null +++ b/erp/app/Modules/POS/Models/PosSession.php @@ -0,0 +1,95 @@ + 'datetime', + 'closed_at' => 'datetime', + 'opening_cash' => 'float', + 'closing_cash' => 'float', + 'expected_cash' => 'float', + 'total_sales' => 'float', + 'total_refunds' => 'float', + ]; + + public function openedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'opened_by'); + } + + public function closedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'closed_by'); + } + + public function orders(): HasMany + { + return $this->hasMany(PosOrder::class, 'session_id'); + } + + public function warehouse(): BelongsTo + { + return $this->belongsTo(Warehouse::class, 'warehouse_id'); + } + + public static function open(User $user, float $openingCash = 0): static + { + $session = static::create([ + 'tenant_id' => $user->tenant_id, + 'name' => 'Register', + 'opened_by' => $user->id, + 'status' => 'open', + 'opened_at' => now(), + 'opening_cash' => $openingCash, + ]); + + $session->name = $session->generateName(); + $session->save(); + + return $session; + } + + public function close(float $closingCash, string $notes = ''): void + { + $this->status = 'closed'; + $this->closed_at = now(); + $this->closing_cash = $closingCash; + $this->notes = $notes ?: $this->notes; + $this->save(); + } + + public function generateName(): string + { + return 'POS-' . date('Y') . '-' . str_pad((string) $this->id, 5, '0', STR_PAD_LEFT); + } +} diff --git a/erp/app/Modules/POS/Policies/PosPolicy.php b/erp/app/Modules/POS/Policies/PosPolicy.php new file mode 100644 index 00000000000..15d4dfb3b91 --- /dev/null +++ b/erp/app/Modules/POS/Policies/PosPolicy.php @@ -0,0 +1,33 @@ +can('inventory.create'); + } + + public function update(User $user): bool + { + return $user->can('inventory.create'); + } + + public function delete(User $user): bool + { + return $user->can('inventory.delete'); + } +} diff --git a/erp/app/Modules/POS/Providers/POSServiceProvider.php b/erp/app/Modules/POS/Providers/POSServiceProvider.php new file mode 100644 index 00000000000..57686d072ae --- /dev/null +++ b/erp/app/Modules/POS/Providers/POSServiceProvider.php @@ -0,0 +1,26 @@ +loadRoutesFrom(__DIR__ . '/../routes/pos.php'); + + Gate::policy(PosSession::class, PosPolicy::class); + Gate::policy(PosOrder::class, PosPolicy::class); + Gate::policy(PosOrderItem::class, PosPolicy::class); + Gate::policy(PosPayment::class, PosPolicy::class); + } +} diff --git a/erp/app/Modules/POS/routes/pos.php b/erp/app/Modules/POS/routes/pos.php new file mode 100644 index 00000000000..a29f598e02c --- /dev/null +++ b/erp/app/Modules/POS/routes/pos.php @@ -0,0 +1,28 @@ +prefix('pos')->name('pos.')->group(function () { + Route::get('dashboard', [PosDashboardController::class, 'index'])->name('dashboard'); + + // Terminal + Route::get('terminal', [PosTerminalController::class, 'terminal'])->name('terminal'); + Route::get('terminal/products', [PosTerminalController::class, 'products'])->name('terminal.products'); + Route::post('terminal/checkout', [PosTerminalController::class, 'checkout'])->name('terminal.checkout'); + Route::get('terminal/{session}/receipt/{order}', [PosTerminalController::class, 'receipt'])->name('terminal.receipt'); + Route::get('sessions-list', [PosTerminalController::class, 'sessions'])->name('sessions-list'); + + // Session actions BEFORE resource + Route::post('sessions/{session}/close', [PosSessionController::class, 'close'])->name('sessions.close'); + Route::get('sessions/{session}/z-report', [PosSessionController::class, 'zReport'])->name('sessions.z-report'); + Route::resource('sessions', PosSessionController::class)->except(['edit', 'update', 'destroy']); + + // Order actions + Route::post('orders/{order}/refund', [PosOrderController::class, 'refund'])->name('orders.refund'); + Route::get('orders/{order}/pdf', [PosOrderController::class, 'pdf'])->name('orders.pdf'); + Route::resource('orders', PosOrderController::class)->only(['index', 'store', 'show']); +}); diff --git a/erp/app/Modules/Planning/Http/Controllers/PlanningController.php b/erp/app/Modules/Planning/Http/Controllers/PlanningController.php new file mode 100644 index 00000000000..51fe5638f4b --- /dev/null +++ b/erp/app/Modules/Planning/Http/Controllers/PlanningController.php @@ -0,0 +1,182 @@ +when($request->employee_id, fn ($q) => $q->where('employee_id', $request->employee_id)) + ->when($request->week, function ($q) use ($request) { + $week = Carbon::parse($request->week)->startOfWeek(); + $q->whereBetween('starts_at', [$week, $week->copy()->endOfWeek()]); + }) + ->orderBy('starts_at') + ->get(); + + $users = User::orderBy('name')->get(['id', 'name', 'email']); + + return Inertia::render('Planning/Index', [ + 'shifts' => $shifts, + 'users' => $users, + 'filters' => $request->only(['employee_id', 'week']), + ]); + } + + public function schedule(Request $request): Response + { + $weekStart = $request->week + ? Carbon::parse($request->week)->startOfWeek() + : Carbon::now()->startOfWeek(); + + $weekEnd = $weekStart->copy()->endOfWeek(); + + $shifts = Shift::with('employee') + ->whereBetween('starts_at', [$weekStart, $weekEnd]) + ->orderBy('starts_at') + ->get(); + + $grouped = $shifts->groupBy('employee_id'); + + $users = User::orderBy('name')->get(['id', 'name', 'email']); + + return Inertia::render('Planning/Schedule', [ + 'shifts' => $shifts, + 'grouped' => $grouped, + 'users' => $users, + 'weekStart' => $weekStart->toDateString(), + 'weekEnd' => $weekEnd->toDateString(), + ]); + } + + public function show(Shift $shift): Response + { + $shift->load(['employee', 'swaps.requester', 'swaps.target']); + + return Inertia::render('Planning/Show', [ + 'shift' => $shift, + 'users' => User::orderBy('name')->get(['id', 'name', 'email']), + ]); + } + + public function store(Request $request): RedirectResponse|JsonResponse + { + $validated = $request->validate([ + 'employee_id' => 'required|exists:users,id', + 'title' => 'required|string|max:255', + 'starts_at' => 'required|date', + 'ends_at' => 'required|date|after:starts_at', + 'break_minutes' => 'nullable|integer|min:0', + 'notes' => 'nullable|string', + ]); + + $shift = new Shift($validated); + + if ($shift->overlapsWithUserShifts()) { + return response()->json(['message' => 'Shift overlaps with an existing shift for this employee.'], 422); + } + + $shift->save(); + + return redirect()->route('planning.index')->with('success', 'Shift created.'); + } + + public function update(Request $request, Shift $shift): RedirectResponse + { + if ($shift->status !== 'scheduled') { + return back()->withErrors(['status' => 'Only scheduled shifts can be updated.']); + } + + $validated = $request->validate([ + 'employee_id' => 'sometimes|exists:users,id', + 'title' => 'sometimes|string|max:255', + 'starts_at' => 'sometimes|date', + 'ends_at' => 'sometimes|date|after:starts_at', + 'break_minutes' => 'nullable|integer|min:0', + 'notes' => 'nullable|string', + ]); + + $shift->update($validated); + + return redirect()->route('planning.show', $shift)->with('success', 'Shift updated.'); + } + + public function destroy(Shift $shift): RedirectResponse + { + if (!in_array($shift->status, ['scheduled', 'cancelled'])) { + return back()->withErrors(['status' => 'Only scheduled or cancelled shifts can be deleted.']); + } + + $shift->delete(); + + return redirect()->route('planning.index')->with('success', 'Shift deleted.'); + } + + public function confirm(Shift $shift): RedirectResponse + { + $shift->confirm(); + + return back()->with('success', 'Shift confirmed.'); + } + + public function complete(Shift $shift): RedirectResponse + { + $shift->complete(); + + return back()->with('success', 'Shift completed.'); + } + + public function cancel(Shift $shift): RedirectResponse + { + $shift->cancel(); + + return back()->with('success', 'Shift cancelled.'); + } + + public function requestSwap(Request $request, Shift $shift): RedirectResponse|JsonResponse + { + $validated = $request->validate([ + 'requested_to' => 'required|exists:users,id', + 'reason' => 'nullable|string', + ]); + + if ($validated['requested_to'] == $shift->employee_id) { + return response()->json(['message' => 'Cannot swap with the same employee.'], 422); + } + + ShiftSwap::create([ + 'shift_id' => $shift->id, + 'requested_by' => $shift->employee_id, + 'requested_to' => $validated['requested_to'], + 'reason' => $validated['reason'] ?? null, + ]); + + return back()->with('success', 'Swap requested.'); + } + + public function approveSwap(Shift $shift, ShiftSwap $swap): RedirectResponse + { + $swap->approve(); + + return back()->with('success', 'Swap approved.'); + } + + public function rejectSwap(Shift $shift, ShiftSwap $swap): RedirectResponse + { + $swap->reject(); + + return back()->with('success', 'Swap rejected.'); + } +} diff --git a/erp/app/Modules/Planning/Models/Shift.php b/erp/app/Modules/Planning/Models/Shift.php new file mode 100644 index 00000000000..19cd516ea50 --- /dev/null +++ b/erp/app/Modules/Planning/Models/Shift.php @@ -0,0 +1,82 @@ + 'datetime', + 'ends_at' => 'datetime', + ]; + + public function employee(): BelongsTo + { + return $this->belongsTo(User::class, 'employee_id'); + } + + public function swaps(): HasMany + { + return $this->hasMany(ShiftSwap::class); + } + + public function durationMinutes(): int + { + return (int) $this->starts_at->diffInMinutes($this->ends_at) - $this->break_minutes; + } + + public function durationHours(): float + { + return $this->durationMinutes() / 60; + } + + public function confirm(): void + { + $this->update(['status' => 'confirmed']); + } + + public function complete(): void + { + $this->update(['status' => 'completed']); + } + + public function cancel(): void + { + $this->update(['status' => 'cancelled']); + } + + public function overlapsWithUserShifts(): bool + { + return static::withoutGlobalScopes() + ->where('employee_id', $this->employee_id) + ->where('id', '!=', $this->id ?? 0) + ->where('status', '!=', 'cancelled') + ->where(function ($q) { + $q->where(function ($q2) { + $q2->where('starts_at', '<', $this->ends_at) + ->where('ends_at', '>', $this->starts_at); + }); + }) + ->exists(); + } +} diff --git a/erp/app/Modules/Planning/Models/ShiftSwap.php b/erp/app/Modules/Planning/Models/ShiftSwap.php new file mode 100644 index 00000000000..3740cc5d1a4 --- /dev/null +++ b/erp/app/Modules/Planning/Models/ShiftSwap.php @@ -0,0 +1,59 @@ + 'datetime', + ]; + + public function shift(): BelongsTo + { + return $this->belongsTo(Shift::class); + } + + public function requester(): BelongsTo + { + return $this->belongsTo(User::class, 'requested_by'); + } + + public function target(): BelongsTo + { + return $this->belongsTo(User::class, 'requested_to'); + } + + public function approve(): void + { + $this->update([ + 'status' => 'approved', + 'responded_at' => now(), + ]); + $this->shift->update(['employee_id' => $this->requested_to]); + } + + public function reject(): void + { + $this->update([ + 'status' => 'rejected', + 'responded_at' => now(), + ]); + } +} diff --git a/erp/app/Modules/Planning/Providers/PlanningServiceProvider.php b/erp/app/Modules/Planning/Providers/PlanningServiceProvider.php new file mode 100644 index 00000000000..6fdd66936df --- /dev/null +++ b/erp/app/Modules/Planning/Providers/PlanningServiceProvider.php @@ -0,0 +1,16 @@ +loadRoutesFrom(__DIR__ . '/../routes/planning.php'); + $this->loadMigrationsFrom(__DIR__ . '/../../../database/migrations'); + } +} diff --git a/erp/app/Modules/Planning/routes/planning.php b/erp/app/Modules/Planning/routes/planning.php new file mode 100644 index 00000000000..80f3c799ee0 --- /dev/null +++ b/erp/app/Modules/Planning/routes/planning.php @@ -0,0 +1,19 @@ +prefix('planning')->name('planning.')->group(function () { + Route::get('schedule', [PlanningController::class, 'schedule'])->name('schedule'); + Route::post('{shift}/confirm', [PlanningController::class, 'confirm'])->name('shifts.confirm'); + Route::post('{shift}/complete', [PlanningController::class, 'complete'])->name('shifts.complete'); + Route::post('{shift}/cancel', [PlanningController::class, 'cancel'])->name('shifts.cancel'); + Route::post('{shift}/swap', [PlanningController::class, 'requestSwap'])->name('shifts.swap'); + Route::post('{shift}/swaps/{swap}/approve', [PlanningController::class, 'approveSwap'])->name('shifts.swaps.approve'); + Route::post('{shift}/swaps/{swap}/reject', [PlanningController::class, 'rejectSwap'])->name('shifts.swaps.reject'); + Route::get('', [PlanningController::class, 'index'])->name('index'); + Route::post('', [PlanningController::class, 'store'])->name('store'); + Route::get('{shift}', [PlanningController::class, 'show'])->name('show'); + Route::patch('{shift}', [PlanningController::class, 'update'])->name('update'); + Route::delete('{shift}', [PlanningController::class, 'destroy'])->name('destroy'); +}); diff --git a/erp/app/Modules/Purchase/Http/Controllers/PurchaseController.php b/erp/app/Modules/Purchase/Http/Controllers/PurchaseController.php new file mode 100644 index 00000000000..c0cc9ecac55 --- /dev/null +++ b/erp/app/Modules/Purchase/Http/Controllers/PurchaseController.php @@ -0,0 +1,184 @@ +user()->tenant_id; + + $stats = [ + 'total_vendors' => PurchaseVendor::where('tenant_id', $tenantId)->count(), + 'open_rfqs' => PurchaseRfq::where('tenant_id', $tenantId) + ->whereIn('status', ['draft', 'sent']) + ->count(), + 'open_pos' => Po::where('tenant_id', $tenantId) + ->whereIn('status', ['draft', 'confirmed']) + ->count(), + 'received_this_month' => Po::where('tenant_id', $tenantId) + ->where('status', 'received') + ->whereMonth('received_at', now()->month) + ->whereYear('received_at', now()->year) + ->count(), + ]; + + return Inertia::render('Purchase/Dashboard', compact('stats')); + } + + public function vendors(): Response + { + $vendors = PurchaseVendor::where('tenant_id', auth()->user()->tenant_id) + ->orderBy('name') + ->paginate(20) + ->withQueryString(); + + return Inertia::render('Purchase/Vendors/Index', compact('vendors')); + } + + public function storeVendor(Request $request): RedirectResponse + { + $data = $request->validate([ + 'name' => 'required|string|max:255', + 'email' => 'nullable|email|max:255', + 'phone' => 'nullable|string|max:50', + 'address' => 'nullable|string', + 'currency' => 'nullable|string|max:3', + 'payment_terms' => 'nullable|string|max:255', + 'is_active' => 'nullable|boolean', + 'rating' => 'nullable|integer|min:1|max:5', + ]); + + PurchaseVendor::create([ + 'tenant_id' => auth()->user()->tenant_id, + ...$data, + ]); + + return redirect()->back()->with('success', 'Vendor created successfully.'); + } + + public function rfqs(): Response + { + $rfqs = PurchaseRfq::where('tenant_id', auth()->user()->tenant_id) + ->with('vendor') + ->orderByDesc('created_at') + ->paginate(20) + ->withQueryString(); + + $vendors = PurchaseVendor::where('tenant_id', auth()->user()->tenant_id) + ->where('is_active', true) + ->orderBy('name') + ->get(['id', 'name']); + + return Inertia::render('Purchase/Rfqs/Index', compact('rfqs', 'vendors')); + } + + public function storeRfq(Request $request): RedirectResponse + { + $data = $request->validate([ + 'po_vendor_id' => 'required|exists:po_vendors,id', + 'expected_delivery' => 'nullable|date', + 'notes' => 'nullable|string', + 'currency' => 'nullable|string|max:3', + ]); + + $rfqNumber = 'RFQ-' . now()->format('Ymd') . '-' . str_pad(rand(1, 9999), 4, '0', STR_PAD_LEFT); + + PurchaseRfq::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'rfq_number' => $rfqNumber, + 'status' => 'draft', + ...$data, + ]); + + return redirect()->back()->with('success', 'RFQ created successfully.'); + } + + public function showRfq(PurchaseRfq $rfq): Response + { + $rfq->load(['vendor', 'lines']); + + return Inertia::render('Purchase/Rfqs/Show', compact('rfq')); + } + + public function addRfqLine(Request $request, PurchaseRfq $rfq): JsonResponse + { + $data = $request->validate([ + 'product_name' => 'required|string|max:255', + 'description' => 'nullable|string', + 'quantity' => 'required|numeric|min:0.001', + 'unit_price' => 'required|numeric|min:0', + 'uom' => 'nullable|string|max:50', + ]); + + $subtotal = $data['quantity'] * $data['unit_price']; + + PurchaseRfqLine::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'po_rfq_id' => $rfq->id, + 'subtotal' => $subtotal, + ...$data, + ]); + + return response()->json(['success' => true]); + } + + public function sendRfq(PurchaseRfq $rfq): JsonResponse + { + $rfq->send(); + + return response()->json(['success' => true]); + } + + public function convertToPo(PurchaseRfq $rfq): JsonResponse + { + $rfq->load('lines'); + $po = $rfq->toPurchaseOrder(); + + return response()->json(['success' => true, 'po_id' => $po->id]); + } + + public function pos(): Response + { + $pos = Po::where('tenant_id', auth()->user()->tenant_id) + ->with('vendor') + ->orderByDesc('created_at') + ->paginate(20) + ->withQueryString(); + + return Inertia::render('Purchase/Pos/Index', compact('pos')); + } + + public function showPo(Po $po): Response + { + $po->load(['vendor', 'lines']); + + return Inertia::render('Purchase/Pos/Show', compact('po')); + } + + public function confirmPo(Po $po): JsonResponse + { + $po->confirm(); + + return response()->json(['success' => true]); + } + + public function receivePo(Po $po): JsonResponse + { + $po->receive(); + + return response()->json(['success' => true]); + } +} diff --git a/erp/app/Modules/Purchase/Models/Po.php b/erp/app/Modules/Purchase/Models/Po.php new file mode 100644 index 00000000000..0518a5331b9 --- /dev/null +++ b/erp/app/Modules/Purchase/Models/Po.php @@ -0,0 +1,80 @@ + 'date', + 'expected_delivery' => 'date', + 'confirmed_at' => 'datetime', + 'received_at' => 'datetime', + 'total_amount' => 'decimal:2', + ]; + + public function vendor(): BelongsTo + { + return $this->belongsTo(PurchaseVendor::class, 'po_vendor_id'); + } + + public function rfq(): BelongsTo + { + return $this->belongsTo(PurchaseRfq::class, 'po_rfq_id'); + } + + public function lines(): HasMany + { + return $this->hasMany(PoLine::class, 'po_id'); + } + + public function confirm(): void + { + $this->update([ + 'status' => 'confirmed', + 'confirmed_at' => now(), + ]); + event(new \App\Events\Purchase\PurchaseOrderConfirmed($this)); + } + + public function receive(): void + { + $this->update([ + 'status' => 'received', + 'received_at' => now(), + ]); + } + + public function cancel(): void + { + $this->update(['status' => 'cancelled']); + } + + public static function generatePoNumber(): string + { + return 'PO-' . now()->format('Ymd') . '-' . str_pad(rand(1, 9999), 4, '0', STR_PAD_LEFT); + } +} diff --git a/erp/app/Modules/Purchase/Models/PoLine.php b/erp/app/Modules/Purchase/Models/PoLine.php new file mode 100644 index 00000000000..4db3d2c7465 --- /dev/null +++ b/erp/app/Modules/Purchase/Models/PoLine.php @@ -0,0 +1,38 @@ + 'decimal:3', + 'unit_price' => 'decimal:2', + 'subtotal' => 'decimal:2', + 'received_qty' => 'decimal:3', + ]; + + public function po(): BelongsTo + { + return $this->belongsTo(Po::class); + } +} diff --git a/erp/app/Modules/Purchase/Models/PurchaseRfq.php b/erp/app/Modules/Purchase/Models/PurchaseRfq.php new file mode 100644 index 00000000000..2d0b6613d18 --- /dev/null +++ b/erp/app/Modules/Purchase/Models/PurchaseRfq.php @@ -0,0 +1,90 @@ + 'date', + 'sent_at' => 'datetime', + ]; + + public function vendor(): BelongsTo + { + return $this->belongsTo(PurchaseVendor::class, 'po_vendor_id'); + } + + public function lines(): HasMany + { + return $this->hasMany(PurchaseRfqLine::class, 'po_rfq_id'); + } + + public function send(): void + { + $this->update([ + 'status' => 'sent', + 'sent_at' => now(), + ]); + } + + public function cancel(): void + { + $this->update(['status' => 'cancelled']); + } + + public function toPurchaseOrder(): Po + { + $po = Po::create([ + 'tenant_id' => $this->tenant_id, + 'po_number' => Po::generatePoNumber(), + 'po_rfq_id' => $this->id, + 'po_vendor_id' => $this->po_vendor_id, + 'status' => 'draft', + 'order_date' => now()->toDateString(), + 'expected_delivery' => $this->expected_delivery?->toDateString(), + 'notes' => $this->notes, + 'currency' => $this->currency, + 'total_amount' => 0, + ]); + + $total = 0; + foreach ($this->lines as $line) { + PoLine::create([ + 'tenant_id' => $this->tenant_id, + 'po_id' => $po->id, + 'product_name' => $line->product_name, + 'description' => $line->description, + 'quantity' => $line->quantity, + 'unit_price' => $line->unit_price, + 'uom' => $line->uom, + 'subtotal' => $line->subtotal, + 'received_qty' => 0, + ]); + $total += $line->subtotal; + } + + $po->update(['total_amount' => $total]); + + return $po; + } +} diff --git a/erp/app/Modules/Purchase/Models/PurchaseRfqLine.php b/erp/app/Modules/Purchase/Models/PurchaseRfqLine.php new file mode 100644 index 00000000000..44b2a894052 --- /dev/null +++ b/erp/app/Modules/Purchase/Models/PurchaseRfqLine.php @@ -0,0 +1,36 @@ + 'decimal:3', + 'unit_price' => 'decimal:2', + 'subtotal' => 'decimal:2', + ]; + + public function rfq(): BelongsTo + { + return $this->belongsTo(PurchaseRfq::class, 'po_rfq_id'); + } +} diff --git a/erp/app/Modules/Purchase/Models/PurchaseVendor.php b/erp/app/Modules/Purchase/Models/PurchaseVendor.php new file mode 100644 index 00000000000..0b9052db1b2 --- /dev/null +++ b/erp/app/Modules/Purchase/Models/PurchaseVendor.php @@ -0,0 +1,41 @@ + 'boolean', + 'rating' => 'integer', + ]; + + public function rfqs(): HasMany + { + return $this->hasMany(PurchaseRfq::class, 'po_vendor_id'); + } + + public function pos(): HasMany + { + return $this->hasMany(Po::class, 'po_vendor_id'); + } +} diff --git a/erp/app/Modules/Purchase/Providers/PurchaseServiceProvider.php b/erp/app/Modules/Purchase/Providers/PurchaseServiceProvider.php new file mode 100644 index 00000000000..794adfa8634 --- /dev/null +++ b/erp/app/Modules/Purchase/Providers/PurchaseServiceProvider.php @@ -0,0 +1,15 @@ +loadRoutesFrom(__DIR__ . '/../routes/purchase.php'); + } +} diff --git a/erp/app/Modules/Purchase/routes/purchase.php b/erp/app/Modules/Purchase/routes/purchase.php new file mode 100644 index 00000000000..82fa6cf55e6 --- /dev/null +++ b/erp/app/Modules/Purchase/routes/purchase.php @@ -0,0 +1,23 @@ +prefix('purchase')->name('purchase.')->group(function () { + Route::get('dashboard', [PurchaseController::class, 'dashboard'])->name('dashboard'); + + Route::get('vendors', [PurchaseController::class, 'vendors'])->name('vendors'); + Route::post('vendors', [PurchaseController::class, 'storeVendor'])->name('vendors.store'); + + Route::get('rfqs', [PurchaseController::class, 'rfqs'])->name('rfqs'); + Route::post('rfqs', [PurchaseController::class, 'storeRfq'])->name('rfqs.store'); + Route::get('rfqs/{rfq}', [PurchaseController::class, 'showRfq'])->name('rfqs.show'); + Route::post('rfqs/{rfq}/lines', [PurchaseController::class, 'addRfqLine'])->name('rfqs.lines.store'); + Route::post('rfqs/{rfq}/send', [PurchaseController::class, 'sendRfq'])->name('rfqs.send'); + Route::post('rfqs/{rfq}/convert', [PurchaseController::class, 'convertToPo'])->name('rfqs.convert'); + + Route::get('pos', [PurchaseController::class, 'pos'])->name('pos'); + Route::get('pos/{po}', [PurchaseController::class, 'showPo'])->name('pos.show'); + Route::post('pos/{po}/confirm', [PurchaseController::class, 'confirmPo'])->name('pos.confirm'); + Route::post('pos/{po}/receive', [PurchaseController::class, 'receivePo'])->name('pos.receive'); +}); diff --git a/erp/app/Modules/QualityControl/Http/Controllers/QualityControlController.php b/erp/app/Modules/QualityControl/Http/Controllers/QualityControlController.php new file mode 100644 index 00000000000..12b4418531f --- /dev/null +++ b/erp/app/Modules/QualityControl/Http/Controllers/QualityControlController.php @@ -0,0 +1,237 @@ +user()->tenant_id; + + $openNcrs = NonConformanceReport::whereIn('status', ['open', 'under_review'])->count(); + + $failedInspections = QcInspection::where('status', 'failed')->count(); + + $pendingInspections = QcInspection::where('status', 'pending')->count(); + + $inspections = QcInspection::with('results') + ->whereIn('status', ['passed', 'failed']) + ->get(); + + $passRate = 0.0; + if ($inspections->isNotEmpty()) { + $total = $inspections->sum(fn ($i) => $i->results->count()); + $passes = $inspections->sum(fn ($i) => $i->results->where('result', 'pass')->count()); + $passRate = $total > 0 ? round(($passes / $total) * 100, 1) : 0.0; + } + + return Inertia::render('QualityControl/Dashboard', [ + 'stats' => [ + 'open_ncrs' => $openNcrs, + 'failed_inspections' => $failedInspections, + 'pending_inspections' => $pendingInspections, + 'pass_rate' => $passRate, + ], + ]); + } + + public function checklists(): Response + { + $checklists = QcChecklist::withCount('items') + ->latest() + ->paginate(25) + ->withQueryString(); + + return Inertia::render('QualityControl/Checklists/Index', [ + 'checklists' => $checklists, + ]); + } + + public function store(Request $request): RedirectResponse + { + return $this->storeChecklist($request); + } + + public function storeChecklist(Request $request): RedirectResponse + { + $data = $request->validate([ + 'name' => 'required|string|max:255', + 'description' => 'nullable|string', + 'category' => 'required|in:incoming,process,final,audit', + 'is_active' => 'boolean', + ]); + + QcChecklist::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'name' => $data['name'], + 'description' => $data['description'] ?? null, + 'category' => $data['category'], + 'is_active' => $data['is_active'] ?? true, + 'created_by' => auth()->id(), + ]); + + return redirect()->route('quality.checklists.index') + ->with('success', 'Checklist created.'); + } + + public function storeChecklistItem(Request $request, QcChecklist $checklist): RedirectResponse + { + $data = $request->validate([ + 'description' => 'required|string', + 'check_type' => 'required|in:pass_fail,measurement,visual,count', + 'expected_value' => 'nullable|string|max:255', + 'unit' => 'nullable|string|max:50', + 'is_required' => 'boolean', + 'sequence' => 'integer|min:0', + ]); + + $checklist->items()->create([ + 'tenant_id' => auth()->user()->tenant_id, + 'description' => $data['description'], + 'check_type' => $data['check_type'], + 'expected_value' => $data['expected_value'] ?? null, + 'unit' => $data['unit'] ?? null, + 'is_required' => $data['is_required'] ?? true, + 'sequence' => $data['sequence'] ?? 0, + ]); + + return back()->with('success', 'Item added to checklist.'); + } + + public function inspections(): Response + { + $inspections = QcInspection::with(['checklist', 'inspector']) + ->latest() + ->paginate(25) + ->withQueryString(); + + $checklists = QcChecklist::active()->orderBy('name')->get(['id', 'name']); + + return Inertia::render('QualityControl/Inspections/Index', [ + 'inspections' => $inspections, + 'checklists' => $checklists, + ]); + } + + public function createInspection(Request $request): RedirectResponse + { + $data = $request->validate([ + 'checklist_id' => 'required|exists:quality_checklists,id', + 'reference_type' => 'nullable|string|max:100', + 'reference_id' => 'nullable|integer', + 'inspector_id' => 'nullable|exists:users,id', + ]); + + QcInspection::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'checklist_id' => $data['checklist_id'], + 'reference_type' => $data['reference_type'] ?? null, + 'reference_id' => $data['reference_id'] ?? null, + 'inspector_id' => $data['inspector_id'] ?? null, + 'status' => 'pending', + ]); + + return redirect()->route('quality.inspections.index') + ->with('success', 'Inspection created.'); + } + + public function startInspection(QcInspection $inspection): RedirectResponse + { + $inspection->start(); + + return back()->with('success', 'Inspection started.'); + } + + public function submitResults(Request $request, QcInspection $inspection): RedirectResponse + { + $data = $request->validate([ + 'results' => 'required|array', + 'results.*.checklist_item_id' => 'required|exists:quality_checklist_items,id', + 'results.*.result' => 'required|in:pass,fail,na', + 'results.*.measured_value' => 'nullable|string|max:255', + 'results.*.notes' => 'nullable|string', + ]); + + foreach ($data['results'] as $resultData) { + QcInspectionResult::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'inspection_id' => $inspection->id, + 'checklist_item_id' => $resultData['checklist_item_id'], + 'result' => $resultData['result'], + 'measured_value' => $resultData['measured_value'] ?? null, + 'notes' => $resultData['notes'] ?? null, + ]); + } + + $hasFail = collect($data['results'])->contains('result', 'fail'); + $outcome = $hasFail ? 'failed' : 'passed'; + $inspection->complete($outcome); + + return back()->with('success', 'Results submitted. Inspection marked as ' . $outcome . '.'); + } + + public function ncrs(): Response + { + $ncrs = NonConformanceReport::with(['reporter', 'assignee', 'inspection']) + ->latest() + ->paginate(25) + ->withQueryString(); + + $inspections = QcInspection::orderBy('id', 'desc')->get(['id']); + + return Inertia::render('QualityControl/NCR/Index', [ + 'ncrs' => $ncrs, + 'inspections' => $inspections, + ]); + } + + public function storeNcr(Request $request): RedirectResponse + { + $data = $request->validate([ + 'title' => 'required|string|max:255', + 'description' => 'required|string', + 'severity' => 'required|in:minor,major,critical', + 'inspection_id' => 'nullable|exists:quality_inspections,id', + 'due_date' => 'nullable|date', + ]); + + $tenantId = auth()->user()->tenant_id; + + NonConformanceReport::create([ + 'tenant_id' => $tenantId, + 'inspection_id' => $data['inspection_id'] ?? null, + 'ncr_number' => NonConformanceReport::generateNumber($tenantId), + 'title' => $data['title'], + 'description' => $data['description'], + 'severity' => $data['severity'], + 'due_date' => $data['due_date'] ?? null, + 'reported_by' => auth()->id(), + 'status' => 'open', + ]); + + return redirect()->route('quality.ncrs.index') + ->with('success', 'NCR created.'); + } + + public function resolveNcr(Request $request, NonConformanceReport $ncr): RedirectResponse + { + $data = $request->validate([ + 'root_cause' => 'required|string', + 'corrective_action' => 'required|string', + ]); + + $ncr->resolve($data['root_cause'], $data['corrective_action']); + + return back()->with('success', 'NCR resolved.'); + } +} diff --git a/erp/app/Modules/QualityControl/Models/NonConformanceReport.php b/erp/app/Modules/QualityControl/Models/NonConformanceReport.php new file mode 100644 index 00000000000..ec215bcf82f --- /dev/null +++ b/erp/app/Modules/QualityControl/Models/NonConformanceReport.php @@ -0,0 +1,81 @@ + 'date', + 'resolved_at' => 'datetime', + ]; + + public static function generateNumber(int $tenantId): string + { + $count = static::withoutGlobalScopes()->where('tenant_id', $tenantId)->count(); + + return 'NCR-' . str_pad((string) ($count + 1), 4, '0', STR_PAD_LEFT); + } + + public function resolve(string $rootCause, string $correctiveAction): void + { + $this->update([ + 'resolved_at' => now(), + 'root_cause' => $rootCause, + 'corrective_action' => $correctiveAction, + 'status' => 'resolved', + ]); + } + + public function close(): void + { + $this->update(['status' => 'closed']); + } + + public function isOverdue(): bool + { + if ($this->due_date === null) { + return false; + } + + return $this->due_date->isPast() + && ! in_array($this->status, ['resolved', 'closed'], true); + } + + public function inspection(): BelongsTo + { + return $this->belongsTo(QcInspection::class, 'inspection_id'); + } + + public function reporter(): BelongsTo + { + return $this->belongsTo(User::class, 'reported_by'); + } + + public function assignee(): BelongsTo + { + return $this->belongsTo(User::class, 'assigned_to'); + } +} diff --git a/erp/app/Modules/QualityControl/Models/QcChecklist.php b/erp/app/Modules/QualityControl/Models/QcChecklist.php new file mode 100644 index 00000000000..6a6699eca9c --- /dev/null +++ b/erp/app/Modules/QualityControl/Models/QcChecklist.php @@ -0,0 +1,50 @@ + 'boolean', + ]; + + public function items(): HasMany + { + return $this->hasMany(QcChecklistItem::class, 'checklist_id'); + } + + public function inspections(): HasMany + { + return $this->hasMany(QcInspection::class, 'checklist_id'); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function scopeActive(Builder $query): Builder + { + return $query->where('is_active', true); + } +} diff --git a/erp/app/Modules/QualityControl/Models/QcChecklistItem.php b/erp/app/Modules/QualityControl/Models/QcChecklistItem.php new file mode 100644 index 00000000000..6143eeb2498 --- /dev/null +++ b/erp/app/Modules/QualityControl/Models/QcChecklistItem.php @@ -0,0 +1,35 @@ + 'boolean', + 'sequence' => 'integer', + ]; + + public function checklist(): BelongsTo + { + return $this->belongsTo(QcChecklist::class, 'checklist_id'); + } +} diff --git a/erp/app/Modules/QualityControl/Models/QcInspection.php b/erp/app/Modules/QualityControl/Models/QcInspection.php new file mode 100644 index 00000000000..73731c142da --- /dev/null +++ b/erp/app/Modules/QualityControl/Models/QcInspection.php @@ -0,0 +1,82 @@ + 'datetime', + 'completed_at' => 'datetime', + ]; + + public function checklist(): BelongsTo + { + return $this->belongsTo(QcChecklist::class, 'checklist_id'); + } + + public function results(): HasMany + { + return $this->hasMany(QcInspectionResult::class, 'inspection_id'); + } + + public function inspector(): BelongsTo + { + return $this->belongsTo(User::class, 'inspector_id'); + } + + public function start(): void + { + $this->update([ + 'status' => 'in_progress', + 'started_at' => now(), + ]); + } + + public function complete(string $outcome): void + { + $this->update([ + 'status' => $outcome, + 'completed_at' => now(), + ]); + } + + public function cancel(): void + { + $this->update(['status' => 'cancelled']); + } + + public function passRate(): float + { + $results = $this->results; + + if ($results->isEmpty()) { + return 0.0; + } + + $passCount = $results->where('result', 'pass')->count(); + + return ($passCount / $results->count()) * 100; + } +} diff --git a/erp/app/Modules/QualityControl/Models/QcInspectionResult.php b/erp/app/Modules/QualityControl/Models/QcInspectionResult.php new file mode 100644 index 00000000000..4831df1e765 --- /dev/null +++ b/erp/app/Modules/QualityControl/Models/QcInspectionResult.php @@ -0,0 +1,33 @@ +belongsTo(QcInspection::class, 'inspection_id'); + } + + public function item(): BelongsTo + { + return $this->belongsTo(QcChecklistItem::class, 'checklist_item_id'); + } +} diff --git a/erp/app/Modules/QualityControl/Providers/QualityControlServiceProvider.php b/erp/app/Modules/QualityControl/Providers/QualityControlServiceProvider.php new file mode 100644 index 00000000000..87549604ef2 --- /dev/null +++ b/erp/app/Modules/QualityControl/Providers/QualityControlServiceProvider.php @@ -0,0 +1,15 @@ +loadRoutesFrom(__DIR__ . '/../routes/quality.php'); + } +} diff --git a/erp/app/Modules/QualityControl/routes/quality.php b/erp/app/Modules/QualityControl/routes/quality.php new file mode 100644 index 00000000000..55d687fa613 --- /dev/null +++ b/erp/app/Modules/QualityControl/routes/quality.php @@ -0,0 +1,24 @@ +prefix('quality')->name('quality.')->group(function () { + Route::get('dashboard', [QualityControlController::class, 'dashboard'])->name('dashboard'); + + // Checklists + Route::get('checklists', [QualityControlController::class, 'checklists'])->name('checklists.index'); + Route::post('checklists', [QualityControlController::class, 'storeChecklist'])->name('checklists.store'); + Route::post('checklists/{checklist}/items', [QualityControlController::class, 'storeChecklistItem'])->name('checklists.items.store'); + + // Inspections + Route::get('inspections', [QualityControlController::class, 'inspections'])->name('inspections.index'); + Route::post('inspections', [QualityControlController::class, 'createInspection'])->name('inspections.store'); + Route::post('inspections/{inspection}/start', [QualityControlController::class, 'startInspection'])->name('inspections.start'); + Route::post('inspections/{inspection}/results', [QualityControlController::class, 'submitResults'])->name('inspections.results'); + + // NCRs + Route::get('ncrs', [QualityControlController::class, 'ncrs'])->name('ncrs.index'); + Route::post('ncrs', [QualityControlController::class, 'storeNcr'])->name('ncrs.store'); + Route::post('ncrs/{ncr}/resolve', [QualityControlController::class, 'resolveNcr'])->name('ncrs.resolve'); +}); diff --git a/erp/app/Modules/Rental/Http/Controllers/RentalController.php b/erp/app/Modules/Rental/Http/Controllers/RentalController.php new file mode 100644 index 00000000000..a355433b1d1 --- /dev/null +++ b/erp/app/Modules/Rental/Http/Controllers/RentalController.php @@ -0,0 +1,200 @@ +filled('status')) { + $query->where('status', $request->status); + } + + if ($request->filled('category')) { + $query->where('category', $request->category); + } + + $items = $query->orderBy('name')->paginate(20)->withQueryString(); + + return Inertia::render('Rental/Index', [ + 'items' => $items, + 'filters' => $request->only(['status', 'category']), + ]); + } + + public function show(RentalItem $item): Response + { + $item->load([ + 'agreements' => fn ($q) => $q->latest()->limit(10), + ]); + + return Inertia::render('Rental/Show', [ + 'item' => $item, + ]); + } + + public function store(Request $request): RedirectResponse|\Symfony\Component\HttpFoundation\Response + { + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'description' => 'nullable|string', + 'category' => 'nullable|string|max:100', + 'daily_rate' => 'required|numeric|min:0', + 'serial_number' => 'nullable|string|max:100', + 'status' => 'nullable|in:available,rented,maintenance', + ]); + + $item = RentalItem::create($validated); + + if ($request->wantsJson()) { + return response()->json($item, 201); + } + + return redirect()->route('rental.items.show', $item)->with('success', 'Rental item created.'); + } + + public function update(Request $request, RentalItem $item): RedirectResponse|\Symfony\Component\HttpFoundation\Response + { + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'description' => 'nullable|string', + 'category' => 'nullable|string|max:100', + 'daily_rate' => 'required|numeric|min:0', + 'serial_number' => 'nullable|string|max:100', + 'status' => 'nullable|in:available,rented,maintenance', + ]); + + $item->update($validated); + + if ($request->wantsJson()) { + return response()->json($item); + } + + return redirect()->route('rental.items.show', $item)->with('success', 'Rental item updated.'); + } + + public function destroy(RentalItem $item): RedirectResponse|\Symfony\Component\HttpFoundation\Response + { + if (! $item->isAvailable()) { + if (request()->wantsJson()) { + return response()->json(['message' => 'Cannot delete an item that is currently rented or under maintenance.'], 422); + } + return redirect()->back()->with('error', 'Cannot delete an item that is currently rented or under maintenance.'); + } + + $hasActiveAgreement = $item->agreements()->where('status', 'active')->exists(); + if ($hasActiveAgreement) { + if (request()->wantsJson()) { + return response()->json(['message' => 'Cannot delete an item with an active rental agreement.'], 422); + } + return redirect()->back()->with('error', 'Cannot delete an item with an active rental agreement.'); + } + + $item->delete(); + + if (request()->wantsJson()) { + return response()->json(null, 204); + } + + return redirect()->route('rental.items.index')->with('success', 'Rental item deleted.'); + } + + public function rent(Request $request, RentalItem $item): RedirectResponse|\Symfony\Component\HttpFoundation\Response + { + if (! $item->isAvailable()) { + if ($request->wantsJson()) { + return response()->json(['message' => 'Item is not available for rent.'], 422); + } + return redirect()->back()->with('error', 'Item is not available for rent.')->withErrors(['item' => 'Item is not available for rent.']); + } + + $validated = $request->validate([ + 'customer_name' => 'required|string|max:255', + 'customer_email' => 'nullable|email|max:255', + 'start_date' => 'required|date', + 'end_date' => 'nullable|date|after_or_equal:start_date', + 'daily_rate' => 'nullable|numeric|min:0', + 'deposit' => 'nullable|numeric|min:0', + 'notes' => 'nullable|string', + ]); + + $agreement = RentalAgreement::create([ + 'rental_item_id' => $item->id, + 'customer_name' => $validated['customer_name'], + 'customer_email' => $validated['customer_email'] ?? null, + 'start_date' => $validated['start_date'], + 'end_date' => $validated['end_date'] ?? null, + 'daily_rate' => $validated['daily_rate'] ?? $item->daily_rate, + 'deposit' => $validated['deposit'] ?? 0, + 'notes' => $validated['notes'] ?? null, + 'status' => 'active', + ]); + + $item->update(['status' => 'rented']); + + if ($request->wantsJson()) { + return response()->json($agreement->load('item'), 201); + } + + return redirect()->route('rental.items.show', $item)->with('success', 'Item rented successfully.'); + } + + public function returnItem(Request $request, RentalItem $item): RedirectResponse|\Symfony\Component\HttpFoundation\Response + { + $agreement = $item->agreements()->where('status', 'active')->latest()->first(); + + if (! $agreement) { + if ($request->wantsJson()) { + return response()->json(['message' => 'No active rental agreement found for this item.'], 422); + } + return redirect()->back()->with('error', 'No active rental agreement found for this item.'); + } + + $agreement->return(); + + if ($request->wantsJson()) { + return response()->json($agreement->fresh()); + } + + return redirect()->route('rental.items.show', $item)->with('success', 'Item returned successfully.'); + } + + public function calendar(Request $request): Response + { + $items = RentalItem::with([ + 'agreements' => fn ($q) => $q->whereIn('status', ['active', 'overdue']) + ->orderBy('start_date'), + ])->orderBy('name')->get(); + + return Inertia::render('Rental/Calendar', [ + 'items' => $items, + ]); + } + + public function agreements(Request $request): Response + { + $query = RentalAgreement::with('item'); + + if ($request->filled('status')) { + $query->where('status', $request->status); + } + + $agreements = $query->latest()->paginate(20)->withQueryString(); + + return Inertia::render('Rental/Agreements', [ + 'agreements' => $agreements, + 'filters' => $request->only(['status']), + ]); + } +} diff --git a/erp/app/Modules/Rental/Models/RentalAgreement.php b/erp/app/Modules/Rental/Models/RentalAgreement.php new file mode 100644 index 00000000000..b2d682202e5 --- /dev/null +++ b/erp/app/Modules/Rental/Models/RentalAgreement.php @@ -0,0 +1,78 @@ + 'date', + 'end_date' => 'date', + 'returned_at' => 'datetime', + ]; + + public function item(): BelongsTo + { + return $this->belongsTo(RentalItem::class, 'rental_item_id'); + } + + public function daysRented(): int + { + $end = $this->end_date ?? Carbon::today(); + return (int) $this->start_date->diffInDays($end) + 1; + } + + public function totalAmount(): float + { + return (float) ($this->daily_rate * $this->daysRented()); + } + + public function isOverdue(): bool + { + return $this->end_date !== null + && $this->end_date->lt(Carbon::today()) + && $this->status === 'active'; + } + + public function return(Carbon $returnedAt = null): void + { + $this->update([ + 'status' => 'returned', + 'returned_at' => $returnedAt ?? now(), + ]); + + if ($this->item) { + $this->item->update(['status' => 'available']); + } + } + + public function cancel(): void + { + $this->update(['status' => 'cancelled']); + + if ($this->item) { + $this->item->update(['status' => 'available']); + } + } +} diff --git a/erp/app/Modules/Rental/Models/RentalItem.php b/erp/app/Modules/Rental/Models/RentalItem.php new file mode 100644 index 00000000000..b7fd64e7bff --- /dev/null +++ b/erp/app/Modules/Rental/Models/RentalItem.php @@ -0,0 +1,41 @@ +hasMany(RentalAgreement::class, 'rental_item_id'); + } + + public function currentAgreement(): HasOne + { + return $this->hasOne(RentalAgreement::class, 'rental_item_id') + ->where('status', 'active') + ->latestOfMany(); + } + + public function isAvailable(): bool + { + return $this->status === 'available'; + } +} diff --git a/erp/app/Modules/Rental/Providers/RentalServiceProvider.php b/erp/app/Modules/Rental/Providers/RentalServiceProvider.php new file mode 100644 index 00000000000..cf5722ff795 --- /dev/null +++ b/erp/app/Modules/Rental/Providers/RentalServiceProvider.php @@ -0,0 +1,15 @@ +loadRoutesFrom(__DIR__ . '/../routes/rental.php'); + } +} diff --git a/erp/app/Modules/Rental/routes/rental.php b/erp/app/Modules/Rental/routes/rental.php new file mode 100644 index 00000000000..8cd59d1e93f --- /dev/null +++ b/erp/app/Modules/Rental/routes/rental.php @@ -0,0 +1,13 @@ +prefix('rental')->name('rental.')->group(function () { + // Custom action routes BEFORE resource + Route::post('items/{item}/rent', [RentalController::class, 'rent'])->name('items.rent'); + Route::post('items/{item}/return', [RentalController::class, 'returnItem'])->name('items.return'); + Route::get('calendar', [RentalController::class, 'calendar'])->name('calendar'); + Route::get('agreements', [RentalController::class, 'agreements'])->name('agreements'); + Route::resource('items', RentalController::class)->except(['create', 'edit']); +}); diff --git a/erp/app/Modules/Repairs/Http/Controllers/RepairController.php b/erp/app/Modules/Repairs/Http/Controllers/RepairController.php new file mode 100644 index 00000000000..e1bc0e89b8d --- /dev/null +++ b/erp/app/Modules/Repairs/Http/Controllers/RepairController.php @@ -0,0 +1,154 @@ +id; + $today = now()->toDateString(); + + return Inertia::render('Repairs/Dashboard', [ + 'stats' => [ + 'total' => RepairOrder::withoutGlobalScopes()->where('tenant_id', $tenantId)->count(), + 'open' => RepairOrder::withoutGlobalScopes()->where('tenant_id', $tenantId)->whereIn('status', ['draft', 'confirmed', 'in_progress'])->count(), + 'in_progress' => RepairOrder::withoutGlobalScopes()->where('tenant_id', $tenantId)->where('status', 'in_progress')->count(), + 'completed_today' => RepairOrder::withoutGlobalScopes()->where('tenant_id', $tenantId)->where('status', 'done')->whereDate('completed_at', $today)->count(), + 'overdue' => RepairOrder::withoutGlobalScopes()->where('tenant_id', $tenantId)->whereNotIn('status', ['done', 'cancelled'])->whereNotNull('scheduled_date')->where('scheduled_date', '<', $today)->count(), + ], + ]); + } + + public function index(): Response + { + $repairs = RepairOrder::withoutGlobalScopes() + ->where('tenant_id', app('tenant')->id) + ->with('assignedUser') + ->orderByDesc('created_at') + ->paginate(20); + + return Inertia::render('Repairs/Orders/Index', ['repairs' => $repairs]); + } + + public function show(RepairOrder $order): Response + { + $order->load(['lines.product', 'assignedUser', 'contact']); + + return Inertia::render('Repairs/Orders/Show', ['order' => $order]); + } + + public function store(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'product_name' => 'required|string|max:255', + 'serial_number' => 'nullable|string|max:255', + 'priority' => 'nullable|in:low,medium,high,urgent', + 'diagnosis' => 'nullable|string', + 'scheduled_date' => 'nullable|date', + 'assigned_to' => 'nullable|exists:users,id', + 'warranty_claim' => 'nullable|boolean', + 'estimated_hours' => 'nullable|numeric|min:0', + 'estimated_cost' => 'nullable|numeric|min:0', + ]); + + $tenantId = app('tenant')->id; + RepairOrder::create(array_merge( + ['tenant_id' => $tenantId, 'order_number' => RepairOrder::generateOrderNumber($tenantId)], + $validated + )); + + return redirect()->route('repairs.orders.index')->with('success', 'Repair order created.'); + } + + public function update(Request $request, RepairOrder $order): RedirectResponse + { + $validated = $request->validate([ + 'product_name' => 'required|string|max:255', + 'serial_number' => 'nullable|string|max:255', + 'priority' => 'nullable|in:low,medium,high,urgent', + 'status' => 'nullable|in:draft,confirmed,in_progress,done,cancelled', + 'diagnosis' => 'nullable|string', + 'scheduled_date' => 'nullable|date', + 'assigned_to' => 'nullable|exists:users,id', + 'warranty_claim' => 'nullable|boolean', + 'estimated_hours' => 'nullable|numeric|min:0', + 'estimated_cost' => 'nullable|numeric|min:0', + ]); + + $order->update($validated); + + return redirect()->back()->with('success', 'Repair order updated.'); + } + + public function addLine(Request $request, RepairOrder $order): RedirectResponse + { + $validated = $request->validate([ + 'line_type' => 'required|in:part,labor,service', + 'description' => 'required|string|max:255', + 'quantity' => 'required|numeric|min:0.01', + 'unit_price' => 'required|numeric|min:0', + 'product_id' => 'nullable|exists:products,id', + ]); + + $validated['total'] = $validated['quantity'] * $validated['unit_price']; + + $order->lines()->create(array_merge( + ['tenant_id' => app('tenant')->id], + $validated + )); + + return redirect()->back()->with('success', 'Line added.'); + } + + public function removeLine(RepairLine $line): RedirectResponse + { + $line->delete(); + + return redirect()->back()->with('success', 'Line removed.'); + } + + public function confirm(RepairOrder $order): RedirectResponse + { + $order->confirm(); + + return redirect()->back()->with('success', 'Order confirmed.'); + } + + public function start(RepairOrder $order): RedirectResponse + { + $order->start(); + + return redirect()->back()->with('success', 'Order started.'); + } + + public function complete(Request $request, RepairOrder $order): RedirectResponse + { + $validated = $request->validate([ + 'actual_hours' => 'nullable|numeric|min:0', + ]); + + if (!empty($validated['actual_hours'])) { + $order->update(['actual_hours' => $validated['actual_hours']]); + } + + $order->complete(); + + return redirect()->back()->with('success', 'Order completed.'); + } + + public function cancel(RepairOrder $order): RedirectResponse + { + $order->cancel(); + + return redirect()->back()->with('success', 'Order cancelled.'); + } +} diff --git a/erp/app/Modules/Repairs/Models/RepairLine.php b/erp/app/Modules/Repairs/Models/RepairLine.php new file mode 100644 index 00000000000..dd74678edc1 --- /dev/null +++ b/erp/app/Modules/Repairs/Models/RepairLine.php @@ -0,0 +1,41 @@ + 'decimal:2', + 'unit_price' => 'decimal:2', + 'total' => 'decimal:2', + 'is_invoiced' => 'boolean', + ]; + + public function repairOrder(): BelongsTo + { + return $this->belongsTo(RepairOrder::class); + } + + public function product(): BelongsTo + { + return $this->belongsTo(\App\Modules\Inventory\Models\Product::class); + } +} diff --git a/erp/app/Modules/Repairs/Models/RepairOrder.php b/erp/app/Modules/Repairs/Models/RepairOrder.php new file mode 100644 index 00000000000..6befa8432f6 --- /dev/null +++ b/erp/app/Modules/Repairs/Models/RepairOrder.php @@ -0,0 +1,118 @@ + 'date', + 'started_at' => 'datetime', + 'completed_at' => 'datetime', + 'warranty_claim' => 'boolean', + 'estimated_hours' => 'decimal:2', + 'actual_hours' => 'decimal:2', + 'estimated_cost' => 'decimal:2', + 'final_cost' => 'decimal:2', + ]; + + public function lines(): HasMany + { + return $this->hasMany(RepairLine::class); + } + + public function assignedUser(): BelongsTo + { + return $this->belongsTo(User::class, 'assigned_to'); + } + + public function contact(): BelongsTo + { + return $this->belongsTo(\App\Modules\Finance\Models\Contact::class); + } + + public function product(): BelongsTo + { + return $this->belongsTo(\App\Modules\Inventory\Models\Product::class); + } + + public static function generateOrderNumber(int $tenantId): string + { + $count = static::withoutGlobalScopes()->where('tenant_id', $tenantId)->count(); + return 'RO-' . str_pad($count + 1, 5, '0', STR_PAD_LEFT); + } + + public function confirm(): void + { + $this->update(['status' => 'confirmed']); + } + + public function start(): void + { + $this->update([ + 'status' => 'in_progress', + 'started_at' => now(), + ]); + } + + public function complete(): void + { + $finalCost = $this->lines()->sum('total'); + $this->update([ + 'status' => 'done', + 'completed_at' => now(), + 'final_cost' => $finalCost, + ]); + } + + public function cancel(): void + { + $this->update(['status' => 'cancelled']); + } + + public function isOverdue(): bool + { + return $this->scheduled_date + && $this->scheduled_date->isPast() + && !in_array($this->status, ['done', 'cancelled']); + } + + public function totalPartsValue(): float + { + return (float) $this->lines()->where('line_type', 'part')->sum('total'); + } + + public function totalLaborValue(): float + { + return (float) $this->lines()->where('line_type', 'labor')->sum('total'); + } +} diff --git a/erp/app/Modules/Repairs/Providers/RepairsServiceProvider.php b/erp/app/Modules/Repairs/Providers/RepairsServiceProvider.php new file mode 100644 index 00000000000..e66d24e4813 --- /dev/null +++ b/erp/app/Modules/Repairs/Providers/RepairsServiceProvider.php @@ -0,0 +1,15 @@ +loadRoutesFrom(__DIR__ . '/../routes/repairs.php'); + } +} diff --git a/erp/app/Modules/Repairs/routes/repairs.php b/erp/app/Modules/Repairs/routes/repairs.php new file mode 100644 index 00000000000..3faf4fad466 --- /dev/null +++ b/erp/app/Modules/Repairs/routes/repairs.php @@ -0,0 +1,20 @@ +prefix('repairs')->name('repairs.')->group(function () { + Route::get('dashboard', [RepairController::class, 'dashboard'])->name('dashboard'); + + Route::get('orders', [RepairController::class, 'index'])->name('orders.index'); + Route::post('orders', [RepairController::class, 'store'])->name('orders.store'); + Route::get('orders/{order}', [RepairController::class, 'show'])->name('orders.show'); + Route::patch('orders/{order}', [RepairController::class, 'update'])->name('orders.update'); + Route::post('orders/{order}/confirm', [RepairController::class, 'confirm'])->name('orders.confirm'); + Route::post('orders/{order}/start', [RepairController::class, 'start'])->name('orders.start'); + Route::post('orders/{order}/complete', [RepairController::class, 'complete'])->name('orders.complete'); + Route::post('orders/{order}/cancel', [RepairController::class, 'cancel'])->name('orders.cancel'); + Route::post('orders/{order}/lines', [RepairController::class, 'addLine'])->name('orders.lines.store'); + + Route::delete('lines/{line}', [RepairController::class, 'removeLine'])->name('lines.destroy'); +}); diff --git a/erp/app/Modules/Sign/Http/Controllers/SignController.php b/erp/app/Modules/Sign/Http/Controllers/SignController.php new file mode 100644 index 00000000000..6308e03d1ac --- /dev/null +++ b/erp/app/Modules/Sign/Http/Controllers/SignController.php @@ -0,0 +1,150 @@ +status, fn ($q) => $q->where('status', $request->status)) + ->with(['signers', 'creator']) + ->orderByDesc('created_at') + ->paginate(20) + ->withQueryString(); + + return Inertia::render('Sign/Index', [ + 'signRequests' => $signRequests, + 'filters' => $request->only(['status']), + ]); + } + + public function show(SignRequest $signRequest): Response + { + $signRequest->load(['signers', 'creator']); + + return Inertia::render('Sign/Show', [ + 'signRequest' => $signRequest, + ]); + } + + public function store(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'title' => 'required|string|max:255', + 'document_path' => 'required|string|max:255', + 'document_name' => 'required|string|max:255', + 'message' => 'nullable|string', + 'signers' => 'nullable|array', + 'signers.*.name' => 'required_with:signers|string|max:255', + 'signers.*.email' => 'required_with:signers|email|max:255', + 'signers.*.sequence' => 'nullable|integer', + ]); + + $signRequest = SignRequest::create([ + 'title' => $validated['title'], + 'document_path' => $validated['document_path'], + 'document_name' => $validated['document_name'], + 'message' => $validated['message'] ?? null, + 'tenant_id' => auth()->user()->tenant_id, + 'created_by' => auth()->id(), + 'status' => 'draft', + ]); + + foreach ($validated['signers'] ?? [] as $signerData) { + $signer = new SignRequestSigner([ + 'sign_request_id' => $signRequest->id, + 'tenant_id' => $signRequest->tenant_id, + 'signer_name' => $signerData['name'], + 'signer_email' => $signerData['email'], + 'sequence' => $signerData['sequence'] ?? 0, + 'status' => 'pending', + ]); + $signer->token = $signer->generateToken(); + $signer->save(); + } + + return redirect()->route('sign.show', $signRequest)->with('success', 'Sign request created.'); + } + + public function destroy(SignRequest $signRequest): RedirectResponse + { + abort_if( + ! in_array($signRequest->status, ['draft', 'cancelled']), + 403, + 'Only draft or cancelled requests can be deleted.' + ); + + $signRequest->delete(); + + return redirect()->route('sign.index')->with('success', 'Sign request deleted.'); + } + + public function send(SignRequest $signRequest): RedirectResponse + { + $signRequest->send(); + + return redirect()->back()->with('success', 'Sign request sent.'); + } + + public function cancel(SignRequest $signRequest): RedirectResponse + { + $signRequest->cancel(); + + return redirect()->back()->with('success', 'Sign request cancelled.'); + } + + public function addSigner(Request $request, SignRequest $signRequest): RedirectResponse + { + abort_if($signRequest->status !== 'draft', 403, 'Signers can only be added to draft requests.'); + + $validated = $request->validate([ + 'signer_name' => 'required|string|max:255', + 'signer_email' => 'required|email|max:255', + 'sequence' => 'nullable|integer', + ]); + + $signer = new SignRequestSigner([ + 'sign_request_id' => $signRequest->id, + 'tenant_id' => $signRequest->tenant_id, + 'signer_name' => $validated['signer_name'], + 'signer_email' => $validated['signer_email'], + 'sequence' => $validated['sequence'] ?? 0, + 'status' => 'pending', + ]); + $signer->token = $signer->generateToken(); + $signer->save(); + + return redirect()->back()->with('success', 'Signer added.'); + } + + public function removeSigner(SignRequest $signRequest, SignRequestSigner $signer): RedirectResponse + { + abort_if($signRequest->status !== 'draft', 403, 'Signers can only be removed from draft requests.'); + + $signer->delete(); + + return redirect()->back()->with('success', 'Signer removed.'); + } + + public function sign(SignRequest $signRequest, SignRequestSigner $signer): RedirectResponse + { + $signer->sign(); + + return redirect()->back()->with('success', 'Signed successfully.'); + } + + public function decline(SignRequest $signRequest, SignRequestSigner $signer): RedirectResponse + { + $signer->decline(); + + return redirect()->back()->with('success', 'Declined.'); + } +} diff --git a/erp/app/Modules/Sign/Models/SignRequest.php b/erp/app/Modules/Sign/Models/SignRequest.php new file mode 100644 index 00000000000..3b23a24c055 --- /dev/null +++ b/erp/app/Modules/Sign/Models/SignRequest.php @@ -0,0 +1,80 @@ + 'datetime', + ]; + + public function signers(): HasMany + { + return $this->hasMany(SignRequestSigner::class)->orderBy('sequence'); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function send(): void + { + $this->status = 'sent'; + $this->save(); + + foreach ($this->signers as $signer) { + if (empty($signer->token)) { + $signer->token = $signer->generateToken(); + $signer->save(); + } + } + } + + public function cancel(): void + { + $this->status = 'cancelled'; + $this->save(); + } + + public function checkCompletion(): void + { + if ($this->allSigned()) { + $this->status = 'completed'; + $this->completed_at = now(); + $this->save(); + } + } + + public function pendingSigners(): Collection + { + return $this->signers()->where('status', 'pending')->get(); + } + + public function allSigned(): bool + { + return $this->signers()->exists() + && $this->signers()->where('status', '!=', 'signed')->doesntExist(); + } +} diff --git a/erp/app/Modules/Sign/Models/SignRequestSigner.php b/erp/app/Modules/Sign/Models/SignRequestSigner.php new file mode 100644 index 00000000000..7f119d5f508 --- /dev/null +++ b/erp/app/Modules/Sign/Models/SignRequestSigner.php @@ -0,0 +1,56 @@ + 'datetime', + 'declined_at' => 'datetime', + ]; + + public function request(): BelongsTo + { + return $this->belongsTo(SignRequest::class, 'sign_request_id'); + } + + public function sign(): void + { + $this->status = 'signed'; + $this->signed_at = now(); + $this->save(); + + $this->request->checkCompletion(); + } + + public function decline(): void + { + $this->status = 'declined'; + $this->declined_at = now(); + $this->save(); + } + + public function generateToken(): string + { + return Str::random(40); + } +} diff --git a/erp/app/Modules/Sign/Providers/SignServiceProvider.php b/erp/app/Modules/Sign/Providers/SignServiceProvider.php new file mode 100644 index 00000000000..a3e09813f10 --- /dev/null +++ b/erp/app/Modules/Sign/Providers/SignServiceProvider.php @@ -0,0 +1,16 @@ +loadRoutesFrom(__DIR__ . '/../routes/sign.php'); + $this->loadMigrationsFrom(__DIR__ . '/../../../database/migrations'); + } +} diff --git a/erp/app/Modules/Sign/routes/sign.php b/erp/app/Modules/Sign/routes/sign.php new file mode 100644 index 00000000000..d566545492e --- /dev/null +++ b/erp/app/Modules/Sign/routes/sign.php @@ -0,0 +1,17 @@ +prefix('sign')->name('sign.')->group(function () { + Route::post('{signRequest}/send', [SignController::class, 'send'])->name('send'); + Route::post('{signRequest}/cancel', [SignController::class, 'cancel'])->name('cancel'); + Route::post('{signRequest}/signers', [SignController::class, 'addSigner'])->name('signers.store'); + Route::delete('{signRequest}/signers/{signer}', [SignController::class, 'removeSigner'])->name('signers.destroy'); + Route::post('{signRequest}/signers/{signer}/sign', [SignController::class, 'sign'])->name('signers.sign'); + Route::post('{signRequest}/signers/{signer}/decline', [SignController::class, 'decline'])->name('signers.decline'); + Route::get('', [SignController::class, 'index'])->name('index'); + Route::post('', [SignController::class, 'store'])->name('store'); + Route::get('{signRequest}', [SignController::class, 'show'])->name('show'); + Route::delete('{signRequest}', [SignController::class, 'destroy'])->name('destroy'); +}); diff --git a/erp/app/Modules/SocialMarketing/Http/Controllers/SocialMarketingController.php b/erp/app/Modules/SocialMarketing/Http/Controllers/SocialMarketingController.php new file mode 100644 index 00000000000..01f0be6ac7e --- /dev/null +++ b/erp/app/Modules/SocialMarketing/Http/Controllers/SocialMarketingController.php @@ -0,0 +1,165 @@ +count(); + $totalPosts = SocialPost::count(); + $scheduledPosts = SocialPost::where('status', 'scheduled')->count(); + + $publishedThisMonth = SocialPost::where('status', 'published') + ->whereYear('published_at', now()->year) + ->whereMonth('published_at', now()->month) + ->count(); + + $totalReach = SocialPost::where('status', 'published') + ->get() + ->sum(fn ($post) => $post->getTotalReach()); + + return Inertia::render('SocialMarketing/Dashboard', [ + 'stats' => [ + 'total_accounts' => $totalAccounts, + 'connected_accounts' => $connectedAccounts, + 'total_posts' => $totalPosts, + 'scheduled_posts' => $scheduledPosts, + 'published_this_month' => $publishedThisMonth, + 'total_reach' => $totalReach, + ], + ]); + } + + public function accounts(): Response + { + $accounts = SocialAccount::orderBy('platform')->get(); + + return Inertia::render('SocialMarketing/Accounts/Index', [ + 'accounts' => $accounts, + ]); + } + + public function storeAccount(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'platform' => ['required', 'string', 'in:facebook,twitter,linkedin,instagram,youtube,tiktok'], + 'account_name' => ['required', 'string'], + 'account_handle' => ['nullable', 'string'], + 'followers_count' => ['nullable', 'integer'], + ]); + + SocialAccount::create($validated); + + return back()->with('success', 'Account connected successfully.'); + } + + public function toggleAccount(SocialAccount $account): RedirectResponse + { + if ($account->is_connected) { + $account->disconnect(); + } else { + $account->reconnect(); + } + + return back()->with('success', 'Account connection toggled.'); + } + + public function posts(Request $request): Response + { + $query = SocialPost::with('author')->orderByDesc('created_at'); + + if ($request->filled('status')) { + $query->where('status', $request->input('status')); + } + + $posts = $query->paginate(20); + + return Inertia::render('SocialMarketing/Posts/Index', [ + 'posts' => $posts, + 'currentStatus' => $request->input('status', ''), + ]); + } + + public function createPost(): Response + { + $accounts = SocialAccount::where('is_connected', true)->get(); + + return Inertia::render('SocialMarketing/Posts/Create', [ + 'accounts' => $accounts, + ]); + } + + public function storePost(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'content' => ['required', 'string', 'max:2000'], + 'platforms' => ['required', 'array'], + 'platforms.*' => ['string', 'in:facebook,twitter,linkedin,instagram,youtube,tiktok'], + 'social_account_ids' => ['nullable', 'array'], + 'scheduled_at' => ['nullable', 'date_format:Y-m-d H:i:s'], + ]); + + $post = SocialPost::create(array_merge($validated, [ + 'status' => 'draft', + 'created_by' => auth()->id(), + 'social_account_ids' => $validated['social_account_ids'] ?? [], + ])); + + if (!empty($validated['scheduled_at'])) { + $post->schedule(Carbon::parse($validated['scheduled_at'])); + } + + return redirect()->route('social-marketing.posts')->with('success', 'Post created successfully.'); + } + + public function updatePost(Request $request, SocialPost $post): RedirectResponse + { + $validated = $request->validate([ + 'content' => ['required', 'string', 'max:2000'], + 'platforms' => ['required', 'array'], + 'platforms.*' => ['string', 'in:facebook,twitter,linkedin,instagram,youtube,tiktok'], + 'social_account_ids' => ['nullable', 'array'], + 'scheduled_at' => ['nullable', 'date_format:Y-m-d H:i:s'], + ]); + + $post->update($validated); + + return back()->with('success', 'Post updated successfully.'); + } + + public function publishPost(SocialPost $post): RedirectResponse + { + $post->publish(); + + return back()->with('success', 'Post published successfully.'); + } + + public function schedulePost(Request $request, SocialPost $post): RedirectResponse + { + $validated = $request->validate([ + 'scheduled_at' => ['required'], + ]); + + $post->schedule(Carbon::parse($validated['scheduled_at'])); + + return back()->with('success', 'Post scheduled successfully.'); + } + + public function deletePost(SocialPost $post): RedirectResponse + { + $post->delete(); + + return back()->with('success', 'Post deleted successfully.'); + } +} diff --git a/erp/app/Modules/SocialMarketing/Models/SocialAccount.php b/erp/app/Modules/SocialMarketing/Models/SocialAccount.php new file mode 100644 index 00000000000..ecc86019b1f --- /dev/null +++ b/erp/app/Modules/SocialMarketing/Models/SocialAccount.php @@ -0,0 +1,71 @@ + 'boolean', + 'is_active' => 'boolean', + 'followers_count' => 'integer', + 'following_count' => 'integer', + 'last_synced_at' => 'datetime', + ]; + + public function posts(): HasMany + { + return $this->hasMany(SocialPost::class)->whereJsonContains('social_account_ids', $this->id); + } + + public function getPlatformColor(): string + { + return match ($this->platform) { + 'facebook' => '#1877F2', + 'twitter' => '#1DA1F2', + 'linkedin' => '#0A66C2', + 'instagram' => '#E4405F', + 'youtube' => '#FF0000', + 'tiktok' => '#000000', + default => '#6B7280', + }; + } + + public function getPlatformIcon(): string + { + return $this->platform; + } + + public function disconnect(): void + { + $this->update(['is_connected' => false]); + } + + public function reconnect(): void + { + $this->update([ + 'is_connected' => true, + 'last_synced_at' => now(), + ]); + } +} diff --git a/erp/app/Modules/SocialMarketing/Models/SocialPost.php b/erp/app/Modules/SocialMarketing/Models/SocialPost.php new file mode 100644 index 00000000000..029b9f33721 --- /dev/null +++ b/erp/app/Modules/SocialMarketing/Models/SocialPost.php @@ -0,0 +1,88 @@ + 'array', + 'platforms' => 'array', + 'social_account_ids' => 'array', + 'metrics' => 'array', + 'scheduled_at' => 'datetime', + 'published_at' => 'datetime', + ]; + + public function author(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function schedule(Carbon $date): void + { + $this->update([ + 'status' => 'scheduled', + 'scheduled_at' => $date, + ]); + } + + public function publish(): void + { + $this->update([ + 'status' => 'published', + 'published_at' => now(), + ]); + } + + public function markFailed(string $error): void + { + $this->update([ + 'status' => 'failed', + 'error_message' => $error, + ]); + } + + public function getTotalReach(): int + { + return $this->metrics['reach'] ?? 0; + } + + public function getTotalEngagement(): int + { + return ($this->metrics['likes'] ?? 0) + + ($this->metrics['shares'] ?? 0) + + ($this->metrics['comments'] ?? 0); + } + + public function isScheduled(): bool + { + return $this->status === 'scheduled' + && $this->scheduled_at !== null + && $this->scheduled_at->isFuture(); + } +} diff --git a/erp/app/Modules/SocialMarketing/Providers/SocialMarketingServiceProvider.php b/erp/app/Modules/SocialMarketing/Providers/SocialMarketingServiceProvider.php new file mode 100644 index 00000000000..0708f6408ac --- /dev/null +++ b/erp/app/Modules/SocialMarketing/Providers/SocialMarketingServiceProvider.php @@ -0,0 +1,15 @@ +loadRoutesFrom(__DIR__ . '/../routes/social_marketing.php'); + } +} diff --git a/erp/app/Modules/SocialMarketing/routes/social_marketing.php b/erp/app/Modules/SocialMarketing/routes/social_marketing.php new file mode 100644 index 00000000000..ee6bbc4b2d4 --- /dev/null +++ b/erp/app/Modules/SocialMarketing/routes/social_marketing.php @@ -0,0 +1,18 @@ +prefix('social-marketing')->name('social-marketing.')->group(function () { + Route::get('dashboard', [SocialMarketingController::class, 'dashboard'])->name('dashboard'); + Route::get('accounts', [SocialMarketingController::class, 'accounts'])->name('accounts'); + Route::post('accounts', [SocialMarketingController::class, 'storeAccount'])->name('accounts.store'); + Route::post('accounts/{account}/toggle', [SocialMarketingController::class, 'toggleAccount'])->name('accounts.toggle'); + Route::get('posts', [SocialMarketingController::class, 'posts'])->name('posts'); + Route::get('posts/create', [SocialMarketingController::class, 'createPost'])->name('posts.create'); + Route::post('posts', [SocialMarketingController::class, 'storePost'])->name('posts.store'); + Route::patch('posts/{post}', [SocialMarketingController::class, 'updatePost'])->name('posts.update'); + Route::post('posts/{post}/publish', [SocialMarketingController::class, 'publishPost'])->name('posts.publish'); + Route::post('posts/{post}/schedule', [SocialMarketingController::class, 'schedulePost'])->name('posts.schedule'); + Route::delete('posts/{post}', [SocialMarketingController::class, 'deletePost'])->name('posts.destroy'); +}); diff --git a/erp/app/Modules/Subcontracting/Http/Controllers/SubcontractController.php b/erp/app/Modules/Subcontracting/Http/Controllers/SubcontractController.php new file mode 100644 index 00000000000..4d7fb84f243 --- /dev/null +++ b/erp/app/Modules/Subcontracting/Http/Controllers/SubcontractController.php @@ -0,0 +1,180 @@ +when($request->status, fn ($q) => $q->byStatus($request->status)) + ->orderByDesc('created_at') + ->paginate(20) + ->withQueryString(); + + return Inertia::render('Subcontracting/Index', [ + 'orders' => $orders, + 'filters' => $request->only(['status']), + ]); + } + + public function show(SubcontractOrder $order): Response + { + $order->load('components'); + + return Inertia::render('Subcontracting/Show', [ + 'order' => $order, + ]); + } + + public function store(Request $request): RedirectResponse|JsonResponse + { + $validated = $request->validate([ + 'reference' => 'required|string|max:255|unique:subcontracts,reference', + 'vendor_id' => 'nullable|exists:users,id', + 'finished_product' => 'required|string|max:255', + 'finished_qty' => 'required|numeric|min:0.0001', + 'unit_price' => 'required|numeric|min:0', + 'notes' => 'nullable|string', + 'components' => 'nullable|array', + 'components.*.component_name' => 'required_with:components|string|max:255', + 'components.*.quantity' => 'required_with:components|numeric|min:0.0001', + 'components.*.unit' => 'nullable|string|max:50', + ]); + + $order = SubcontractOrder::create([ + 'tenant_id' => auth()->user()->tenant_id, + 'vendor_id' => $validated['vendor_id'] ?? null, + 'reference' => $validated['reference'], + 'finished_product' => $validated['finished_product'], + 'finished_qty' => $validated['finished_qty'], + 'unit_price' => $validated['unit_price'], + 'notes' => $validated['notes'] ?? null, + 'status' => 'draft', + ]); + + foreach ($validated['components'] ?? [] as $comp) { + $order->components()->create([ + 'tenant_id' => $order->tenant_id, + 'component_name' => $comp['component_name'], + 'quantity' => $comp['quantity'], + 'unit' => $comp['unit'] ?? 'pcs', + ]); + } + + if ($request->wantsJson()) { + return response()->json($order->load('components'), 201); + } + + return redirect()->route('subcontracting.orders.show', $order) + ->with('success', 'Subcontract order created successfully.'); + } + + public function update(Request $request, SubcontractOrder $order): RedirectResponse + { + abort_if($order->status !== 'draft', 403, 'Only draft orders can be updated.'); + + $validated = $request->validate([ + 'reference' => 'required|string|max:255|unique:subcontracts,reference,' . $order->id, + 'vendor_id' => 'nullable|exists:users,id', + 'finished_product' => 'required|string|max:255', + 'finished_qty' => 'required|numeric|min:0.0001', + 'unit_price' => 'required|numeric|min:0', + 'notes' => 'nullable|string', + ]); + + $order->update($validated); + + return redirect()->route('subcontracting.orders.show', $order) + ->with('success', 'Subcontract order updated successfully.'); + } + + public function destroy(SubcontractOrder $order): RedirectResponse + { + abort_if( + ! in_array($order->status, ['draft', 'cancelled'], true), + 403, + 'Only draft or cancelled orders can be deleted.' + ); + + $order->delete(); + + return redirect()->route('subcontracting.orders.index') + ->with('success', 'Subcontract order deleted.'); + } + + public function send(SubcontractOrder $order): RedirectResponse + { + abort_if(! $order->canTransitionTo('sent'), 422, 'Cannot send order in current status.'); + + $order->send(); + + return redirect()->back()->with('success', 'Order sent to vendor.'); + } + + public function startProduction(SubcontractOrder $order): RedirectResponse + { + abort_if(! $order->canTransitionTo('in_progress'), 422, 'Cannot start production in current status.'); + + $order->startProduction(); + + return redirect()->back()->with('success', 'Production started.'); + } + + public function receive(SubcontractOrder $order): RedirectResponse + { + abort_if(! $order->canTransitionTo('received'), 422, 'Cannot receive order in current status.'); + + $order->receive(); + + return redirect()->back()->with('success', 'Finished goods received.'); + } + + public function cancel(SubcontractOrder $order): RedirectResponse + { + abort_if(! $order->canTransitionTo('cancelled'), 422, 'Cannot cancel order in current status.'); + + $order->cancel(); + + return redirect()->back()->with('success', 'Order cancelled.'); + } + + public function addComponent(Request $request, SubcontractOrder $order): RedirectResponse + { + abort_if($order->status !== 'draft', 403, 'Components can only be added to draft orders.'); + + $validated = $request->validate([ + 'component_name' => 'required|string|max:255', + 'quantity' => 'required|numeric|min:0.0001', + 'unit' => 'nullable|string|max:50', + ]); + + $order->components()->create([ + 'tenant_id' => $order->tenant_id, + 'component_name' => $validated['component_name'], + 'quantity' => $validated['quantity'], + 'unit' => $validated['unit'] ?? 'pcs', + ]); + + return redirect()->back()->with('success', 'Component added.'); + } + + public function removeComponent(SubcontractOrder $order, SubcontractComponent $component): RedirectResponse + { + abort_if($component->subcontract_id !== $order->id, 404); + abort_if($order->status !== 'draft', 403, 'Components can only be removed from draft orders.'); + + $component->delete(); + + return redirect()->back()->with('success', 'Component removed.'); + } +} diff --git a/erp/app/Modules/Subcontracting/Models/SubcontractComponent.php b/erp/app/Modules/Subcontracting/Models/SubcontractComponent.php new file mode 100644 index 00000000000..3ba1bdef227 --- /dev/null +++ b/erp/app/Modules/Subcontracting/Models/SubcontractComponent.php @@ -0,0 +1,31 @@ + 'float', + ]; + + public function order(): BelongsTo + { + return $this->belongsTo(SubcontractOrder::class, 'subcontract_id'); + } +} diff --git a/erp/app/Modules/Subcontracting/Models/SubcontractOrder.php b/erp/app/Modules/Subcontracting/Models/SubcontractOrder.php new file mode 100644 index 00000000000..47ec82e44c9 --- /dev/null +++ b/erp/app/Modules/Subcontracting/Models/SubcontractOrder.php @@ -0,0 +1,90 @@ + 'float', + 'unit_price' => 'float', + 'sent_at' => 'datetime', + 'received_at' => 'datetime', + ]; + + /** @param \Illuminate\Database\Eloquent\Builder $query */ + public function scopeByStatus($query, string $status): void + { + $query->where('status', $status); + } + + public function components(): HasMany + { + return $this->hasMany(SubcontractComponent::class, 'subcontract_id'); + } + + public function send(): void + { + $this->status = 'sent'; + $this->sent_at = now(); + $this->save(); + } + + public function startProduction(): void + { + $this->status = 'in_progress'; + $this->save(); + } + + public function receive(): void + { + $this->status = 'received'; + $this->received_at = now(); + $this->save(); + } + + public function cancel(): void + { + $this->status = 'cancelled'; + $this->save(); + } + + public function totalCost(): float + { + return (float) ($this->unit_price * $this->finished_qty); + } + + public function canTransitionTo(string $status): bool + { + $allowed = [ + 'draft' => ['sent', 'cancelled'], + 'sent' => ['in_progress', 'cancelled'], + 'in_progress' => ['received', 'cancelled'], + 'received' => [], + 'cancelled' => [], + ]; + + return in_array($status, $allowed[$this->status] ?? [], true); + } +} diff --git a/erp/app/Modules/Subcontracting/Providers/SubcontractingServiceProvider.php b/erp/app/Modules/Subcontracting/Providers/SubcontractingServiceProvider.php new file mode 100644 index 00000000000..424310fc465 --- /dev/null +++ b/erp/app/Modules/Subcontracting/Providers/SubcontractingServiceProvider.php @@ -0,0 +1,16 @@ +loadRoutesFrom(__DIR__ . '/../routes/subcontracting.php'); + $this->loadMigrationsFrom(__DIR__ . '/../../../database/migrations'); + } +} diff --git a/erp/app/Modules/Subcontracting/routes/subcontracting.php b/erp/app/Modules/Subcontracting/routes/subcontracting.php new file mode 100644 index 00000000000..0f0a1b48b77 --- /dev/null +++ b/erp/app/Modules/Subcontracting/routes/subcontracting.php @@ -0,0 +1,15 @@ +prefix('subcontracting')->name('subcontracting.')->group(function () { + // Custom action routes BEFORE resource + Route::post('orders/{order}/send', [SubcontractController::class, 'send'])->name('orders.send'); + Route::post('orders/{order}/start-production', [SubcontractController::class, 'startProduction'])->name('orders.start-production'); + Route::post('orders/{order}/receive', [SubcontractController::class, 'receive'])->name('orders.receive'); + Route::post('orders/{order}/cancel', [SubcontractController::class, 'cancel'])->name('orders.cancel'); + Route::post('orders/{order}/components', [SubcontractController::class, 'addComponent'])->name('orders.components.store'); + Route::delete('orders/{order}/components/{component}', [SubcontractController::class, 'removeComponent'])->name('orders.components.destroy'); + Route::resource('orders', SubcontractController::class)->except(['create', 'edit']); +}); diff --git a/erp/app/Modules/Subscriptions/Http/Controllers/SubscriptionController.php b/erp/app/Modules/Subscriptions/Http/Controllers/SubscriptionController.php new file mode 100644 index 00000000000..eb048a8c343 --- /dev/null +++ b/erp/app/Modules/Subscriptions/Http/Controllers/SubscriptionController.php @@ -0,0 +1,215 @@ +filled('status')) { + $query->where('status', $request->status); + } + + if ($request->filled('plan_id')) { + $query->where('plan_id', $request->plan_id); + } + + $subscriptions = $query->latest()->paginate(20)->withQueryString(); + + $mrr = Subscription::with('plan') + ->whereIn('status', ['trial', 'active']) + ->get() + ->sum(fn ($s) => $s->mrr()); + + $plans = SubscriptionPlan::where('is_active', true)->orderBy('name')->get(); + + return Inertia::render('Subscriptions/Index', [ + 'subscriptions' => $subscriptions, + 'plans' => $plans, + 'mrr' => $mrr, + 'filters' => $request->only(['status', 'plan_id']), + ]); + } + + public function plans(Request $request): Response|JsonResponse + { + $plans = SubscriptionPlan::where('is_active', true)->orderBy('name')->get(); + + if ($request->wantsJson()) { + return response()->json($plans); + } + + return Inertia::render('Subscriptions/Plans', [ + 'plans' => $plans, + ]); + } + + public function show(Subscription $subscription): Response + { + $subscription->load([ + 'plan', + 'invoices' => fn ($q) => $q->latest()->limit(10), + ]); + + return Inertia::render('Subscriptions/Show', [ + 'subscription' => $subscription, + ]); + } + + public function storePlan(Request $request): RedirectResponse|JsonResponse + { + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'description' => 'nullable|string', + 'billing_cycle' => 'required|in:monthly,quarterly,annual', + 'price' => 'required|numeric|min:0', + 'trial_days' => 'nullable|integer|min:0', + 'is_active' => 'nullable|boolean', + ]); + + $plan = SubscriptionPlan::create($validated); + + if ($request->wantsJson()) { + return response()->json($plan, 201); + } + + return redirect()->route('subscriptions.index')->with('success', 'Plan created successfully.'); + } + + public function store(Request $request): RedirectResponse|JsonResponse + { + $validated = $request->validate([ + 'plan_id' => 'required|integer', + 'customer_name' => 'required|string|max:255', + 'customer_email' => 'required|email|max:255', + 'status' => 'nullable|in:trial,active,past_due,cancelled,expired', + 'notes' => 'nullable|string', + ]); + + $plan = SubscriptionPlan::findOrFail($validated['plan_id']); + + if (! $plan->is_active) { + if ($request->wantsJson()) { + return response()->json(['message' => 'The selected plan is not active.'], 422); + } + return redirect()->back()->withErrors(['plan_id' => 'The selected plan is not active.']); + } + + $today = Carbon::today(); + $cycleDays = $plan->cycleDays(); + + $subscription = Subscription::create([ + 'plan_id' => $plan->id, + 'customer_name' => $validated['customer_name'], + 'customer_email' => $validated['customer_email'], + 'status' => $validated['status'] ?? 'active', + 'notes' => $validated['notes'] ?? null, + 'current_period_start' => $today, + 'current_period_end' => $today->copy()->addDays($cycleDays - 1), + ]); + + // Create initial pending invoice + $subscription->invoices()->create([ + 'tenant_id' => $subscription->tenant_id, + 'amount' => $plan->price, + 'status' => 'pending', + 'due_date' => $today, + 'period_start' => $today, + 'period_end' => $today->copy()->addDays($cycleDays - 1), + ]); + + if ($request->wantsJson()) { + return response()->json($subscription->load('plan', 'invoices'), 201); + } + + return redirect()->route('subscriptions.show', $subscription)->with('success', 'Subscription created successfully.'); + } + + public function cancel(Subscription $subscription): RedirectResponse|JsonResponse + { + $subscription->cancel(); + + if (request()->wantsJson()) { + return response()->json($subscription->fresh()); + } + + return redirect()->route('subscriptions.show', $subscription)->with('success', 'Subscription cancelled.'); + } + + public function renew(Subscription $subscription): RedirectResponse|JsonResponse + { + $invoice = $subscription->renew(); + + if (request()->wantsJson()) { + return response()->json([ + 'subscription' => $subscription->fresh()->load('plan'), + 'invoice' => $invoice, + ]); + } + + return redirect()->route('subscriptions.show', $subscription)->with('success', 'Subscription renewed.'); + } + + public function payInvoice(Subscription $subscription, SubscriptionInvoice $invoice): RedirectResponse|JsonResponse + { + $invoice->markPaid(); + + if (request()->wantsJson()) { + return response()->json($invoice->fresh()); + } + + return redirect()->route('subscriptions.show', $subscription)->with('success', 'Invoice marked as paid.'); + } + + public function metrics(Request $request): Response|JsonResponse + { + $activeSubscriptions = Subscription::with('plan') + ->whereIn('status', ['trial', 'active']) + ->get(); + + $mrr = $activeSubscriptions->sum(fn ($s) => $s->mrr()); + + $activeCount = Subscription::where('status', 'active')->count(); + $trialCount = Subscription::where('status', 'trial')->count(); + + // Churn rate: cancelled this month / active last month + $cancelledThisMonth = Subscription::where('status', 'cancelled') + ->whereMonth('cancelled_at', now()->month) + ->whereYear('cancelled_at', now()->year) + ->count(); + + $activeLastMonth = Subscription::whereIn('status', ['active', 'trial', 'cancelled', 'past_due', 'expired']) + ->where('created_at', '<=', now()->startOfMonth()) + ->count(); + + $churnRate = $activeLastMonth > 0 + ? round(($cancelledThisMonth / $activeLastMonth) * 100, 2) + : 0.0; + + $metricsData = [ + 'mrr' => $mrr, + 'active_count' => $activeCount, + 'trial_count' => $trialCount, + 'churn_rate' => $churnRate, + ]; + + if ($request->wantsJson()) { + return response()->json($metricsData); + } + + return Inertia::render('Subscriptions/Metrics', $metricsData); + } +} diff --git a/erp/app/Modules/Subscriptions/Models/Subscription.php b/erp/app/Modules/Subscriptions/Models/Subscription.php new file mode 100644 index 00000000000..af22161dd7d --- /dev/null +++ b/erp/app/Modules/Subscriptions/Models/Subscription.php @@ -0,0 +1,98 @@ + 'datetime', + 'cancelled_at' => 'datetime', + 'current_period_start' => 'date', + 'current_period_end' => 'date', + ]; + + public function plan(): BelongsTo + { + return $this->belongsTo(SubscriptionPlan::class, 'plan_id'); + } + + public function invoices(): HasMany + { + return $this->hasMany(SubscriptionInvoice::class, 'subscription_id'); + } + + public function cancel(): void + { + $this->status = 'cancelled'; + $this->cancelled_at = now(); + $this->save(); + } + + public function renew(): SubscriptionInvoice + { + $plan = $this->plan; + $cycleDays = $plan->cycleDays(); + + $newStart = $this->current_period_end->copy()->addDay(); + $newEnd = $newStart->copy()->addDays($cycleDays - 1); + + $invoice = $this->invoices()->create([ + 'tenant_id' => $this->tenant_id, + 'amount' => $plan->price, + 'status' => 'pending', + 'due_date' => $newStart, + 'period_start' => $newStart, + 'period_end' => $newEnd, + ]); + + $this->status = 'active'; + $this->current_period_start = $newStart; + $this->current_period_end = $newEnd; + $this->save(); + + event(new \App\Events\Subscriptions\SubscriptionRenewed($this)); + + return $invoice; + } + + public function isActive(): bool + { + return in_array($this->status, ['trial', 'active']); + } + + public function daysUntilRenewal(): int + { + return (int) Carbon::today()->diffInDays($this->current_period_end, false); + } + + public function mrr(): float + { + if (! $this->isActive()) { + return 0.0; + } + + return $this->plan->monthlyEquivalent(); + } +} diff --git a/erp/app/Modules/Subscriptions/Models/SubscriptionInvoice.php b/erp/app/Modules/Subscriptions/Models/SubscriptionInvoice.php new file mode 100644 index 00000000000..75ad9cea451 --- /dev/null +++ b/erp/app/Modules/Subscriptions/Models/SubscriptionInvoice.php @@ -0,0 +1,50 @@ + 'date', + 'period_start' => 'date', + 'period_end' => 'date', + 'paid_at' => 'datetime', + ]; + + public function subscription(): BelongsTo + { + return $this->belongsTo(Subscription::class, 'subscription_id'); + } + + public function markPaid(): void + { + $this->status = 'paid'; + $this->paid_at = now(); + $this->save(); + } + + public function markFailed(): void + { + $this->status = 'failed'; + $this->save(); + + $this->subscription()->update(['status' => 'past_due']); + } +} diff --git a/erp/app/Modules/Subscriptions/Models/SubscriptionPlan.php b/erp/app/Modules/Subscriptions/Models/SubscriptionPlan.php new file mode 100644 index 00000000000..b0d03b9ebdb --- /dev/null +++ b/erp/app/Modules/Subscriptions/Models/SubscriptionPlan.php @@ -0,0 +1,55 @@ + 'boolean', + 'price' => 'decimal:2', + ]; + + public function subscriptions(): HasMany + { + return $this->hasMany(Subscription::class, 'plan_id'); + } + + public function monthlyEquivalent(): float + { + $months = match ($this->billing_cycle) { + 'monthly' => 1, + 'quarterly' => 3, + 'annual' => 12, + default => 1, + }; + + return (float) ($this->price / $months); + } + + public function cycleDays(): int + { + return match ($this->billing_cycle) { + 'monthly' => 30, + 'quarterly' => 90, + 'annual' => 365, + default => 30, + }; + } +} diff --git a/erp/app/Modules/Subscriptions/Providers/SubscriptionsServiceProvider.php b/erp/app/Modules/Subscriptions/Providers/SubscriptionsServiceProvider.php new file mode 100644 index 00000000000..407a38b6a92 --- /dev/null +++ b/erp/app/Modules/Subscriptions/Providers/SubscriptionsServiceProvider.php @@ -0,0 +1,15 @@ +loadRoutesFrom(__DIR__ . '/../routes/subscriptions.php'); + } +} diff --git a/erp/app/Modules/Subscriptions/routes/subscriptions.php b/erp/app/Modules/Subscriptions/routes/subscriptions.php new file mode 100644 index 00000000000..2a3c53ad5fb --- /dev/null +++ b/erp/app/Modules/Subscriptions/routes/subscriptions.php @@ -0,0 +1,16 @@ +prefix('subscriptions')->name('subscriptions.')->group(function () { + Route::get('metrics', [SubscriptionController::class, 'metrics'])->name('metrics'); + Route::get('plans', [SubscriptionController::class, 'plans'])->name('plans.index'); + Route::post('plans', [SubscriptionController::class, 'storePlan'])->name('plans.store'); + Route::post('{subscription}/cancel', [SubscriptionController::class, 'cancel'])->name('cancel'); + Route::post('{subscription}/renew', [SubscriptionController::class, 'renew'])->name('renew'); + Route::post('{subscription}/invoices/{invoice}/pay', [SubscriptionController::class, 'payInvoice'])->name('invoices.pay'); + Route::get('', [SubscriptionController::class, 'index'])->name('index'); + Route::post('', [SubscriptionController::class, 'store'])->name('store'); + Route::get('{subscription}', [SubscriptionController::class, 'show'])->name('show'); +}); diff --git a/erp/app/Modules/Survey/Http/Controllers/SurveyController.php b/erp/app/Modules/Survey/Http/Controllers/SurveyController.php new file mode 100644 index 00000000000..93a78f3f2c3 --- /dev/null +++ b/erp/app/Modules/Survey/Http/Controllers/SurveyController.php @@ -0,0 +1,210 @@ +status, fn ($q) => $q->where('status', $request->status)) + ->orderByDesc('created_at') + ->paginate(20) + ->withQueryString(); + + return Inertia::render('Survey/Index', [ + 'surveys' => $surveys, + 'filters' => $request->only(['status']), + ]); + } + + public function show(Survey $survey): Response + { + $survey->load('questions'); + + return Inertia::render('Survey/Show', [ + 'survey' => $survey, + 'responseCount' => $survey->responseCount(), + ]); + } + + public function store(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'title' => 'required|string|max:255', + 'description' => 'nullable|string', + ]); + + $survey = Survey::create([ + ...$validated, + 'tenant_id' => auth()->user()->tenant_id, + 'created_by' => auth()->id(), + 'status' => 'draft', + ]); + + return redirect()->route('surveys.show', $survey)->with('success', 'Survey created.'); + } + + public function update(Request $request, Survey $survey): RedirectResponse + { + abort_if($survey->status !== 'draft', 403, 'Only draft surveys can be updated.'); + + $validated = $request->validate([ + 'title' => 'sometimes|required|string|max:255', + 'description' => 'nullable|string', + 'starts_at' => 'nullable|date', + 'ends_at' => 'nullable|date', + ]); + + $survey->update($validated); + + return redirect()->route('surveys.show', $survey)->with('success', 'Survey updated.'); + } + + public function destroy(Survey $survey): RedirectResponse + { + abort_if($survey->status !== 'draft', 403, 'Only draft surveys can be deleted.'); + + $survey->delete(); + + return redirect()->route('surveys.index')->with('success', 'Survey deleted.'); + } + + public function publish(Survey $survey): RedirectResponse + { + $survey->publish(); + + return redirect()->back()->with('success', 'Survey published.'); + } + + public function close(Survey $survey): RedirectResponse + { + $survey->close(); + + return redirect()->back()->with('success', 'Survey closed.'); + } + + public function addQuestion(Request $request, Survey $survey): RedirectResponse + { + abort_if($survey->status !== 'draft', 403, 'Questions can only be added to draft surveys.'); + + $validated = $request->validate([ + 'question_text' => 'required|string', + 'question_type' => 'required|in:text,single_choice,multiple_choice,rating,yes_no', + 'is_required' => 'boolean', + 'sequence' => 'integer', + 'options' => 'nullable|array', + 'options.*' => 'string', + ]); + + $survey->questions()->create([ + ...$validated, + 'tenant_id' => $survey->tenant_id, + ]); + + return redirect()->back()->with('success', 'Question added.'); + } + + public function removeQuestion(Survey $survey, SurveyQuestion $question): RedirectResponse + { + abort_if($question->survey_id !== $survey->id, 404); + + $question->delete(); + + return redirect()->back()->with('success', 'Question removed.'); + } + + public function respond(Request $request, Survey $survey): RedirectResponse + { + abort_unless($survey->isOpen(), 422, 'This survey is not open for responses.'); + + $validated = $request->validate([ + 'respondent_name' => 'nullable|string|max:255', + 'respondent_email' => 'nullable|email|max:255', + 'answers' => 'nullable|array', + 'answers.*.question_id' => 'required|exists:survey_questions,id', + 'answers.*.answer_text' => 'nullable|string', + 'answers.*.answer_options' => 'nullable|array', + ]); + + $response = SurveyResponse::create([ + 'survey_id' => $survey->id, + 'tenant_id' => $survey->tenant_id, + 'respondent_name' => $validated['respondent_name'] ?? null, + 'respondent_email' => $validated['respondent_email'] ?? null, + ]); + + foreach ($validated['answers'] ?? [] as $answerData) { + SurveyAnswer::create([ + 'survey_response_id' => $response->id, + 'survey_question_id' => $answerData['question_id'], + 'tenant_id' => $survey->tenant_id, + 'answer_text' => $answerData['answer_text'] ?? null, + 'answer_options' => $answerData['answer_options'] ?? null, + ]); + } + + $response->submit(); + + return redirect()->back()->with('success', 'Response submitted.'); + } + + public function results(Survey $survey): Response + { + $survey->load('questions.answers'); + + $questionStats = $survey->questions->map(function (SurveyQuestion $question) { + $answers = $question->answers; + + $stats = match ($question->question_type) { + 'text' => [ + 'type' => 'text', + 'answers' => $answers->pluck('answer_text')->filter()->values(), + ], + 'single_choice', 'multiple_choice' => [ + 'type' => $question->question_type, + 'counts' => collect($question->options ?? [])->mapWithKeys(function ($option) use ($answers) { + $count = $answers->filter(function ($answer) use ($option) { + $opts = $answer->answer_options ?? []; + return in_array($option, $opts, true) || $answer->answer_text === $option; + })->count(); + return [$option => $count]; + }), + ], + 'rating' => [ + 'type' => 'rating', + 'average' => $answers->whereNotNull('answer_text')->avg('answer_text'), + 'count' => $answers->count(), + ], + 'yes_no' => [ + 'type' => 'yes_no', + 'yes' => $answers->filter(fn ($a) => strtolower($a->answer_text ?? '') === 'yes')->count(), + 'no' => $answers->filter(fn ($a) => strtolower($a->answer_text ?? '') === 'no')->count(), + ], + default => ['type' => 'unknown', 'answers' => []], + }; + + return [ + 'id' => $question->id, + 'question_text' => $question->question_text, + 'question_type' => $question->question_type, + 'stats' => $stats, + ]; + }); + + return Inertia::render('Survey/Results', [ + 'survey' => $survey, + 'questionStats' => $questionStats, + 'responseCount' => $survey->responseCount(), + ]); + } +} diff --git a/erp/app/Modules/Survey/Models/Survey.php b/erp/app/Modules/Survey/Models/Survey.php new file mode 100644 index 00000000000..532edfb853b --- /dev/null +++ b/erp/app/Modules/Survey/Models/Survey.php @@ -0,0 +1,72 @@ + 'datetime', + 'ends_at' => 'datetime', + ]; + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function questions(): HasMany + { + return $this->hasMany(SurveyQuestion::class)->orderBy('sequence'); + } + + public function responses(): HasMany + { + return $this->hasMany(SurveyResponse::class); + } + + public function publish(): void + { + if ($this->starts_at === null) { + $this->starts_at = now(); + } + $this->status = 'published'; + $this->save(); + } + + public function close(): void + { + $this->status = 'closed'; + $this->ends_at = now(); + $this->save(); + } + + public function responseCount(): int + { + return $this->responses()->count(); + } + + public function isOpen(): bool + { + return $this->status === 'published' + && ($this->ends_at === null || $this->ends_at->gt(now())); + } +} diff --git a/erp/app/Modules/Survey/Models/SurveyAnswer.php b/erp/app/Modules/Survey/Models/SurveyAnswer.php new file mode 100644 index 00000000000..003e76b8692 --- /dev/null +++ b/erp/app/Modules/Survey/Models/SurveyAnswer.php @@ -0,0 +1,34 @@ + 'array', + ]; + + public function response(): BelongsTo + { + return $this->belongsTo(SurveyResponse::class, 'survey_response_id'); + } + + public function question(): BelongsTo + { + return $this->belongsTo(SurveyQuestion::class, 'survey_question_id'); + } +} diff --git a/erp/app/Modules/Survey/Models/SurveyQuestion.php b/erp/app/Modules/Survey/Models/SurveyQuestion.php new file mode 100644 index 00000000000..c1331f9d722 --- /dev/null +++ b/erp/app/Modules/Survey/Models/SurveyQuestion.php @@ -0,0 +1,38 @@ + 'array', + 'is_required' => 'boolean', + ]; + + public function survey(): BelongsTo + { + return $this->belongsTo(Survey::class); + } + + public function answers(): HasMany + { + return $this->hasMany(SurveyAnswer::class); + } +} diff --git a/erp/app/Modules/Survey/Models/SurveyResponse.php b/erp/app/Modules/Survey/Models/SurveyResponse.php new file mode 100644 index 00000000000..84c449812ed --- /dev/null +++ b/erp/app/Modules/Survey/Models/SurveyResponse.php @@ -0,0 +1,41 @@ + 'datetime', + ]; + + public function survey(): BelongsTo + { + return $this->belongsTo(Survey::class); + } + + public function answers(): HasMany + { + return $this->hasMany(SurveyAnswer::class); + } + + public function submit(): void + { + $this->submitted_at = now(); + $this->save(); + } +} diff --git a/erp/app/Modules/Survey/Providers/SurveyServiceProvider.php b/erp/app/Modules/Survey/Providers/SurveyServiceProvider.php new file mode 100644 index 00000000000..40d8744938e --- /dev/null +++ b/erp/app/Modules/Survey/Providers/SurveyServiceProvider.php @@ -0,0 +1,16 @@ +loadRoutesFrom(__DIR__ . '/../routes/survey.php'); + $this->loadMigrationsFrom(__DIR__ . '/../../../database/migrations'); + } +} diff --git a/erp/app/Modules/Survey/routes/survey.php b/erp/app/Modules/Survey/routes/survey.php new file mode 100644 index 00000000000..1f81ac489d7 --- /dev/null +++ b/erp/app/Modules/Survey/routes/survey.php @@ -0,0 +1,18 @@ +prefix('surveys')->name('surveys.')->group(function () { + Route::post('{survey}/publish', [SurveyController::class, 'publish'])->name('publish'); + Route::post('{survey}/close', [SurveyController::class, 'close'])->name('close'); + Route::post('{survey}/questions', [SurveyController::class, 'addQuestion'])->name('questions.store'); + Route::delete('{survey}/questions/{question}', [SurveyController::class, 'removeQuestion'])->name('questions.destroy'); + Route::post('{survey}/respond', [SurveyController::class, 'respond'])->name('respond'); + Route::get('{survey}/results', [SurveyController::class, 'results'])->name('results'); + Route::get('', [SurveyController::class, 'index'])->name('index'); + Route::post('', [SurveyController::class, 'store'])->name('store'); + Route::get('{survey}', [SurveyController::class, 'show'])->name('show'); + Route::patch('{survey}', [SurveyController::class, 'update'])->name('update'); + Route::delete('{survey}', [SurveyController::class, 'destroy'])->name('destroy'); +}); diff --git a/erp/app/Modules/Website/Http/Controllers/WebsiteController.php b/erp/app/Modules/Website/Http/Controllers/WebsiteController.php new file mode 100644 index 00000000000..9547ddbb0ff --- /dev/null +++ b/erp/app/Modules/Website/Http/Controllers/WebsiteController.php @@ -0,0 +1,133 @@ + WebPage::count(), + 'published_pages' => WebPage::where('status', 'published')->count(), + 'total_posts' => BlogPost::count(), + 'published_posts' => BlogPost::where('status', 'published')->count(), + 'total_menus' => WebMenu::count(), + ]; + + return Inertia::render('Website/Dashboard', ['stats' => $stats]); + } + + public function pages(): Response + { + $pages = WebPage::orderByDesc('created_at')->paginate(20); + + return Inertia::render('Website/Pages/Index', ['pages' => $pages]); + } + + public function storePage(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'title' => 'required|string|max:255', + 'slug' => 'required|alpha_dash|max:255', + 'status' => 'sometimes|in:draft,published,archived', + ]); + + if (empty($validated['slug'])) { + $validated['slug'] = Str::slug($validated['title']); + } + + WebPage::create($validated); + + return redirect()->back(); + } + + public function updatePage(Request $request, WebPage $page): RedirectResponse + { + $validated = $request->validate([ + 'title' => 'required|string|max:255', + 'slug' => 'required|alpha_dash|max:255', + 'status' => 'sometimes|in:draft,published,archived', + ]); + + $page->update($validated); + + return redirect()->back(); + } + + public function publishPage(WebPage $page): JsonResponse + { + $page->publish(); + + return response()->json(['success' => true]); + } + + public function posts(): Response + { + $posts = BlogPost::orderByDesc('created_at')->paginate(20); + + return Inertia::render('Website/Blog/Index', ['posts' => $posts]); + } + + public function storePost(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'title' => 'required|string|max:255', + 'slug' => 'required|alpha_dash|max:255', + 'content' => 'nullable|string', + ]); + + $validated['author_id'] = auth()->id(); + + BlogPost::create($validated); + + return redirect()->back(); + } + + public function publishPost(BlogPost $post): JsonResponse + { + $post->publish(); + + return response()->json(['success' => true]); + } + + public function menus(): Response + { + $menus = WebMenu::all(); + + return Inertia::render('Website/Menus/Index', ['menus' => $menus]); + } + + public function storeMenu(Request $request): RedirectResponse + { + $request->validate([ + 'name' => 'required|string|max:255', + 'location' => 'required|in:header,footer,sidebar', + ]); + + WebMenu::create($request->only(['name', 'location'])); + + return redirect()->back(); + } + + public function updateMenu(Request $request, WebMenu $menu): JsonResponse + { + $request->validate([ + 'items' => 'required|array', + ]); + + $menu->update(['items' => $request->items]); + + return response()->json(['success' => true]); + } +} diff --git a/erp/app/Modules/Website/Models/BlogPost.php b/erp/app/Modules/Website/Models/BlogPost.php new file mode 100644 index 00000000000..c725bf9d6ab --- /dev/null +++ b/erp/app/Modules/Website/Models/BlogPost.php @@ -0,0 +1,44 @@ + 'datetime', + 'tags' => 'array', + ]; + + public function publish(): void + { + $this->status = 'published'; + $this->published_at = now(); + $this->save(); + } + + public function incrementViews(): void + { + $this->increment('view_count'); + } +} diff --git a/erp/app/Modules/Website/Models/WebMenu.php b/erp/app/Modules/Website/Models/WebMenu.php new file mode 100644 index 00000000000..c80e3eb2b54 --- /dev/null +++ b/erp/app/Modules/Website/Models/WebMenu.php @@ -0,0 +1,26 @@ + 'array', + 'is_active' => 'boolean', + ]; +} diff --git a/erp/app/Modules/Website/Models/WebPage.php b/erp/app/Modules/Website/Models/WebPage.php new file mode 100644 index 00000000000..0d859f5d1ff --- /dev/null +++ b/erp/app/Modules/Website/Models/WebPage.php @@ -0,0 +1,44 @@ + 'datetime', + 'is_homepage' => 'boolean', + ]; + + public function publish(): void + { + $this->status = 'published'; + $this->published_at = now(); + $this->save(); + } + + public function archive(): void + { + $this->status = 'archived'; + $this->save(); + } +} diff --git a/erp/app/Modules/Website/Providers/WebsiteServiceProvider.php b/erp/app/Modules/Website/Providers/WebsiteServiceProvider.php new file mode 100644 index 00000000000..c6697c44448 --- /dev/null +++ b/erp/app/Modules/Website/Providers/WebsiteServiceProvider.php @@ -0,0 +1,15 @@ +loadRoutesFrom(__DIR__ . '/../routes/website.php'); + } +} diff --git a/erp/app/Modules/Website/routes/website.php b/erp/app/Modules/Website/routes/website.php new file mode 100644 index 00000000000..29d57e996e5 --- /dev/null +++ b/erp/app/Modules/Website/routes/website.php @@ -0,0 +1,24 @@ +prefix('website')->name('website.')->group(function () { + Route::get('dashboard', [WebsiteController::class, 'dashboard'])->name('dashboard'); + + // Pages + Route::get('pages', [WebsiteController::class, 'pages'])->name('pages'); + Route::post('pages', [WebsiteController::class, 'storePage'])->name('pages.store'); + Route::put('pages/{page}', [WebsiteController::class, 'updatePage'])->name('pages.update'); + Route::post('pages/{page}/publish', [WebsiteController::class, 'publishPage'])->name('pages.publish'); + + // Blog + Route::get('blog', [WebsiteController::class, 'posts'])->name('blog'); + Route::post('blog', [WebsiteController::class, 'storePost'])->name('blog.store'); + Route::post('blog/{post}/publish', [WebsiteController::class, 'publishPost'])->name('blog.publish'); + + // Menus + Route::get('menus', [WebsiteController::class, 'menus'])->name('menus'); + Route::post('menus', [WebsiteController::class, 'storeMenu'])->name('menus.store'); + Route::put('menus/{menu}', [WebsiteController::class, 'updateMenu'])->name('menus.update'); +}); diff --git a/erp/app/Notifications/LeaveRequestActioned.php b/erp/app/Notifications/LeaveRequestActioned.php new file mode 100644 index 00000000000..c72c0db9fc6 --- /dev/null +++ b/erp/app/Notifications/LeaveRequestActioned.php @@ -0,0 +1,32 @@ + "Leave request {$this->action}", + 'message' => "Your leave from {$this->request->start_date->toDateString()} to {$this->request->end_date->toDateString()} has been {$this->action}.", + 'link' => '/hr/leave', + 'type' => 'hr', + ]; + } +} diff --git a/erp/app/Providers/AppServiceProvider.php b/erp/app/Providers/AppServiceProvider.php new file mode 100644 index 00000000000..7bb2b52669f --- /dev/null +++ b/erp/app/Providers/AppServiceProvider.php @@ -0,0 +1,32 @@ +by($request->user()?->id ?: $request->ip()); + }); + + RateLimiter::for('auth', function (Request $request) { + return Limit::perMinute(10)->by($request->ip()); + }); + } +} diff --git a/erp/app/Services/AlertEvaluatorService.php b/erp/app/Services/AlertEvaluatorService.php new file mode 100644 index 00000000000..4ce6cd04017 --- /dev/null +++ b/erp/app/Services/AlertEvaluatorService.php @@ -0,0 +1,150 @@ +type) { + 'overdue_invoice' => $this->checkOverdueInvoices($rule), + 'low_stock' => $this->checkLowStock($rule), + 'high_receivables' => $this->checkHighReceivables($rule), + 'unresolved_ticket' => $this->checkUnresolvedTickets($rule), + default => [], + }; + } + + private function checkOverdueInvoices(AlertRule $rule): array + { + $days = $rule->conditions['days'] ?? 30; + $threshold = now()->subDays($days); + + $invoices = Invoice::withoutGlobalScopes() + ->where('tenant_id', $rule->tenant_id) + ->where('status', 'sent') + ->where('due_date', '<', $threshold) + ->get(['id', 'number', 'total', 'due_date', 'contact_id']); + + if ($invoices->isEmpty()) { + return []; + } + + return [[ + 'message' => "{$invoices->count()} invoice(s) overdue by more than {$days} days", + 'context' => [ + 'count' => $invoices->count(), + 'ids' => $invoices->pluck('id')->toArray(), + 'numbers' => $invoices->pluck('number')->toArray(), + 'total_outstanding' => $invoices->sum('total'), + ], + ]]; + } + + private function checkLowStock(AlertRule $rule): array + { + $threshold = $rule->conditions['below'] ?? 10; + + $products = Product::withoutGlobalScopes() + ->where('tenant_id', $rule->tenant_id) + ->where('stock_quantity', '<', $threshold) + ->where('reorder_point', '>', 0) + ->get(['id', 'name', 'sku', 'stock_quantity']); + + if ($products->isEmpty()) { + return []; + } + + return [[ + 'message' => "{$products->count()} product(s) with stock below {$threshold}", + 'context' => [ + 'count' => $products->count(), + 'products' => $products->map(fn ($p) => [ + 'id' => $p->id, + 'name' => $p->name, + 'sku' => $p->sku, + 'quantity' => $p->stock_quantity, + ])->toArray(), + ], + ]]; + } + + private function checkHighReceivables(AlertRule $rule): array + { + $threshold = $rule->conditions['amount'] ?? 10000; + + $total = Invoice::withoutGlobalScopes() + ->where('tenant_id', $rule->tenant_id) + ->whereNotIn('status', ['paid', 'cancelled']) + ->sum('total'); + + if ($total < $threshold) { + return []; + } + + return [[ + 'message' => "Outstanding receivables (\${$total}) exceed threshold of \${$threshold}", + 'context' => ['total_outstanding' => $total, 'threshold' => $threshold], + ]]; + } + + private function checkUnresolvedTickets(AlertRule $rule): array + { + $hours = $rule->conditions['hours'] ?? 48; + $cutoff = now()->subHours($hours); + + try { + $count = Ticket::withoutGlobalScopes() + ->where('tenant_id', $rule->tenant_id) + ->whereNotIn('status', ['resolved', 'closed']) + ->where('created_at', '<', $cutoff) + ->count(); + + if ($count === 0) { + return []; + } + + return [[ + 'message' => "{$count} helpdesk ticket(s) unresolved for more than {$hours} hours", + 'context' => ['count' => $count, 'hours' => $hours], + ]]; + } catch (\Throwable) { + return []; + } + } + + public function fire(AlertRule $rule, array $triggered): void + { + foreach ($triggered as $alert) { + AlertEvent::create([ + 'alert_rule_id' => $rule->id, + 'message' => $alert['message'], + 'context' => $alert['context'] ?? null, + 'triggered_at' => now(), + ]); + } + + $rule->update(['last_triggered_at' => now()]); + + // Send in-app notifications + foreach ($rule->notification_targets as $target) { + if (is_int($target)) { + NotificationService::send( + $rule->tenant_id, + $target, + 'alert', + "Alert: {$rule->name}", + $triggered[0]['message'] ?? '', + ['rule_id' => $rule->id] + ); + } + } + } +} diff --git a/erp/app/Services/CreditLimitService.php b/erp/app/Services/CreditLimitService.php new file mode 100644 index 00000000000..f57818b2a42 --- /dev/null +++ b/erp/app/Services/CreditLimitService.php @@ -0,0 +1,82 @@ +join('invoices', 'invoices.id', '=', 'invoice_items.invoice_id') + ->where('invoices.contact_id', $contact->id) + ->whereIn('invoices.status', ['sent', 'partial']) + ->whereNull('invoices.deleted_at') + ->sum(DB::raw('invoice_items.quantity * invoice_items.unit_price')); + } + + public function getAvailableCredit(Contact $contact): float + { + if ($contact->credit_limit <= 0) { + return PHP_FLOAT_MAX; + } + + $outstanding = $this->getOutstandingBalance($contact); + return max(0, $contact->credit_limit - $outstanding); + } + + public function wouldExceedLimit(Contact $contact, float $amount): bool + { + if ($contact->credit_limit <= 0) { + return false; + } + + $outstanding = $this->getOutstandingBalance($contact); + return ($outstanding + $amount) > $contact->credit_limit; + } + + public function getCreditStatus(Contact $contact): array + { + $outstanding = $this->getOutstandingBalance($contact); + $available = $this->getAvailableCredit($contact); + $limitSet = $contact->credit_limit > 0; + $utilizationPct = $limitSet + ? min(100, round(($outstanding / $contact->credit_limit) * 100, 2)) + : 0; + + return [ + 'contact_id' => $contact->id, + 'contact_name' => $contact->name, + 'credit_limit' => $contact->credit_limit, + 'credit_terms_days' => $contact->credit_terms_days, + 'credit_hold' => $contact->credit_hold, + 'outstanding_balance' => round($outstanding, 2), + 'available_credit' => $limitSet ? round($available, 2) : null, + 'utilization_percent' => $utilizationPct, + 'is_over_limit' => $limitSet && $outstanding > $contact->credit_limit, + 'limit_set' => $limitSet, + ]; + } + + public function getContactsNearLimit(int $tenantId, float $threshold = 80.0): array + { + $contacts = Contact::where('tenant_id', $tenantId) + ->customers() + ->where('credit_limit', '>', 0) + ->get(); + + $alerts = []; + foreach ($contacts as $contact) { + $status = $this->getCreditStatus($contact); + if ($status['utilization_percent'] >= $threshold || $contact->credit_hold) { + $alerts[] = $status; + } + } + + usort($alerts, fn ($a, $b) => $b['utilization_percent'] <=> $a['utilization_percent']); + return $alerts; + } +} diff --git a/erp/app/Services/NotificationService.php b/erp/app/Services/NotificationService.php new file mode 100644 index 00000000000..4951ae8f153 --- /dev/null +++ b/erp/app/Services/NotificationService.php @@ -0,0 +1,112 @@ + $tenantId, + 'user_id' => $userId, + 'type' => $type, + 'title' => $title, + 'message' => $message, + 'data' => $data, + ]); + + broadcast(new ErpNotificationEvent($tenantId, $type, $title, $message, $data)); + + return $notification; + } + + /** + * Get summary notifications for sidebar bell (cached, page-load driven). + */ + public static function forUser(\App\Models\User $user): array + { + $tenantId = $user->tenant_id; + $cacheKey = "notifications.{$tenantId}.{$user->id}"; + + // Cache for 5 minutes to avoid N+1 on every page load + return Cache::remember($cacheKey, 300, function () use ($user, $tenantId) { + $items = []; + + // 1. Overdue invoices (finance.view permission required) + if ($user->hasAnyPermission(['finance.view', 'finance.create'])) { + $overdueCount = Invoice::where('tenant_id', $tenantId) + ->whereNotIn('status', ['paid', 'cancelled']) + ->whereNotNull('due_date') + ->where('due_date', '<', now()->startOfDay()) + ->count(); + + if ($overdueCount > 0) { + $items[] = [ + 'type' => 'overdue_invoices', + 'label' => "{$overdueCount} overdue invoice" . ($overdueCount > 1 ? 's' : ''), + 'href' => '/finance/invoices?status=overdue', + 'count' => $overdueCount, + 'severity' => 'warning', + ]; + } + } + + // 2. Low stock (inventory.view permission required) + if ($user->hasAnyPermission(['inventory.view', 'inventory.create'])) { + $lowStockCount = Product::where('tenant_id', $tenantId) + ->where('is_active', true) + ->with('stockLevels') + ->get() + ->filter(fn ($p) => $p->stockLevels->sum('quantity') < 10) + ->count(); + + if ($lowStockCount > 0) { + $items[] = [ + 'type' => 'low_stock', + 'label' => "{$lowStockCount} product" . ($lowStockCount > 1 ? 's' : '') . ' low on stock', + 'href' => '/inventory/products', + 'count' => $lowStockCount, + 'severity' => 'warning', + ]; + } + } + + // 3. Pending leave requests (hr.view + admin/manager role) + if ($user->hasAnyRole(['super-admin', 'admin', 'manager']) && $user->hasAnyPermission(['hr.view', 'hr.create'])) { + $pendingLeave = LeaveRequest::where('tenant_id', $tenantId) + ->where('status', 'pending') + ->count(); + + if ($pendingLeave > 0) { + $items[] = [ + 'type' => 'pending_leave', + 'label' => "{$pendingLeave} pending leave request" . ($pendingLeave > 1 ? 's' : ''), + 'href' => '/hr/leave-requests', + 'count' => $pendingLeave, + 'severity' => 'info', + ]; + } + } + + return $items; + }); + } + + public static function clearCache(int $tenantId): void + { + // Clear notification cache for all users in the tenant + foreach (\App\Models\User::where('tenant_id', $tenantId)->pluck('id') as $userId) { + Cache::forget("notifications.{$tenantId}.{$userId}"); + } + } +} diff --git a/erp/app/Services/WebhookService.php b/erp/app/Services/WebhookService.php new file mode 100644 index 00000000000..7d2c70f3fc7 --- /dev/null +++ b/erp/app/Services/WebhookService.php @@ -0,0 +1,45 @@ + $webhook->id, + 'event' => $event, + 'payload' => $payload, + 'attempts' => 1, + ]); + + try { + $body = json_encode($payload); + $signature = hash_hmac('sha256', $body, $webhook->secret ?? ''); + $response = Http::timeout(10) + ->withHeaders([ + 'Content-Type' => 'application/json', + 'X-Webhook-Event' => $event, + 'X-Webhook-Signature' => "sha256={$signature}", + ]) + ->post($webhook->url, $payload); + + $delivery->update([ + 'response_status' => $response->status(), + 'response_body' => substr($response->body(), 0, 1000), + 'delivered_at' => now(), + ]); + } catch (\Throwable $e) { + $delivery->update([ + 'failed_at' => now(), + 'response_body' => $e->getMessage(), + ]); + } + + return $delivery; + } +} diff --git a/erp/app/Traits/LogsActivity.php b/erp/app/Traits/LogsActivity.php new file mode 100644 index 00000000000..b55456996e3 --- /dev/null +++ b/erp/app/Traits/LogsActivity.php @@ -0,0 +1,43 @@ +getAttributes()); + }); + + static::updated(function ($model) { + static::logActivity($model, 'updated', $model->getOriginal(), $model->getChanges()); + }); + + static::deleted(function ($model) { + static::logActivity($model, 'deleted', $model->getAttributes(), []); + }); + } + + protected static function logActivity($model, string $action, array $oldValues, array $newValues): void + { + $tenantId = $model->tenant_id ?? (app()->has('tenant') ? app('tenant')->id : null); + if (!$tenantId) return; + + AuditLog::create([ + 'tenant_id' => $tenantId, + 'user_id' => Auth::id(), + 'event' => $action, + 'action' => $action, + 'auditable_type' => get_class($model), + 'auditable_id' => $model->id, + 'old_values' => empty($oldValues) ? null : $oldValues, + 'new_values' => empty($newValues) ? null : $newValues, + 'ip_address' => Request::ip(), + 'user_agent' => Request::userAgent(), + ]); + } +} diff --git a/erp/artisan b/erp/artisan new file mode 100755 index 00000000000..c35e31d6a29 --- /dev/null +++ b/erp/artisan @@ -0,0 +1,18 @@ +#!/usr/bin/env php +handleCommand(new ArgvInput); + +exit($status); diff --git a/erp/bootstrap/app.php b/erp/bootstrap/app.php new file mode 100644 index 00000000000..f49cd8a4e5a --- /dev/null +++ b/erp/bootstrap/app.php @@ -0,0 +1,36 @@ +withRouting( + channels: __DIR__.'/../routes/channels.php', + web: __DIR__ . '/../routes/web.php', + api: __DIR__ . '/../routes/api.php', + commands: __DIR__ . '/../routes/console.php', + health: '/up', + then: function () { + require __DIR__ . '/../app/Modules/QualityControl/routes/quality.php'; + }, + ) + ->withMiddleware(function (Middleware $middleware): void { + $middleware->web(append: [ + \App\Http\Middleware\HandleInertiaRequests::class, + \Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets::class, + ]); + + $middleware->alias([ + 'tenant' => \App\Http\Middleware\TenantMiddleware::class, + 'role' => \Spatie\Permission\Middleware\RoleMiddleware::class, + 'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class, + 'role_or_permission' => \Spatie\Permission\Middleware\RoleOrPermissionMiddleware::class, + 'two_factor' => \App\Http\Middleware\RequiresTwoFactor::class, + ]); + + $middleware->append(\App\Http\Middleware\SecurityHeaders::class); + }) + ->withExceptions(function (Exceptions $exceptions): void { + // + })->create(); diff --git a/erp/bootstrap/cache/.gitignore b/erp/bootstrap/cache/.gitignore new file mode 100644 index 00000000000..d6b7ef32c84 --- /dev/null +++ b/erp/bootstrap/cache/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/erp/bootstrap/providers.php b/erp/bootstrap/providers.php new file mode 100644 index 00000000000..b24a029ae5a --- /dev/null +++ b/erp/bootstrap/providers.php @@ -0,0 +1,11 @@ +=5.0.0" + }, + "require-dev": { + "doctrine/dbal": "^4.0.0", + "nesbot/carbon": "^2.71.0 || ^3.0.0", + "phpunit/phpunit": "^10.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Carbon\\Doctrine\\": "src/Carbon/Doctrine/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "KyleKatarn", + "email": "kylekatarnls@gmail.com" + } + ], + "description": "Types to use Carbon in Doctrine", + "keywords": [ + "carbon", + "date", + "datetime", + "doctrine", + "time" + ], + "support": { + "issues": "https://github.com/CarbonPHP/carbon-doctrine-types/issues", + "source": "https://github.com/CarbonPHP/carbon-doctrine-types/tree/3.2.0" + }, + "funding": [ + { + "url": "https://github.com/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon", + "type": "open_collective" + }, + { + "url": "https://tidelift.com/funding/github/packagist/nesbot/carbon", + "type": "tidelift" + } + ], + "time": "2024-02-09T16:56:22+00:00" + }, + { + "name": "clue/redis-protocol", + "version": "v0.3.2", + "source": { + "type": "git", + "url": "https://github.com/clue/redis-protocol.git", + "reference": "6f565332f5531b7722d1e9c445314b91862f6d6c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/clue/redis-protocol/zipball/6f565332f5531b7722d1e9c445314b91862f6d6c", + "reference": "6f565332f5531b7722d1e9c445314b91862f6d6c", + "shasum": "" + }, + "require": { + "php": ">=5.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "type": "library", + "autoload": { + "psr-4": { + "Clue\\Redis\\Protocol\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@lueck.tv" + } + ], + "description": "A streaming Redis protocol (RESP) parser and serializer written in pure PHP.", + "homepage": "https://github.com/clue/redis-protocol", + "keywords": [ + "parser", + "protocol", + "redis", + "resp", + "serializer", + "streaming" + ], + "support": { + "issues": "https://github.com/clue/redis-protocol/issues", + "source": "https://github.com/clue/redis-protocol/tree/v0.3.2" + }, + "funding": [ + { + "url": "https://clue.engineering/support", + "type": "custom" + }, + { + "url": "https://github.com/clue", + "type": "github" + } + ], + "time": "2024-08-07T11:06:28+00:00" + }, + { + "name": "clue/redis-react", + "version": "v2.8.0", + "source": { + "type": "git", + "url": "https://github.com/clue/reactphp-redis.git", + "reference": "84569198dfd5564977d2ae6a32de4beb5a24bdca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/clue/reactphp-redis/zipball/84569198dfd5564977d2ae6a32de4beb5a24bdca", + "reference": "84569198dfd5564977d2ae6a32de4beb5a24bdca", + "shasum": "" + }, + "require": { + "clue/redis-protocol": "^0.3.2", + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.0 || ^1.1", + "react/promise-timer": "^1.11", + "react/socket": "^1.16" + }, + "require-dev": { + "clue/block-react": "^1.5", + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "type": "library", + "autoload": { + "psr-4": { + "Clue\\React\\Redis\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering" + } + ], + "description": "Async Redis client implementation, built on top of ReactPHP.", + "homepage": "https://github.com/clue/reactphp-redis", + "keywords": [ + "async", + "client", + "database", + "reactphp", + "redis" + ], + "support": { + "issues": "https://github.com/clue/reactphp-redis/issues", + "source": "https://github.com/clue/reactphp-redis/tree/v2.8.0" + }, + "funding": [ + { + "url": "https://clue.engineering/support", + "type": "custom" + }, + { + "url": "https://github.com/clue", + "type": "github" + } + ], + "time": "2025-01-03T16:18:33+00:00" + }, + { + "name": "composer/pcre", + "version": "3.3.2", + "source": { + "type": "git", + "url": "https://github.com/composer/pcre.git", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "conflict": { + "phpstan/phpstan": "<1.11.10" + }, + "require-dev": { + "phpstan/phpstan": "^1.12 || ^2", + "phpstan/phpstan-strict-rules": "^1 || ^2", + "phpunit/phpunit": "^8 || ^9" + }, + "type": "library", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Pcre\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "PCRE wrapping library that offers type-safe preg_* replacements.", + "keywords": [ + "PCRE", + "preg", + "regex", + "regular expression" + ], + "support": { + "issues": "https://github.com/composer/pcre/issues", + "source": "https://github.com/composer/pcre/tree/3.3.2" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-11-12T16:29:46+00:00" + }, + { + "name": "composer/semver", + "version": "3.4.4", + "source": { + "type": "git", + "url": "https://github.com/composer/semver.git", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.11", + "symfony/phpunit-bridge": "^3 || ^7" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Semver\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" + } + ], + "description": "Semver library that offers utilities, version constraint parsing and validation.", + "keywords": [ + "semantic", + "semver", + "validation", + "versioning" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/semver/issues", + "source": "https://github.com/composer/semver/tree/3.4.4" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2025-08-20T19:15:30+00:00" + }, + { + "name": "dflydev/dot-access-data", + "version": "v3.0.3", + "source": { + "type": "git", + "url": "https://github.com/dflydev/dflydev-dot-access-data.git", + "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dflydev/dflydev-dot-access-data/zipball/a23a2bf4f31d3518f3ecb38660c95715dfead60f", + "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^0.12.42", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.3", + "scrutinizer/ocular": "1.6.0", + "squizlabs/php_codesniffer": "^3.5", + "vimeo/psalm": "^4.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Dflydev\\DotAccessData\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Dragonfly Development Inc.", + "email": "info@dflydev.com", + "homepage": "http://dflydev.com" + }, + { + "name": "Beau Simensen", + "email": "beau@dflydev.com", + "homepage": "http://beausimensen.com" + }, + { + "name": "Carlos Frutos", + "email": "carlos@kiwing.it", + "homepage": "https://github.com/cfrutos" + }, + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com" + } + ], + "description": "Given a deep data structure, access data by dot notation.", + "homepage": "https://github.com/dflydev/dflydev-dot-access-data", + "keywords": [ + "access", + "data", + "dot", + "notation" + ], + "support": { + "issues": "https://github.com/dflydev/dflydev-dot-access-data/issues", + "source": "https://github.com/dflydev/dflydev-dot-access-data/tree/v3.0.3" + }, + "time": "2024-07-08T12:26:09+00:00" + }, + { + "name": "doctrine/inflector", + "version": "2.1.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/inflector.git", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/inflector/zipball/6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "doctrine/coding-standard": "^12.0 || ^13.0", + "phpstan/phpstan": "^1.12 || ^2.0", + "phpstan/phpstan-phpunit": "^1.4 || ^2.0", + "phpstan/phpstan-strict-rules": "^1.6 || ^2.0", + "phpunit/phpunit": "^8.5 || ^12.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Inflector\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Inflector is a small library that can perform string manipulations with regard to upper/lowercase and singular/plural forms of words.", + "homepage": "https://www.doctrine-project.org/projects/inflector.html", + "keywords": [ + "inflection", + "inflector", + "lowercase", + "manipulation", + "php", + "plural", + "singular", + "strings", + "uppercase", + "words" + ], + "support": { + "issues": "https://github.com/doctrine/inflector/issues", + "source": "https://github.com/doctrine/inflector/tree/2.1.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finflector", + "type": "tidelift" + } + ], + "time": "2025-08-10T19:31:58+00:00" + }, + { + "name": "doctrine/lexer", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/doctrine/lexer.git", + "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/lexer/zipball/31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", + "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "doctrine/coding-standard": "^12", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^10.5", + "psalm/plugin-phpunit": "^0.18.3", + "vimeo/psalm": "^5.21" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Common\\Lexer\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Lexer parser library that can be used in Top-Down, Recursive Descent Parsers.", + "homepage": "https://www.doctrine-project.org/projects/lexer.html", + "keywords": [ + "annotations", + "docblock", + "lexer", + "parser", + "php" + ], + "support": { + "issues": "https://github.com/doctrine/lexer/issues", + "source": "https://github.com/doctrine/lexer/tree/3.0.1" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Flexer", + "type": "tidelift" + } + ], + "time": "2024-02-05T11:56:58+00:00" + }, + { + "name": "dompdf/dompdf", + "version": "v3.1.5", + "source": { + "type": "git", + "url": "https://github.com/dompdf/dompdf.git", + "reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/dompdf/zipball/f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496", + "reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496", + "shasum": "" + }, + "require": { + "dompdf/php-font-lib": "^1.0.0", + "dompdf/php-svg-lib": "^1.0.0", + "ext-dom": "*", + "ext-mbstring": "*", + "masterminds/html5": "^2.0", + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "ext-gd": "*", + "ext-json": "*", + "ext-zip": "*", + "mockery/mockery": "^1.3", + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11", + "squizlabs/php_codesniffer": "^3.5", + "symfony/process": "^4.4 || ^5.4 || ^6.2 || ^7.0" + }, + "suggest": { + "ext-gd": "Needed to process images", + "ext-gmagick": "Improves image processing performance", + "ext-imagick": "Improves image processing performance", + "ext-zlib": "Needed for pdf stream compression" + }, + "type": "library", + "autoload": { + "psr-4": { + "Dompdf\\": "src/" + }, + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1" + ], + "authors": [ + { + "name": "The Dompdf Community", + "homepage": "https://github.com/dompdf/dompdf/blob/master/AUTHORS.md" + } + ], + "description": "DOMPDF is a CSS 2.1 compliant HTML to PDF converter", + "homepage": "https://github.com/dompdf/dompdf", + "support": { + "issues": "https://github.com/dompdf/dompdf/issues", + "source": "https://github.com/dompdf/dompdf/tree/v3.1.5" + }, + "time": "2026-03-03T13:54:37+00:00" + }, + { + "name": "dompdf/php-font-lib", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/dompdf/php-font-lib.git", + "reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/a6e9a688a2a80016ac080b97be73d3e10c444c9a", + "reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11 || ^12" + }, + "type": "library", + "autoload": { + "psr-4": { + "FontLib\\": "src/FontLib" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-or-later" + ], + "authors": [ + { + "name": "The FontLib Community", + "homepage": "https://github.com/dompdf/php-font-lib/blob/master/AUTHORS.md" + } + ], + "description": "A library to read, parse, export and make subsets of different types of font files.", + "homepage": "https://github.com/dompdf/php-font-lib", + "support": { + "issues": "https://github.com/dompdf/php-font-lib/issues", + "source": "https://github.com/dompdf/php-font-lib/tree/1.0.2" + }, + "time": "2026-01-20T14:10:26+00:00" + }, + { + "name": "dompdf/php-svg-lib", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/dompdf/php-svg-lib.git", + "reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/8259ffb930817e72b1ff1caef5d226501f3dfeb1", + "reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^7.1 || ^8.0", + "sabberworm/php-css-parser": "^8.4 || ^9.0" + }, + "require-dev": { + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11" + }, + "type": "library", + "autoload": { + "psr-4": { + "Svg\\": "src/Svg" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "The SvgLib Community", + "homepage": "https://github.com/dompdf/php-svg-lib/blob/master/AUTHORS.md" + } + ], + "description": "A library to read, parse and export to PDF SVG files.", + "homepage": "https://github.com/dompdf/php-svg-lib", + "support": { + "issues": "https://github.com/dompdf/php-svg-lib/issues", + "source": "https://github.com/dompdf/php-svg-lib/tree/1.0.2" + }, + "time": "2026-01-02T16:01:13+00:00" + }, + { + "name": "dragonmantank/cron-expression", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/dragonmantank/cron-expression.git", + "reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/d61a8a9604ec1f8c3d150d09db6ce98b32675013", + "reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013", + "shasum": "" + }, + "require": { + "php": "^8.2|^8.3|^8.4|^8.5" + }, + "replace": { + "mtdowling/cron-expression": "^1.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.32|^2.1.31", + "phpunit/phpunit": "^8.5.48|^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Cron\\": "src/Cron/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Chris Tankersley", + "email": "chris@ctankersley.com", + "homepage": "https://github.com/dragonmantank" + } + ], + "description": "CRON for PHP: Calculate the next or previous run date and determine if a CRON expression is due", + "keywords": [ + "cron", + "schedule" + ], + "support": { + "issues": "https://github.com/dragonmantank/cron-expression/issues", + "source": "https://github.com/dragonmantank/cron-expression/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://github.com/dragonmantank", + "type": "github" + } + ], + "time": "2025-10-31T18:51:33+00:00" + }, + { + "name": "egulias/email-validator", + "version": "4.0.4", + "source": { + "type": "git", + "url": "https://github.com/egulias/EmailValidator.git", + "reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/egulias/EmailValidator/zipball/d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa", + "reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa", + "shasum": "" + }, + "require": { + "doctrine/lexer": "^2.0 || ^3.0", + "php": ">=8.1", + "symfony/polyfill-intl-idn": "^1.26" + }, + "require-dev": { + "phpunit/phpunit": "^10.2", + "vimeo/psalm": "^5.12" + }, + "suggest": { + "ext-intl": "PHP Internationalization Libraries are required to use the SpoofChecking validation" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Egulias\\EmailValidator\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Eduardo Gulias Davis" + } + ], + "description": "A library for validating emails against several RFCs", + "homepage": "https://github.com/egulias/EmailValidator", + "keywords": [ + "email", + "emailvalidation", + "emailvalidator", + "validation", + "validator" + ], + "support": { + "issues": "https://github.com/egulias/EmailValidator/issues", + "source": "https://github.com/egulias/EmailValidator/tree/4.0.4" + }, + "funding": [ + { + "url": "https://github.com/egulias", + "type": "github" + } + ], + "time": "2025-03-06T22:45:56+00:00" + }, + { + "name": "evenement/evenement", + "version": "v3.0.2", + "source": { + "type": "git", + "url": "https://github.com/igorw/evenement.git", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/igorw/evenement/zipball/0a16b0d71ab13284339abb99d9d2bd813640efbc", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc", + "shasum": "" + }, + "require": { + "php": ">=7.0" + }, + "require-dev": { + "phpunit/phpunit": "^9 || ^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "Evenement\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Igor Wiedler", + "email": "igor@wiedler.ch" + } + ], + "description": "Événement is a very simple event dispatching library for PHP", + "keywords": [ + "event-dispatcher", + "event-emitter" + ], + "support": { + "issues": "https://github.com/igorw/evenement/issues", + "source": "https://github.com/igorw/evenement/tree/v3.0.2" + }, + "time": "2023-08-08T05:53:35+00:00" + }, + { + "name": "ezyang/htmlpurifier", + "version": "v4.19.0", + "source": { + "type": "git", + "url": "https://github.com/ezyang/htmlpurifier.git", + "reference": "b287d2a16aceffbf6e0295559b39662612b77fcf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ezyang/htmlpurifier/zipball/b287d2a16aceffbf6e0295559b39662612b77fcf", + "reference": "b287d2a16aceffbf6e0295559b39662612b77fcf", + "shasum": "" + }, + "require": { + "php": "~5.6.0 || ~7.0.0 || ~7.1.0 || ~7.2.0 || ~7.3.0 || ~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" + }, + "require-dev": { + "cerdic/css-tidy": "^1.7 || ^2.0", + "simpletest/simpletest": "dev-master" + }, + "suggest": { + "cerdic/css-tidy": "If you want to use the filter 'Filter.ExtractStyleBlocks'.", + "ext-bcmath": "Used for unit conversion and imagecrash protection", + "ext-iconv": "Converts text to and from non-UTF-8 encodings", + "ext-tidy": "Used for pretty-printing HTML" + }, + "type": "library", + "autoload": { + "files": [ + "library/HTMLPurifier.composer.php" + ], + "psr-0": { + "HTMLPurifier": "library/" + }, + "exclude-from-classmap": [ + "/library/HTMLPurifier/Language/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-or-later" + ], + "authors": [ + { + "name": "Edward Z. Yang", + "email": "admin@htmlpurifier.org", + "homepage": "http://ezyang.com" + } + ], + "description": "Standards compliant HTML filter written in PHP", + "homepage": "http://htmlpurifier.org/", + "keywords": [ + "html" + ], + "support": { + "issues": "https://github.com/ezyang/htmlpurifier/issues", + "source": "https://github.com/ezyang/htmlpurifier/tree/v4.19.0" + }, + "time": "2025-10-17T16:34:55+00:00" + }, + { + "name": "fruitcake/php-cors", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/fruitcake/php-cors.git", + "reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/fruitcake/php-cors/zipball/38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379", + "reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379", + "shasum": "" + }, + "require": { + "php": "^8.1", + "symfony/http-foundation": "^5.4|^6.4|^7.3|^8" + }, + "require-dev": { + "phpstan/phpstan": "^2", + "phpunit/phpunit": "^9", + "squizlabs/php_codesniffer": "^4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.3-dev" + } + }, + "autoload": { + "psr-4": { + "Fruitcake\\Cors\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fruitcake", + "homepage": "https://fruitcake.nl" + }, + { + "name": "Barryvdh", + "email": "barryvdh@gmail.com" + } + ], + "description": "Cross-origin resource sharing library for the Symfony HttpFoundation", + "homepage": "https://github.com/fruitcake/php-cors", + "keywords": [ + "cors", + "laravel", + "symfony" + ], + "support": { + "issues": "https://github.com/fruitcake/php-cors/issues", + "source": "https://github.com/fruitcake/php-cors/tree/v1.4.0" + }, + "funding": [ + { + "url": "https://fruitcake.nl", + "type": "custom" + }, + { + "url": "https://github.com/barryvdh", + "type": "github" + } + ], + "time": "2025-12-03T09:33:47+00:00" + }, + { + "name": "graham-campbell/result-type", + "version": "v1.1.4", + "source": { + "type": "git", + "url": "https://github.com/GrahamCampbell/Result-Type.git", + "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/e01f4a821471308ba86aa202fed6698b6b695e3b", + "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.9.5" + }, + "require-dev": { + "phpunit/phpunit": "^8.5.41 || ^9.6.22 || ^10.5.45 || ^11.5.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "GrahamCampbell\\ResultType\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "An Implementation Of The Result Type", + "keywords": [ + "Graham Campbell", + "GrahamCampbell", + "Result Type", + "Result-Type", + "result" + ], + "support": { + "issues": "https://github.com/GrahamCampbell/Result-Type/issues", + "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.4" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/graham-campbell/result-type", + "type": "tidelift" + } + ], + "time": "2025-12-27T19:43:20+00:00" + }, + { + "name": "guzzlehttp/guzzle", + "version": "7.10.4", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle.git", + "reference": "aec528da477062d3af11f51e6b33402be233b21f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/aec528da477062d3af11f51e6b33402be233b21f", + "reference": "aec528da477062d3af11f51e6b33402be233b21f", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/promises": "^2.3", + "guzzlehttp/psr7": "^2.8", + "php": "^7.2.5 || ^8.0", + "psr/http-client": "^1.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" + }, + "provide": { + "psr/http-client-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-curl": "*", + "guzzle/client-integration-tests": "3.0.2", + "guzzlehttp/test-server": "^0.3.2", + "php-http/message-factory": "^1.1", + "phpunit/phpunit": "^8.5.52 || ^9.6.34", + "psr/log": "^1.1 || ^2.0 || ^3.0" + }, + "suggest": { + "ext-curl": "Required for CURL handler support", + "ext-intl": "Required for Internationalized Domain Name (IDN) support", + "psr/log": "Required for using the Log middleware" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "GuzzleHttp\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Jeremy Lindblom", + "email": "jeremeamia@gmail.com", + "homepage": "https://github.com/jeremeamia" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle is a PHP HTTP client library", + "keywords": [ + "client", + "curl", + "framework", + "http", + "http client", + "psr-18", + "psr-7", + "rest", + "web service" + ], + "support": { + "issues": "https://github.com/guzzle/guzzle/issues", + "source": "https://github.com/guzzle/guzzle/tree/7.10.4" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", + "type": "tidelift" + } + ], + "time": "2026-05-22T19:00:53+00:00" + }, + { + "name": "guzzlehttp/promises", + "version": "2.4.1", + "source": { + "type": "git", + "url": "https://github.com/guzzle/promises.git", + "reference": "09e8a212562fb1fb6a512c4156ed71525969d6c2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/promises/zipball/09e8a212562fb1fb6a512c4156ed71525969d6c2", + "reference": "09e8a212562fb1fb6a512c4156ed71525969d6c2", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.52 || ^9.6.34" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/2.4.1" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", + "type": "tidelift" + } + ], + "time": "2026-05-20T22:57:30+00:00" + }, + { + "name": "guzzlehttp/psr7", + "version": "2.10.1", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "73ab136360b5dfd858006eae9795e8fe43c80361" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/73ab136360b5dfd858006eae9795e8fe43c80361", + "reference": "73ab136360b5dfd858006eae9795e8fe43c80361", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0", + "ralouphie/getallheaders": "^3.0" + }, + "provide": { + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "http-interop/http-factory-tests": "1.1.0", + "jshttp/mime-db": "1.54.0.1", + "phpunit/phpunit": "^8.5.52 || ^9.6.34" + }, + "suggest": { + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/2.10.1" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" + } + ], + "time": "2026-05-20T09:27:36+00:00" + }, + { + "name": "guzzlehttp/uri-template", + "version": "v1.0.6", + "source": { + "type": "git", + "url": "https://github.com/guzzle/uri-template.git", + "reference": "eef7f87bab6f204eba3c39224d8075c70c637946" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/uri-template/zipball/eef7f87bab6f204eba3c39224d8075c70c637946", + "reference": "eef7f87bab6f204eba3c39224d8075c70c637946", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "symfony/polyfill-php80": "^1.24" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.52 || ^9.6.34", + "uri-template/tests": "1.0.0" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\UriTemplate\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + } + ], + "description": "A polyfill class for uri_template of PHP", + "keywords": [ + "guzzlehttp", + "uri-template" + ], + "support": { + "issues": "https://github.com/guzzle/uri-template/issues", + "source": "https://github.com/guzzle/uri-template/tree/v1.0.6" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/uri-template", + "type": "tidelift" + } + ], + "time": "2026-05-23T22:00:21+00:00" + }, + { + "name": "inertiajs/inertia-laravel", + "version": "v2.0.24", + "source": { + "type": "git", + "url": "https://github.com/inertiajs/inertia-laravel.git", + "reference": "ea345adad12f110edbbc4bef03b69c2374a535d4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/inertiajs/inertia-laravel/zipball/ea345adad12f110edbbc4bef03b69c2374a535d4", + "reference": "ea345adad12f110edbbc4bef03b69c2374a535d4", + "shasum": "" + }, + "require": { + "ext-json": "*", + "laravel/framework": "^10.0|^11.0|^12.0|^13.0", + "php": "^8.1.0", + "symfony/console": "^6.2|^7.0|^8.0" + }, + "conflict": { + "laravel/boost": "<2.2.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^7.2", + "larastan/larastan": "^3.0", + "laravel/pint": "^1.16", + "mockery/mockery": "^1.3.3", + "orchestra/testbench": "^8.0|^9.2|^10.0|^11.0", + "phpunit/phpunit": "^10.4|^11.5|^12.0", + "roave/security-advisories": "dev-master" + }, + "suggest": { + "ext-pcntl": "Recommended when running the Inertia SSR server via the `inertia:start-ssr` artisan command." + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Inertia\\ServiceProvider" + ] + } + }, + "autoload": { + "files": [ + "./helpers.php" + ], + "psr-4": { + "Inertia\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jonathan Reinink", + "email": "jonathan@reinink.ca", + "homepage": "https://reinink.ca" + } + ], + "description": "The Laravel adapter for Inertia.js.", + "keywords": [ + "inertia", + "laravel" + ], + "support": { + "issues": "https://github.com/inertiajs/inertia-laravel/issues", + "source": "https://github.com/inertiajs/inertia-laravel/tree/v2.0.24" + }, + "time": "2026-04-10T14:36:44+00:00" + }, + { + "name": "laravel/framework", + "version": "v13.11.2", + "source": { + "type": "git", + "url": "https://github.com/laravel/framework.git", + "reference": "4148042bf6ee01edd05408f1f66d91b231f85c25" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/framework/zipball/4148042bf6ee01edd05408f1f66d91b231f85c25", + "reference": "4148042bf6ee01edd05408f1f66d91b231f85c25", + "shasum": "" + }, + "require": { + "brick/math": "^0.14.2 || ^0.15 || ^0.16 || ^0.17", + "composer-runtime-api": "^2.2", + "doctrine/inflector": "^2.0.5", + "dragonmantank/cron-expression": "^3.4", + "egulias/email-validator": "^4.0", + "ext-ctype": "*", + "ext-filter": "*", + "ext-hash": "*", + "ext-mbstring": "*", + "ext-openssl": "*", + "ext-session": "*", + "ext-tokenizer": "*", + "fruitcake/php-cors": "^1.3", + "guzzlehttp/guzzle": "^7.8.2", + "guzzlehttp/promises": "^2.0.3", + "guzzlehttp/uri-template": "^1.0", + "laravel/prompts": "^0.3.0", + "laravel/serializable-closure": "^2.0.10", + "league/commonmark": "^2.8.1", + "league/flysystem": "^3.25.1", + "league/flysystem-local": "^3.25.1", + "league/uri": "^7.5.1", + "monolog/monolog": "^3.0", + "nesbot/carbon": "^3.8.4", + "nunomaduro/termwind": "^2.0", + "php": "^8.3", + "psr/container": "^1.1.1 || ^2.0.1", + "psr/log": "^1.0 || ^2.0 || ^3.0", + "psr/simple-cache": "^1.0 || ^2.0 || ^3.0", + "ramsey/uuid": "^4.7", + "symfony/console": "^7.4.0 || ^8.0.0", + "symfony/error-handler": "^7.4.0 || ^8.0.0", + "symfony/finder": "^7.4.0 || ^8.0.0", + "symfony/http-foundation": "^7.4.0 || ^8.0.0", + "symfony/http-kernel": "^7.4.0 || ^8.0.0", + "symfony/mailer": "^7.4.0 || ^8.0.0", + "symfony/mime": "^7.4.0 || ^8.0.0", + "symfony/polyfill-php84": "^1.36", + "symfony/polyfill-php85": "^1.36", + "symfony/polyfill-php86": "^1.36", + "symfony/process": "^7.4.5 || ^8.0.5", + "symfony/routing": "^7.4.0 || ^8.0.0", + "symfony/uid": "^7.4.0 || ^8.0.0", + "symfony/var-dumper": "^7.4.0 || ^8.0.0", + "tijsverkoyen/css-to-inline-styles": "^2.2.5", + "vlucas/phpdotenv": "^5.6.1", + "voku/portable-ascii": "^2.0.2" + }, + "conflict": { + "tightenco/collect": "<5.5.33" + }, + "provide": { + "psr/container-implementation": "1.1 || 2.0", + "psr/log-implementation": "1.0 || 2.0 || 3.0", + "psr/simple-cache-implementation": "1.0 || 2.0 || 3.0" + }, + "replace": { + "illuminate/auth": "self.version", + "illuminate/broadcasting": "self.version", + "illuminate/bus": "self.version", + "illuminate/cache": "self.version", + "illuminate/collections": "self.version", + "illuminate/concurrency": "self.version", + "illuminate/conditionable": "self.version", + "illuminate/config": "self.version", + "illuminate/console": "self.version", + "illuminate/container": "self.version", + "illuminate/contracts": "self.version", + "illuminate/cookie": "self.version", + "illuminate/database": "self.version", + "illuminate/encryption": "self.version", + "illuminate/events": "self.version", + "illuminate/filesystem": "self.version", + "illuminate/hashing": "self.version", + "illuminate/http": "self.version", + "illuminate/json-schema": "self.version", + "illuminate/log": "self.version", + "illuminate/macroable": "self.version", + "illuminate/mail": "self.version", + "illuminate/notifications": "self.version", + "illuminate/pagination": "self.version", + "illuminate/pipeline": "self.version", + "illuminate/process": "self.version", + "illuminate/queue": "self.version", + "illuminate/redis": "self.version", + "illuminate/reflection": "self.version", + "illuminate/routing": "self.version", + "illuminate/session": "self.version", + "illuminate/support": "self.version", + "illuminate/testing": "self.version", + "illuminate/translation": "self.version", + "illuminate/validation": "self.version", + "illuminate/view": "self.version", + "spatie/once": "*" + }, + "require-dev": { + "ably/ably-php": "^1.0", + "aws/aws-sdk-php": "^3.322.9", + "ext-gmp": "*", + "fakerphp/faker": "^1.24", + "guzzlehttp/psr7": "^2.9", + "laravel/pint": "^1.18", + "league/flysystem-aws-s3-v3": "^3.25.1", + "league/flysystem-ftp": "^3.25.1", + "league/flysystem-path-prefixing": "^3.25.1", + "league/flysystem-read-only": "^3.25.1", + "league/flysystem-sftp-v3": "^3.25.1", + "mockery/mockery": "^1.6.10", + "opis/json-schema": "^2.4.1", + "orchestra/testbench-core": "^11.0.0", + "pda/pheanstalk": "^7.0.0 || ^8.0.0", + "php-http/discovery": "^1.15", + "phpstan/phpstan": "^2.0", + "phpunit/phpunit": "^11.5.50 || ^12.5.8 || ^13.0.3", + "predis/predis": "^2.3 || ^3.0", + "rector/rector": "^2.3", + "resend/resend-php": "^1.0", + "symfony/cache": "^7.4.0 || ^8.0.0", + "symfony/http-client": "^7.4.0 || ^8.0.0", + "symfony/psr-http-message-bridge": "^7.4.0 || ^8.0.0", + "symfony/translation": "^7.4.0 || ^8.0.0" + }, + "suggest": { + "ably/ably-php": "Required to use the Ably broadcast driver (^1.0).", + "aws/aws-sdk-php": "Required to use the SQS queue driver, DynamoDb failed job storage, and SES mail driver (^3.322.9).", + "brianium/paratest": "Required to run tests in parallel (^7.0 || ^8.0).", + "ext-apcu": "Required to use the APC cache driver.", + "ext-fileinfo": "Required to use the Filesystem class.", + "ext-ftp": "Required to use the Flysystem FTP driver.", + "ext-gd": "Required to use Illuminate\\Http\\Testing\\FileFactory::image().", + "ext-memcached": "Required to use the memcache cache driver.", + "ext-pcntl": "Required to use all features of the queue worker and console signal trapping.", + "ext-pdo": "Required to use all database features.", + "ext-posix": "Required to use all features of the queue worker.", + "ext-redis": "Required to use the Redis cache and queue drivers (^4.0 || ^5.0 || ^6.0).", + "fakerphp/faker": "Required to generate fake data using the fake() helper (^1.23).", + "filp/whoops": "Required for friendly error pages in development (^2.14.3).", + "laravel/tinker": "Required to use the tinker console command (^2.0).", + "league/flysystem-aws-s3-v3": "Required to use the Flysystem S3 driver (^3.25.1).", + "league/flysystem-ftp": "Required to use the Flysystem FTP driver (^3.25.1).", + "league/flysystem-path-prefixing": "Required to use the scoped driver (^3.25.1).", + "league/flysystem-read-only": "Required to use read-only disks (^3.25.1)", + "league/flysystem-sftp-v3": "Required to use the Flysystem SFTP driver (^3.25.1).", + "mockery/mockery": "Required to use mocking (^1.6).", + "pda/pheanstalk": "Required to use the beanstalk queue driver (^7.0 || ^8.0).", + "php-http/discovery": "Required to use PSR-7 bridging features (^1.15).", + "phpunit/phpunit": "Required to use assertions and run tests (^11.5.50 || ^12.5.8 || ^13.0.3).", + "predis/predis": "Required to use the predis connector (^2.3 || ^3.0).", + "psr/http-message": "Required to allow Storage::put to accept a StreamInterface (^1.0).", + "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^6.0 || ^7.0).", + "resend/resend-php": "Required to enable support for the Resend mail transport (^0.10.0 || ^1.0).", + "spatie/fork": "Required to use the 'fork' concurrency driver (^1.2).", + "symfony/cache": "Required to PSR-6 cache bridge (^7.4 || ^8.0).", + "symfony/filesystem": "Required to enable support for relative symbolic links (^7.4 || ^8.0).", + "symfony/http-client": "Required to enable support for the Symfony API mail transports (^7.4 || ^8.0).", + "symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^7.4 || ^8.0).", + "symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^7.4 || ^8.0).", + "symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.4 || ^8.0)." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "13.0.x-dev" + } + }, + "autoload": { + "files": [ + "src/Illuminate/Collections/functions.php", + "src/Illuminate/Collections/helpers.php", + "src/Illuminate/Events/functions.php", + "src/Illuminate/Filesystem/functions.php", + "src/Illuminate/Foundation/helpers.php", + "src/Illuminate/Log/functions.php", + "src/Illuminate/Reflection/helpers.php", + "src/Illuminate/Support/functions.php", + "src/Illuminate/Support/helpers.php" + ], + "psr-4": { + "Illuminate\\": "src/Illuminate/", + "Illuminate\\Support\\": [ + "src/Illuminate/Macroable/", + "src/Illuminate/Collections/", + "src/Illuminate/Conditionable/", + "src/Illuminate/Reflection/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Laravel Framework.", + "homepage": "https://laravel.com", + "keywords": [ + "framework", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2026-05-20T11:46:02+00:00" + }, + { + "name": "laravel/prompts", + "version": "v0.3.18", + "source": { + "type": "git", + "url": "https://github.com/laravel/prompts.git", + "reference": "a19af51bb144bf87f08397921fa619f85c7d4e72" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/prompts/zipball/a19af51bb144bf87f08397921fa619f85c7d4e72", + "reference": "a19af51bb144bf87f08397921fa619f85c7d4e72", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.2", + "ext-mbstring": "*", + "php": "^8.1", + "symfony/console": "^6.2|^7.0|^8.0" + }, + "conflict": { + "illuminate/console": ">=10.17.0 <10.25.0", + "laravel/framework": ">=10.17.0 <10.25.0" + }, + "require-dev": { + "illuminate/collections": "^10.0|^11.0|^12.0|^13.0", + "mockery/mockery": "^1.5", + "pestphp/pest": "^2.3|^3.4|^4.0", + "phpstan/phpstan": "^1.12.28", + "phpstan/phpstan-mockery": "^1.1.3" + }, + "suggest": { + "ext-pcntl": "Required for the spinner to be animated." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "0.3.x-dev" + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Laravel\\Prompts\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Add beautiful and user-friendly forms to your command-line applications.", + "support": { + "issues": "https://github.com/laravel/prompts/issues", + "source": "https://github.com/laravel/prompts/tree/v0.3.18" + }, + "time": "2026-05-19T00:47:18+00:00" + }, + { + "name": "laravel/reverb", + "version": "v1.10.2", + "source": { + "type": "git", + "url": "https://github.com/laravel/reverb.git", + "reference": "43a5c0a99b1aaba33dc32f97fcf51f182dd8c8ac" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/reverb/zipball/43a5c0a99b1aaba33dc32f97fcf51f182dd8c8ac", + "reference": "43a5c0a99b1aaba33dc32f97fcf51f182dd8c8ac", + "shasum": "" + }, + "require": { + "clue/redis-react": "^2.6", + "guzzlehttp/psr7": "^2.6", + "illuminate/console": "^10.47|^11.0|^12.0|^13.0", + "illuminate/contracts": "^10.47|^11.0|^12.0|^13.0", + "illuminate/http": "^10.47|^11.0|^12.0|^13.0", + "illuminate/support": "^10.47|^11.0|^12.0|^13.0", + "laravel/prompts": "^0.1.15|^0.2.0|^0.3.0", + "php": "^8.2", + "pusher/pusher-php-server": "^7.2", + "ratchet/rfc6455": "^0.4", + "react/promise-timer": "^1.10", + "react/socket": "^1.14", + "symfony/console": "^6.0|^7.0|^8.0", + "symfony/http-foundation": "^6.3|^7.0|^8.0" + }, + "require-dev": { + "orchestra/testbench": "^8.36|^9.15|^10.8|^11.0", + "pestphp/pest": "^2.0|^3.0|^4.0", + "phpstan/phpstan": "^1.10", + "ratchet/pawl": "^0.4.1", + "react/async": "^4.2", + "react/http": "^1.9" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Reverb\\ApplicationManagerServiceProvider", + "Laravel\\Reverb\\ReverbServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Reverb\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Joe Dixon", + "email": "joe@laravel.com" + } + ], + "description": "Laravel Reverb provides a real-time WebSocket communication backend for Laravel applications.", + "keywords": [ + "WebSockets", + "laravel", + "real-time", + "websocket" + ], + "support": { + "issues": "https://github.com/laravel/reverb/issues", + "source": "https://github.com/laravel/reverb/tree/v1.10.2" + }, + "time": "2026-05-10T15:47:52+00:00" + }, + { + "name": "laravel/sanctum", + "version": "v4.3.2", + "source": { + "type": "git", + "url": "https://github.com/laravel/sanctum.git", + "reference": "2a9bccc18e9907808e0018dd15fa643937886b1e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/sanctum/zipball/2a9bccc18e9907808e0018dd15fa643937886b1e", + "reference": "2a9bccc18e9907808e0018dd15fa643937886b1e", + "shasum": "" + }, + "require": { + "ext-json": "*", + "illuminate/console": "^11.0|^12.0|^13.0", + "illuminate/contracts": "^11.0|^12.0|^13.0", + "illuminate/database": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", + "php": "^8.2", + "symfony/console": "^7.0|^8.0" + }, + "require-dev": { + "mockery/mockery": "^1.6", + "orchestra/testbench": "^9.15|^10.8|^11.0", + "phpstan/phpstan": "^1.10" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Sanctum\\SanctumServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Sanctum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel Sanctum provides a featherweight authentication system for SPAs and simple APIs.", + "keywords": [ + "auth", + "laravel", + "sanctum" + ], + "support": { + "issues": "https://github.com/laravel/sanctum/issues", + "source": "https://github.com/laravel/sanctum" + }, + "time": "2026-04-30T11:46:25+00:00" + }, + { + "name": "laravel/serializable-closure", + "version": "v2.0.13", + "source": { + "type": "git", + "url": "https://github.com/laravel/serializable-closure.git", + "reference": "b566ee0dd251f3c4078bed003a7ce015f5ea6dce" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/b566ee0dd251f3c4078bed003a7ce015f5ea6dce", + "reference": "b566ee0dd251f3c4078bed003a7ce015f5ea6dce", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", + "nesbot/carbon": "^2.67|^3.0", + "pestphp/pest": "^2.36|^3.0|^4.0", + "phpstan/phpstan": "^2.0", + "symfony/var-dumper": "^6.2.0|^7.0.0|^8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\SerializableClosure\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Nuno Maduro", + "email": "nuno@laravel.com" + } + ], + "description": "Laravel Serializable Closure provides an easy and secure way to serialize closures in PHP.", + "keywords": [ + "closure", + "laravel", + "serializable" + ], + "support": { + "issues": "https://github.com/laravel/serializable-closure/issues", + "source": "https://github.com/laravel/serializable-closure" + }, + "time": "2026-04-16T14:03:50+00:00" + }, + { + "name": "laravel/tinker", + "version": "v3.0.2", + "source": { + "type": "git", + "url": "https://github.com/laravel/tinker.git", + "reference": "4faba77764bd33411735936acdf30446d058c78b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/tinker/zipball/4faba77764bd33411735936acdf30446d058c78b", + "reference": "4faba77764bd33411735936acdf30446d058c78b", + "shasum": "" + }, + "require": { + "illuminate/console": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/contracts": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "php": "^8.1", + "psy/psysh": "^0.12.0", + "symfony/var-dumper": "^5.4|^6.0|^7.0|^8.0" + }, + "require-dev": { + "mockery/mockery": "~1.3.3|^1.4.2", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^10.5|^11.5" + }, + "suggest": { + "illuminate/database": "The Illuminate Database package (^8.0|^9.0|^10.0|^11.0|^12.0|^13.0)." + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Tinker\\TinkerServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Tinker\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Powerful REPL for the Laravel framework.", + "keywords": [ + "REPL", + "Tinker", + "laravel", + "psysh" + ], + "support": { + "issues": "https://github.com/laravel/tinker/issues", + "source": "https://github.com/laravel/tinker/tree/v3.0.2" + }, + "time": "2026-03-17T14:54:13+00:00" + }, + { + "name": "league/commonmark", + "version": "2.8.2", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/commonmark.git", + "reference": "59fb075d2101740c337c7216e3f32b36c204218b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/59fb075d2101740c337c7216e3f32b36c204218b", + "reference": "59fb075d2101740c337c7216e3f32b36c204218b", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "league/config": "^1.1.1", + "php": "^7.4 || ^8.0", + "psr/event-dispatcher": "^1.0", + "symfony/deprecation-contracts": "^2.1 || ^3.0", + "symfony/polyfill-php80": "^1.16" + }, + "require-dev": { + "cebe/markdown": "^1.0", + "commonmark/cmark": "0.31.1", + "commonmark/commonmark.js": "0.31.1", + "composer/package-versions-deprecated": "^1.8", + "embed/embed": "^4.4", + "erusev/parsedown": "^1.0", + "ext-json": "*", + "github/gfm": "0.29.0", + "michelf/php-markdown": "^1.4 || ^2.0", + "nyholm/psr7": "^1.5", + "phpstan/phpstan": "^1.8.2", + "phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0", + "scrutinizer/ocular": "^1.8.1", + "symfony/finder": "^5.3 | ^6.0 | ^7.0 || ^8.0", + "symfony/process": "^5.4 | ^6.0 | ^7.0 || ^8.0", + "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0 || ^8.0", + "unleashedtech/php-coding-standard": "^3.1.1", + "vimeo/psalm": "^4.24.0 || ^5.0.0 || ^6.0.0" + }, + "suggest": { + "symfony/yaml": "v2.3+ required if using the Front Matter extension" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.9-dev" + } + }, + "autoload": { + "psr-4": { + "League\\CommonMark\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com", + "role": "Lead Developer" + } + ], + "description": "Highly-extensible PHP Markdown parser which fully supports the CommonMark spec and GitHub-Flavored Markdown (GFM)", + "homepage": "https://commonmark.thephpleague.com", + "keywords": [ + "commonmark", + "flavored", + "gfm", + "github", + "github-flavored", + "markdown", + "md", + "parser" + ], + "support": { + "docs": "https://commonmark.thephpleague.com/", + "forum": "https://github.com/thephpleague/commonmark/discussions", + "issues": "https://github.com/thephpleague/commonmark/issues", + "rss": "https://github.com/thephpleague/commonmark/releases.atom", + "source": "https://github.com/thephpleague/commonmark" + }, + "funding": [ + { + "url": "https://www.colinodell.com/sponsor", + "type": "custom" + }, + { + "url": "https://www.paypal.me/colinpodell/10.00", + "type": "custom" + }, + { + "url": "https://github.com/colinodell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/commonmark", + "type": "tidelift" + } + ], + "time": "2026-03-19T13:16:38+00:00" + }, + { + "name": "league/config", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/config.git", + "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/config/zipball/754b3604fb2984c71f4af4a9cbe7b57f346ec1f3", + "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3", + "shasum": "" + }, + "require": { + "dflydev/dot-access-data": "^3.0.1", + "nette/schema": "^1.2", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.8.2", + "phpunit/phpunit": "^9.5.5", + "scrutinizer/ocular": "^1.8.1", + "unleashedtech/php-coding-standard": "^3.1", + "vimeo/psalm": "^4.7.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.2-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Config\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com", + "role": "Lead Developer" + } + ], + "description": "Define configuration arrays with strict schemas and access values with dot notation", + "homepage": "https://config.thephpleague.com", + "keywords": [ + "array", + "config", + "configuration", + "dot", + "dot-access", + "nested", + "schema" + ], + "support": { + "docs": "https://config.thephpleague.com/", + "issues": "https://github.com/thephpleague/config/issues", + "rss": "https://github.com/thephpleague/config/releases.atom", + "source": "https://github.com/thephpleague/config" + }, + "funding": [ + { + "url": "https://www.colinodell.com/sponsor", + "type": "custom" + }, + { + "url": "https://www.paypal.me/colinpodell/10.00", + "type": "custom" + }, + { + "url": "https://github.com/colinodell", + "type": "github" + } + ], + "time": "2022-12-11T20:36:23+00:00" + }, + { + "name": "league/flysystem", + "version": "3.34.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem.git", + "reference": "2daaac3b0d4c83ea7ed5d8586e786f5d00f3540e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/2daaac3b0d4c83ea7ed5d8586e786f5d00f3540e", + "reference": "2daaac3b0d4c83ea7ed5d8586e786f5d00f3540e", + "shasum": "" + }, + "require": { + "league/flysystem-local": "^3.0.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "conflict": { + "async-aws/core": "<1.19.0", + "async-aws/s3": "<1.14.0", + "aws/aws-sdk-php": "3.209.31 || 3.210.0", + "guzzlehttp/guzzle": "<7.0", + "guzzlehttp/ringphp": "<1.1.1", + "phpseclib/phpseclib": "3.0.15", + "symfony/http-client": "<5.2" + }, + "require-dev": { + "async-aws/s3": "^1.5 || ^2.0", + "async-aws/simple-s3": "^1.1 || ^2.0", + "aws/aws-sdk-php": "^3.295.10", + "composer/semver": "^3.0", + "ext-fileinfo": "*", + "ext-ftp": "*", + "ext-mongodb": "^1.3|^2", + "ext-zip": "*", + "friendsofphp/php-cs-fixer": "^3.5", + "google/cloud-storage": "^1.23", + "guzzlehttp/psr7": "^2.6", + "microsoft/azure-storage-blob": "^1.1", + "mongodb/mongodb": "^1.2|^2", + "phpseclib/phpseclib": "^3.0.36", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^9.5.11|^10.0", + "sabre/dav": "^4.6.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "File storage abstraction for PHP", + "keywords": [ + "WebDAV", + "aws", + "cloud", + "file", + "files", + "filesystem", + "filesystems", + "ftp", + "s3", + "sftp", + "storage" + ], + "support": { + "issues": "https://github.com/thephpleague/flysystem/issues", + "source": "https://github.com/thephpleague/flysystem/tree/3.34.0" + }, + "time": "2026-05-14T10:28:08+00:00" + }, + { + "name": "league/flysystem-local", + "version": "3.31.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem-local.git", + "reference": "2f669db18a4c20c755c2bb7d3a7b0b2340488079" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/2f669db18a4c20c755c2bb7d3a7b0b2340488079", + "reference": "2f669db18a4c20c755c2bb7d3a7b0b2340488079", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "league/flysystem": "^3.0.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\Local\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "Local filesystem adapter for Flysystem.", + "keywords": [ + "Flysystem", + "file", + "files", + "filesystem", + "local" + ], + "support": { + "source": "https://github.com/thephpleague/flysystem-local/tree/3.31.0" + }, + "time": "2026-01-23T15:30:45+00:00" + }, + { + "name": "league/mime-type-detection", + "version": "1.16.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/mime-type-detection.git", + "reference": "2d6702ff215bf922936ccc1ad31007edc76451b9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/2d6702ff215bf922936ccc1ad31007edc76451b9", + "reference": "2d6702ff215bf922936ccc1ad31007edc76451b9", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.2", + "phpstan/phpstan": "^0.12.68", + "phpunit/phpunit": "^8.5.8 || ^9.3 || ^10.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\MimeTypeDetection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "Mime-type detection for Flysystem", + "support": { + "issues": "https://github.com/thephpleague/mime-type-detection/issues", + "source": "https://github.com/thephpleague/mime-type-detection/tree/1.16.0" + }, + "funding": [ + { + "url": "https://github.com/frankdejonge", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/flysystem", + "type": "tidelift" + } + ], + "time": "2024-09-21T08:32:55+00:00" + }, + { + "name": "league/uri", + "version": "7.8.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri.git", + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri/zipball/08cf38e3924d4f56238125547b5720496fac8fd4", + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4", + "shasum": "" + }, + "require": { + "league/uri-interfaces": "^7.8.1", + "php": "^8.1", + "psr/http-factory": "^1" + }, + "conflict": { + "league/uri-schemes": "^1.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-dom": "to convert the URI into an HTML anchor tag", + "ext-fileinfo": "to create Data URI from file contennts", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "ext-uri": "to use the PHP native URI class", + "jeremykendall/php-domain-parser": "to further parse the URI host and resolve its Public Suffix and Top Level Domain", + "league/uri-components": "to provide additional tools to manipulate URI objects components", + "league/uri-polyfill": "to backport the PHP URI extension for older versions of PHP", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "URI manipulation library", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "URN", + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "middleware", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc2141", + "rfc3986", + "rfc3987", + "rfc6570", + "rfc8141", + "uri", + "uri-template", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri/tree/7.8.1" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2026-03-15T20:22:25+00:00" + }, + { + "name": "league/uri-interfaces", + "version": "7.8.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri-interfaces.git", + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/85d5c77c5d6d3af6c54db4a78246364908f3c928", + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "php": "^8.1", + "psr/http-message": "^1.1 || ^2.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "Common tools for parsing and resolving RFC3987/RFC3986 URI", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc3986", + "rfc3987", + "rfc6570", + "uri", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri-interfaces/tree/7.8.1" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2026-03-08T20:05:35+00:00" + }, + { + "name": "maatwebsite/excel", + "version": "3.1.69", + "source": { + "type": "git", + "url": "https://github.com/SpartnerNL/Laravel-Excel.git", + "reference": "ae5d65b7c9a2fac43bff4d44f796ac95d7a8e760" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/SpartnerNL/Laravel-Excel/zipball/ae5d65b7c9a2fac43bff4d44f796ac95d7a8e760", + "reference": "ae5d65b7c9a2fac43bff4d44f796ac95d7a8e760", + "shasum": "" + }, + "require": { + "composer/semver": "^3.3", + "ext-json": "*", + "illuminate/support": "5.8.*||^6.0||^7.0||^8.0||^9.0||^10.0||^11.0||^12.0||^13.0", + "php": "^7.0||^8.0", + "phpoffice/phpspreadsheet": "^1.30.4", + "psr/simple-cache": "^1.0||^2.0||^3.0" + }, + "require-dev": { + "laravel/scout": "^7.0||^8.0||^9.0||^10.0||^11.0", + "orchestra/testbench": "^6.0||^7.0||^8.0||^9.0||^10.0||^11.0", + "predis/predis": "^1.1" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Excel": "Maatwebsite\\Excel\\Facades\\Excel" + }, + "providers": [ + "Maatwebsite\\Excel\\ExcelServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Maatwebsite\\Excel\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Patrick Brouwers", + "email": "patrick@spartner.nl" + } + ], + "description": "Supercharged Excel exports and imports in Laravel", + "keywords": [ + "PHPExcel", + "batch", + "csv", + "excel", + "export", + "import", + "laravel", + "php", + "phpspreadsheet" + ], + "support": { + "issues": "https://github.com/SpartnerNL/Laravel-Excel/issues", + "source": "https://github.com/SpartnerNL/Laravel-Excel/tree/3.1.69" + }, + "funding": [ + { + "url": "https://laravel-excel.com/commercial-support", + "type": "custom" + }, + { + "url": "https://github.com/patrickbrouwers", + "type": "github" + } + ], + "time": "2026-04-30T20:03:58+00:00" + }, + { + "name": "maennchen/zipstream-php", + "version": "3.2.2", + "source": { + "type": "git", + "url": "https://github.com/maennchen/ZipStream-PHP.git", + "reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e", + "reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "ext-zlib": "*", + "php-64bit": "^8.3" + }, + "require-dev": { + "brianium/paratest": "^7.7", + "ext-zip": "*", + "friendsofphp/php-cs-fixer": "^3.86", + "guzzlehttp/guzzle": "^7.5", + "mikey179/vfsstream": "^1.6", + "php-coveralls/php-coveralls": "^2.5", + "phpunit/phpunit": "^12.0", + "vimeo/psalm": "^6.0" + }, + "suggest": { + "guzzlehttp/psr7": "^2.4", + "psr/http-message": "^2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "ZipStream\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paul Duncan", + "email": "pabs@pablotron.org" + }, + { + "name": "Jonatan Männchen", + "email": "jonatan@maennchen.ch" + }, + { + "name": "Jesse Donat", + "email": "donatj@gmail.com" + }, + { + "name": "András Kolesár", + "email": "kolesar@kolesar.hu" + } + ], + "description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.", + "keywords": [ + "stream", + "zip" + ], + "support": { + "issues": "https://github.com/maennchen/ZipStream-PHP/issues", + "source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.2" + }, + "funding": [ + { + "url": "https://github.com/maennchen", + "type": "github" + } + ], + "time": "2026-04-11T18:38:28+00:00" + }, + { + "name": "markbaker/complex", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/MarkBaker/PHPComplex.git", + "reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/MarkBaker/PHPComplex/zipball/95c56caa1cf5c766ad6d65b6344b807c1e8405b9", + "reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "dev-master", + "phpcompatibility/php-compatibility": "^9.3", + "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0", + "squizlabs/php_codesniffer": "^3.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "Complex\\": "classes/src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mark Baker", + "email": "mark@lange.demon.co.uk" + } + ], + "description": "PHP Class for working with complex numbers", + "homepage": "https://github.com/MarkBaker/PHPComplex", + "keywords": [ + "complex", + "mathematics" + ], + "support": { + "issues": "https://github.com/MarkBaker/PHPComplex/issues", + "source": "https://github.com/MarkBaker/PHPComplex/tree/3.0.2" + }, + "time": "2022-12-06T16:21:08+00:00" + }, + { + "name": "markbaker/matrix", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/MarkBaker/PHPMatrix.git", + "reference": "728434227fe21be27ff6d86621a1b13107a2562c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/MarkBaker/PHPMatrix/zipball/728434227fe21be27ff6d86621a1b13107a2562c", + "reference": "728434227fe21be27ff6d86621a1b13107a2562c", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "dev-master", + "phpcompatibility/php-compatibility": "^9.3", + "phpdocumentor/phpdocumentor": "2.*", + "phploc/phploc": "^4.0", + "phpmd/phpmd": "2.*", + "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0", + "sebastian/phpcpd": "^4.0", + "squizlabs/php_codesniffer": "^3.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "Matrix\\": "classes/src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mark Baker", + "email": "mark@demon-angel.eu" + } + ], + "description": "PHP Class for working with matrices", + "homepage": "https://github.com/MarkBaker/PHPMatrix", + "keywords": [ + "mathematics", + "matrix", + "vector" + ], + "support": { + "issues": "https://github.com/MarkBaker/PHPMatrix/issues", + "source": "https://github.com/MarkBaker/PHPMatrix/tree/3.0.1" + }, + "time": "2022-12-02T22:17:43+00:00" + }, + { + "name": "masterminds/html5", + "version": "2.10.0", + "source": { + "type": "git", + "url": "https://github.com/Masterminds/html5-php.git", + "reference": "fcf91eb64359852f00d921887b219479b4f21251" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fcf91eb64359852f00d921887b219479b4f21251", + "reference": "fcf91eb64359852f00d921887b219479b4f21251", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Masterminds\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Matt Butcher", + "email": "technosophos@gmail.com" + }, + { + "name": "Matt Farina", + "email": "matt@mattfarina.com" + }, + { + "name": "Asmir Mustafic", + "email": "goetas@gmail.com" + } + ], + "description": "An HTML5 parser and serializer.", + "homepage": "http://masterminds.github.io/html5-php", + "keywords": [ + "HTML5", + "dom", + "html", + "parser", + "querypath", + "serializer", + "xml" + ], + "support": { + "issues": "https://github.com/Masterminds/html5-php/issues", + "source": "https://github.com/Masterminds/html5-php/tree/2.10.0" + }, + "time": "2025-07-25T09:04:22+00:00" + }, + { + "name": "monolog/monolog", + "version": "3.10.0", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/monolog.git", + "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/b321dd6749f0bf7189444158a3ce785cc16d69b0", + "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/log": "^2.0 || ^3.0" + }, + "provide": { + "psr/log-implementation": "3.0.0" + }, + "require-dev": { + "aws/aws-sdk-php": "^3.0", + "doctrine/couchdb": "~1.0@dev", + "elasticsearch/elasticsearch": "^7 || ^8", + "ext-json": "*", + "graylog2/gelf-php": "^1.4.2 || ^2.0", + "guzzlehttp/guzzle": "^7.4.5", + "guzzlehttp/psr7": "^2.2", + "mongodb/mongodb": "^1.8 || ^2.0", + "php-amqplib/php-amqplib": "~2.4 || ^3", + "php-console/php-console": "^3.1.8", + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^10.5.17 || ^11.0.7", + "predis/predis": "^1.1 || ^2", + "rollbar/rollbar": "^4.0", + "ruflin/elastica": "^7 || ^8", + "symfony/mailer": "^5.4 || ^6", + "symfony/mime": "^5.4 || ^6" + }, + "suggest": { + "aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB", + "doctrine/couchdb": "Allow sending log messages to a CouchDB server", + "elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client", + "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)", + "ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler", + "ext-mbstring": "Allow to work properly with unicode symbols", + "ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)", + "ext-openssl": "Required to send log messages using SSL", + "ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)", + "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server", + "mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)", + "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib", + "rollbar/rollbar": "Allow sending log messages to Rollbar", + "ruflin/elastica": "Allow sending log messages to an Elastic Search server" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Monolog\\": "src/Monolog" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "https://seld.be" + } + ], + "description": "Sends your logs to files, sockets, inboxes, databases and various web services", + "homepage": "https://github.com/Seldaek/monolog", + "keywords": [ + "log", + "logging", + "psr-3" + ], + "support": { + "issues": "https://github.com/Seldaek/monolog/issues", + "source": "https://github.com/Seldaek/monolog/tree/3.10.0" + }, + "funding": [ + { + "url": "https://github.com/Seldaek", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/monolog/monolog", + "type": "tidelift" + } + ], + "time": "2026-01-02T08:56:05+00:00" + }, + { + "name": "nesbot/carbon", + "version": "3.11.4", + "source": { + "type": "git", + "url": "https://github.com/CarbonPHP/carbon.git", + "reference": "e890471a3494740f7d9326d72ce6a8c559ffee60" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/e890471a3494740f7d9326d72ce6a8c559ffee60", + "reference": "e890471a3494740f7d9326d72ce6a8c559ffee60", + "shasum": "" + }, + "require": { + "carbonphp/carbon-doctrine-types": "<100.0", + "ext-json": "*", + "php": "^8.1", + "psr/clock": "^1.0", + "symfony/clock": "^6.3.12 || ^7.0 || ^8.0", + "symfony/polyfill-mbstring": "^1.0", + "symfony/translation": "^4.4.18 || ^5.2.1 || ^6.0 || ^7.0 || ^8.0" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "require-dev": { + "doctrine/dbal": "^3.6.3 || ^4.0", + "doctrine/orm": "^2.15.2 || ^3.0", + "friendsofphp/php-cs-fixer": "^v3.87.1", + "kylekatarnls/multi-tester": "^2.5.3", + "phpmd/phpmd": "^2.15.0", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.22", + "phpunit/phpunit": "^10.5.53", + "squizlabs/php_codesniffer": "^3.13.4 || ^4.0.0" + }, + "bin": [ + "bin/carbon" + ], + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Carbon\\Laravel\\ServiceProvider" + ] + }, + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-2.x": "2.x-dev", + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Carbon\\": "src/Carbon/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Brian Nesbitt", + "email": "brian@nesbot.com", + "homepage": "https://markido.com" + }, + { + "name": "kylekatarnls", + "homepage": "https://github.com/kylekatarnls" + } + ], + "description": "An API extension for DateTime that supports 281 different languages.", + "homepage": "https://carbonphp.github.io/carbon/", + "keywords": [ + "date", + "datetime", + "time" + ], + "support": { + "docs": "https://carbonphp.github.io/carbon/guide/getting-started/introduction.html", + "issues": "https://github.com/CarbonPHP/carbon/issues", + "source": "https://github.com/CarbonPHP/carbon" + }, + "funding": [ + { + "url": "https://github.com/sponsors/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon#sponsor", + "type": "opencollective" + }, + { + "url": "https://tidelift.com/subscription/pkg/packagist-nesbot-carbon?utm_source=packagist-nesbot-carbon&utm_medium=referral&utm_campaign=readme", + "type": "tidelift" + } + ], + "time": "2026-04-07T09:57:54+00:00" + }, + { + "name": "nette/schema", + "version": "v1.3.5", + "source": { + "type": "git", + "url": "https://github.com/nette/schema.git", + "reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/schema/zipball/f0ab1a3cda782dbc5da270d28545236aa80c4002", + "reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002", + "shasum": "" + }, + "require": { + "nette/utils": "^4.0", + "php": "8.1 - 8.5" + }, + "require-dev": { + "nette/phpstan-rules": "^1.0", + "nette/tester": "^2.6", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1.39@stable", + "tracy/tracy": "^2.8" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.3-dev" + } + }, + "autoload": { + "psr-4": { + "Nette\\": "src" + }, + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "📐 Nette Schema: validating data structures against a given Schema.", + "homepage": "https://nette.org", + "keywords": [ + "config", + "nette" + ], + "support": { + "issues": "https://github.com/nette/schema/issues", + "source": "https://github.com/nette/schema/tree/v1.3.5" + }, + "time": "2026-02-23T03:47:12+00:00" + }, + { + "name": "nette/utils", + "version": "v4.1.4", + "source": { + "type": "git", + "url": "https://github.com/nette/utils.git", + "reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/utils/zipball/7da6c396d7ebe142bc857c20479d5e70a5e1aac7", + "reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7", + "shasum": "" + }, + "require": { + "php": "8.2 - 8.5" + }, + "conflict": { + "nette/finder": "<3", + "nette/schema": "<1.2.2" + }, + "require-dev": { + "jetbrains/phpstorm-attributes": "^1.2", + "nette/phpstan-rules": "^1.0", + "nette/tester": "^2.5", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1@stable", + "tracy/tracy": "^2.9" + }, + "suggest": { + "ext-gd": "to use Image", + "ext-iconv": "to use Strings::webalize(), toAscii(), chr() and reverse()", + "ext-intl": "to use Strings::webalize(), toAscii(), normalize() and compare()", + "ext-json": "to use Nette\\Utils\\Json", + "ext-mbstring": "to use Strings::lower() etc...", + "ext-tokenizer": "to use Nette\\Utils\\Reflection::getUseStatements()" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.1-dev" + } + }, + "autoload": { + "psr-4": { + "Nette\\": "src" + }, + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "🛠 Nette Utils: lightweight utilities for string & array manipulation, image handling, safe JSON encoding/decoding, validation, slug or strong password generating etc.", + "homepage": "https://nette.org", + "keywords": [ + "array", + "core", + "datetime", + "images", + "json", + "nette", + "paginator", + "password", + "slugify", + "string", + "unicode", + "utf-8", + "utility", + "validation" + ], + "support": { + "issues": "https://github.com/nette/utils/issues", + "source": "https://github.com/nette/utils/tree/v4.1.4" + }, + "time": "2026-05-11T20:49:54+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.7.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" + }, + "time": "2025-12-06T11:56:16+00:00" + }, + { + "name": "nunomaduro/termwind", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/nunomaduro/termwind.git", + "reference": "712a31b768f5daea284c2169a7d227031001b9a8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/712a31b768f5daea284c2169a7d227031001b9a8", + "reference": "712a31b768f5daea284c2169a7d227031001b9a8", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^8.2", + "symfony/console": "^7.4.4 || ^8.0.4" + }, + "require-dev": { + "illuminate/console": "^11.47.0", + "laravel/pint": "^1.27.1", + "mockery/mockery": "^1.6.12", + "pestphp/pest": "^2.36.0 || ^3.8.4 || ^4.3.2", + "phpstan/phpstan": "^1.12.32", + "phpstan/phpstan-strict-rules": "^1.6.2", + "symfony/var-dumper": "^7.3.5 || ^8.0.4", + "thecodingmachine/phpstan-strict-rules": "^1.0.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Termwind\\Laravel\\TermwindServiceProvider" + ] + }, + "branch-alias": { + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "files": [ + "src/Functions.php" + ], + "psr-4": { + "Termwind\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "It's like Tailwind CSS, but for the console.", + "keywords": [ + "cli", + "console", + "css", + "package", + "php", + "style" + ], + "support": { + "issues": "https://github.com/nunomaduro/termwind/issues", + "source": "https://github.com/nunomaduro/termwind/tree/v2.4.0" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://github.com/xiCO2k", + "type": "github" + } + ], + "time": "2026-02-16T23:10:27+00:00" + }, + { + "name": "paragonie/constant_time_encoding", + "version": "v3.1.3", + "source": { + "type": "git", + "url": "https://github.com/paragonie/constant_time_encoding.git", + "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", + "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", + "shasum": "" + }, + "require": { + "php": "^8" + }, + "require-dev": { + "infection/infection": "^0", + "nikic/php-fuzzer": "^0", + "phpunit/phpunit": "^9|^10|^11", + "vimeo/psalm": "^4|^5|^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "ParagonIE\\ConstantTime\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com", + "role": "Maintainer" + }, + { + "name": "Steve 'Sc00bz' Thomas", + "email": "steve@tobtu.com", + "homepage": "https://www.tobtu.com", + "role": "Original Developer" + } + ], + "description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)", + "keywords": [ + "base16", + "base32", + "base32_decode", + "base32_encode", + "base64", + "base64_decode", + "base64_encode", + "bin2hex", + "encoding", + "hex", + "hex2bin", + "rfc4648" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/constant_time_encoding/issues", + "source": "https://github.com/paragonie/constant_time_encoding" + }, + "time": "2025-09-24T15:06:41+00:00" + }, + { + "name": "phpoffice/phpspreadsheet", + "version": "1.30.5", + "source": { + "type": "git", + "url": "https://github.com/PHPOffice/PhpSpreadsheet.git", + "reference": "97bcabd32a64924688487dcd64aceaf158affb5c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/97bcabd32a64924688487dcd64aceaf158affb5c", + "reference": "97bcabd32a64924688487dcd64aceaf158affb5c", + "shasum": "" + }, + "require": { + "composer/pcre": "^1||^2||^3", + "ext-ctype": "*", + "ext-dom": "*", + "ext-fileinfo": "*", + "ext-gd": "*", + "ext-iconv": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-simplexml": "*", + "ext-xml": "*", + "ext-xmlreader": "*", + "ext-xmlwriter": "*", + "ext-zip": "*", + "ext-zlib": "*", + "ezyang/htmlpurifier": "^4.15", + "maennchen/zipstream-php": "^2.1 || ^3.0", + "markbaker/complex": "^3.0", + "markbaker/matrix": "^3.0", + "php": ">=7.4.0 <8.5.0", + "psr/simple-cache": "^1.0 || ^2.0 || ^3.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "dev-main", + "doctrine/instantiator": "^1.5", + "dompdf/dompdf": "^1.0 || ^2.0 || ^3.0", + "friendsofphp/php-cs-fixer": "^3.2", + "mitoteam/jpgraph": "^10.3", + "mpdf/mpdf": "^8.1.1", + "phpcompatibility/php-compatibility": "^9.3", + "phpstan/phpstan": "^1.1", + "phpstan/phpstan-phpunit": "^1.0", + "phpunit/phpunit": "^8.5 || ^9.0", + "squizlabs/php_codesniffer": "^3.7", + "tecnickcom/tcpdf": "^6.5" + }, + "suggest": { + "dompdf/dompdf": "Option for rendering PDF with PDF Writer", + "ext-intl": "PHP Internationalization Functions", + "mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers", + "mpdf/mpdf": "Option for rendering PDF with PDF Writer", + "tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer" + }, + "type": "library", + "autoload": { + "psr-4": { + "PhpOffice\\PhpSpreadsheet\\": "src/PhpSpreadsheet" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Maarten Balliauw", + "homepage": "https://blog.maartenballiauw.be" + }, + { + "name": "Mark Baker", + "homepage": "https://markbakeruk.net" + }, + { + "name": "Franck Lefevre", + "homepage": "https://rootslabs.net" + }, + { + "name": "Erik Tilt" + }, + { + "name": "Adrien Crivelli" + }, + { + "name": "Owen Leibman" + } + ], + "description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine", + "homepage": "https://github.com/PHPOffice/PhpSpreadsheet", + "keywords": [ + "OpenXML", + "excel", + "gnumeric", + "ods", + "php", + "spreadsheet", + "xls", + "xlsx" + ], + "support": { + "issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues", + "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/1.30.5" + }, + "time": "2026-05-31T05:13:11+00:00" + }, + { + "name": "phpoption/phpoption", + "version": "1.9.5", + "source": { + "type": "git", + "url": "https://github.com/schmittjoh/php-option.git", + "reference": "75365b91986c2405cf5e1e012c5595cd487a98be" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/75365b91986c2405cf5e1e012c5595cd487a98be", + "reference": "75365b91986c2405cf5e1e012c5595cd487a98be", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.44 || ^9.6.25 || ^10.5.53 || ^11.5.34" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "1.9-dev" + } + }, + "autoload": { + "psr-4": { + "PhpOption\\": "src/PhpOption/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Johannes M. Schmitt", + "email": "schmittjoh@gmail.com", + "homepage": "https://github.com/schmittjoh" + }, + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "Option Type for PHP", + "keywords": [ + "language", + "option", + "php", + "type" + ], + "support": { + "issues": "https://github.com/schmittjoh/php-option/issues", + "source": "https://github.com/schmittjoh/php-option/tree/1.9.5" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpoption/phpoption", + "type": "tidelift" + } + ], + "time": "2025-12-27T19:41:33+00:00" + }, + { + "name": "pragmarx/google2fa", + "version": "v8.0.3", + "source": { + "type": "git", + "url": "https://github.com/antonioribeiro/google2fa.git", + "reference": "6f8d87ebd5afbf7790bde1ffc7579c7c705e0fad" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/antonioribeiro/google2fa/zipball/6f8d87ebd5afbf7790bde1ffc7579c7c705e0fad", + "reference": "6f8d87ebd5afbf7790bde1ffc7579c7c705e0fad", + "shasum": "" + }, + "require": { + "paragonie/constant_time_encoding": "^1.0|^2.0|^3.0", + "php": "^7.1|^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.9", + "phpunit/phpunit": "^7.5.15|^8.5|^9.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "PragmaRX\\Google2FA\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Antonio Carlos Ribeiro", + "email": "acr@antoniocarlosribeiro.com", + "role": "Creator & Designer" + } + ], + "description": "A One Time Password Authentication package, compatible with Google Authenticator.", + "keywords": [ + "2fa", + "Authentication", + "Two Factor Authentication", + "google2fa" + ], + "support": { + "issues": "https://github.com/antonioribeiro/google2fa/issues", + "source": "https://github.com/antonioribeiro/google2fa/tree/v8.0.3" + }, + "time": "2024-09-05T11:56:40+00:00" + }, + { + "name": "pragmarx/google2fa-laravel", + "version": "v3.0.1", + "source": { + "type": "git", + "url": "https://github.com/antonioribeiro/google2fa-laravel.git", + "reference": "d885bb5bca8be03b226d040aa80250402760a67c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/antonioribeiro/google2fa-laravel/zipball/d885bb5bca8be03b226d040aa80250402760a67c", + "reference": "d885bb5bca8be03b226d040aa80250402760a67c", + "shasum": "" + }, + "require": { + "laravel/framework": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "php": ">=7.0", + "pragmarx/google2fa-qrcode": "^1.0|^2.0|^3.0" + }, + "require-dev": { + "bacon/bacon-qr-code": "^2.0", + "orchestra/testbench": "3.4.*|3.5.*|3.6.*|3.7.*|4.*|5.*|6.*|7.*|8.*|9.*|10.*|11.*", + "phpunit/phpunit": "~5|~6|~7|~8|~9|~10|~11|~12" + }, + "suggest": { + "bacon/bacon-qr-code": "Required to generate inline QR Codes.", + "pragmarx/recovery": "Generate recovery codes." + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Google2FA": "PragmaRX\\Google2FALaravel\\Facade" + }, + "providers": [ + "PragmaRX\\Google2FALaravel\\ServiceProvider" + ] + }, + "component": "package", + "frameworks": [ + "Laravel" + ], + "branch-alias": { + "dev-master": "0.2-dev" + } + }, + "autoload": { + "psr-4": { + "PragmaRX\\Google2FALaravel\\": "src/", + "PragmaRX\\Google2FALaravel\\Tests\\": "tests/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Antonio Carlos Ribeiro", + "email": "acr@antoniocarlosribeiro.com", + "role": "Creator & Designer" + } + ], + "description": "A One Time Password Authentication package, compatible with Google Authenticator.", + "keywords": [ + "Authentication", + "Two Factor Authentication", + "google2fa", + "laravel" + ], + "support": { + "issues": "https://github.com/antonioribeiro/google2fa-laravel/issues", + "source": "https://github.com/antonioribeiro/google2fa-laravel/tree/v3.0.1" + }, + "time": "2026-03-17T20:54:53+00:00" + }, + { + "name": "pragmarx/google2fa-qrcode", + "version": "v3.0.1", + "source": { + "type": "git", + "url": "https://github.com/antonioribeiro/google2fa-qrcode.git", + "reference": "c23ebcc3a50de0d1566016a6dd1486e183bb78e1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/antonioribeiro/google2fa-qrcode/zipball/c23ebcc3a50de0d1566016a6dd1486e183bb78e1", + "reference": "c23ebcc3a50de0d1566016a6dd1486e183bb78e1", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "pragmarx/google2fa": "^4.0|^5.0|^6.0|^7.0|^8.0" + }, + "require-dev": { + "bacon/bacon-qr-code": "^2.0", + "chillerlan/php-qrcode": "^1.0|^2.0|^3.0|^4.0", + "khanamiryan/qrcode-detector-decoder": "^1.0", + "phpunit/phpunit": "~4|~5|~6|~7|~8|~9" + }, + "suggest": { + "bacon/bacon-qr-code": "For QR Code generation, requires imagick", + "chillerlan/php-qrcode": "For QR Code generation" + }, + "type": "library", + "extra": { + "component": "package", + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "psr-4": { + "PragmaRX\\Google2FAQRCode\\": "src/", + "PragmaRX\\Google2FAQRCode\\Tests\\": "tests/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Antonio Carlos Ribeiro", + "email": "acr@antoniocarlosribeiro.com", + "role": "Creator & Designer" + } + ], + "description": "QR Code package for Google2FA", + "keywords": [ + "2fa", + "Authentication", + "Two Factor Authentication", + "google2fa", + "qr code", + "qrcode" + ], + "support": { + "issues": "https://github.com/antonioribeiro/google2fa-qrcode/issues", + "source": "https://github.com/antonioribeiro/google2fa-qrcode/tree/v3.0.1" + }, + "time": "2025-09-19T23:02:26+00:00" + }, + { + "name": "psr/clock", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/clock.git", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Psr\\Clock\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for reading the clock.", + "homepage": "https://github.com/php-fig/clock", + "keywords": [ + "clock", + "now", + "psr", + "psr-20", + "time" + ], + "support": { + "issues": "https://github.com/php-fig/clock/issues", + "source": "https://github.com/php-fig/clock/tree/1.0.0" + }, + "time": "2022-11-25T14:36:26+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, + { + "name": "psr/http-client", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client" + }, + "time": "2023-09-23T14:17:50+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "psr/simple-cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/simple-cache.git", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\SimpleCache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interfaces for simple caching", + "keywords": [ + "cache", + "caching", + "psr", + "psr-16", + "simple-cache" + ], + "support": { + "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" + }, + "time": "2021-10-29T13:26:27+00:00" + }, + { + "name": "psy/psysh", + "version": "v0.12.23", + "source": { + "type": "git", + "url": "https://github.com/bobthecow/psysh.git", + "reference": "4dcc0f08047d52bbde475eda481146fd8e27e1a4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/4dcc0f08047d52bbde475eda481146fd8e27e1a4", + "reference": "4dcc0f08047d52bbde475eda481146fd8e27e1a4", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "nikic/php-parser": "^5.0 || ^4.0", + "php": "^8.0 || ^7.4", + "symfony/console": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4", + "symfony/var-dumper": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4" + }, + "conflict": { + "symfony/console": "4.4.37 || 5.3.14 || 5.3.15 || 5.4.3 || 5.4.4 || 6.0.3 || 6.0.4" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.2", + "composer/class-map-generator": "^1.6" + }, + "suggest": { + "composer/class-map-generator": "Improved tab completion performance with better class discovery.", + "ext-pcntl": "Enabling the PCNTL extension makes PsySH a lot happier :)", + "ext-posix": "If you have PCNTL, you'll want the POSIX extension as well." + }, + "bin": [ + "bin/psysh" + ], + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": false, + "forward-command": false + }, + "branch-alias": { + "dev-main": "0.12.x-dev" + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Psy\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Justin Hileman", + "email": "justin@justinhileman.info" + } + ], + "description": "An interactive shell for modern PHP.", + "homepage": "https://psysh.org", + "keywords": [ + "REPL", + "console", + "interactive", + "shell" + ], + "support": { + "issues": "https://github.com/bobthecow/psysh/issues", + "source": "https://github.com/bobthecow/psysh/tree/v0.12.23" + }, + "time": "2026-05-23T13:41:31+00:00" + }, + { + "name": "pusher/pusher-php-server", + "version": "7.2.8", + "source": { + "type": "git", + "url": "https://github.com/pusher/pusher-http-php.git", + "reference": "4aa139ed2a2a805cd265449b691198beee1309d2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/pusher/pusher-http-php/zipball/4aa139ed2a2a805cd265449b691198beee1309d2", + "reference": "4aa139ed2a2a805cd265449b691198beee1309d2", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "guzzlehttp/guzzle": "^7.2", + "php": "^7.3|^8.0", + "psr/log": "^1.0|^2.0|^3.0" + }, + "require-dev": { + "overtrue/phplint": "^2.3", + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.0-dev" + } + }, + "autoload": { + "psr-4": { + "Pusher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Library for interacting with the Pusher REST API", + "keywords": [ + "events", + "messaging", + "php-pusher-server", + "publish", + "push", + "pusher", + "real time", + "real-time", + "realtime", + "rest", + "trigger" + ], + "support": { + "issues": "https://github.com/pusher/pusher-http-php/issues", + "source": "https://github.com/pusher/pusher-http-php/tree/7.2.8" + }, + "time": "2026-05-18T13:11:36+00:00" + }, + { + "name": "ralouphie/getallheaders", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/getallheaders.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" + } + ], + "description": "A polyfill for getallheaders.", + "support": { + "issues": "https://github.com/ralouphie/getallheaders/issues", + "source": "https://github.com/ralouphie/getallheaders/tree/develop" + }, + "time": "2019-03-08T08:55:37+00:00" + }, + { + "name": "ramsey/collection", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/ramsey/collection.git", + "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/collection/zipball/344572933ad0181accbf4ba763e85a0306a8c5e2", + "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "captainhook/plugin-composer": "^5.3", + "ergebnis/composer-normalize": "^2.45", + "fakerphp/faker": "^1.24", + "hamcrest/hamcrest-php": "^2.0", + "jangregor/phpstan-prophecy": "^2.1", + "mockery/mockery": "^1.6", + "php-parallel-lint/php-console-highlighter": "^1.0", + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpspec/prophecy-phpunit": "^2.3", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^10.5", + "ramsey/coding-standard": "^2.3", + "ramsey/conventional-commits": "^1.6", + "roave/security-advisories": "dev-latest" + }, + "type": "library", + "extra": { + "captainhook": { + "force-install": true + }, + "ramsey/conventional-commits": { + "configFile": "conventional-commits.json" + } + }, + "autoload": { + "psr-4": { + "Ramsey\\Collection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ben Ramsey", + "email": "ben@benramsey.com", + "homepage": "https://benramsey.com" + } + ], + "description": "A PHP library for representing and manipulating collections.", + "keywords": [ + "array", + "collection", + "hash", + "map", + "queue", + "set" + ], + "support": { + "issues": "https://github.com/ramsey/collection/issues", + "source": "https://github.com/ramsey/collection/tree/2.1.1" + }, + "time": "2025-03-22T05:38:12+00:00" + }, + { + "name": "ramsey/uuid", + "version": "4.9.2", + "source": { + "type": "git", + "url": "https://github.com/ramsey/uuid.git", + "reference": "8429c78ca35a09f27565311b98101e2826affde0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/8429c78ca35a09f27565311b98101e2826affde0", + "reference": "8429c78ca35a09f27565311b98101e2826affde0", + "shasum": "" + }, + "require": { + "brick/math": "^0.8.16 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14", + "php": "^8.0", + "ramsey/collection": "^1.2 || ^2.0" + }, + "replace": { + "rhumsaa/uuid": "self.version" + }, + "require-dev": { + "captainhook/captainhook": "^5.25", + "captainhook/plugin-composer": "^5.3", + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "ergebnis/composer-normalize": "^2.47", + "mockery/mockery": "^1.6", + "paragonie/random-lib": "^2", + "php-mock/php-mock": "^2.6", + "php-mock/php-mock-mockery": "^1.5", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpbench/phpbench": "^1.2.14", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.6", + "slevomat/coding-standard": "^8.18", + "squizlabs/php_codesniffer": "^3.13" + }, + "suggest": { + "ext-bcmath": "Enables faster math with arbitrary-precision integers using BCMath.", + "ext-gmp": "Enables faster math with arbitrary-precision integers using GMP.", + "ext-uuid": "Enables the use of PeclUuidTimeGenerator and PeclUuidRandomGenerator.", + "paragonie/random-lib": "Provides RandomLib for use with the RandomLibAdapter", + "ramsey/uuid-doctrine": "Allows the use of Ramsey\\Uuid\\Uuid as Doctrine field type." + }, + "type": "library", + "extra": { + "captainhook": { + "force-install": true + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Ramsey\\Uuid\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A PHP library for generating and working with universally unique identifiers (UUIDs).", + "keywords": [ + "guid", + "identifier", + "uuid" + ], + "support": { + "issues": "https://github.com/ramsey/uuid/issues", + "source": "https://github.com/ramsey/uuid/tree/4.9.2" + }, + "time": "2025-12-14T04:43:48+00:00" + }, + { + "name": "ratchet/rfc6455", + "version": "v0.4.1", + "source": { + "type": "git", + "url": "https://github.com/ratchetphp/RFC6455.git", + "reference": "9b05f371219cbaf9748b505f139617dd0715592b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ratchetphp/RFC6455/zipball/9b05f371219cbaf9748b505f139617dd0715592b", + "reference": "9b05f371219cbaf9748b505f139617dd0715592b", + "shasum": "" + }, + "require": { + "php": ">=7.4", + "psr/http-factory-implementation": "^1.0", + "symfony/polyfill-php80": "^1.15" + }, + "require-dev": { + "guzzlehttp/psr7": "^2.7", + "phpunit/phpunit": "^9.5", + "react/socket": "^1.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Ratchet\\RFC6455\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "role": "Developer" + }, + { + "name": "Matt Bonneau", + "role": "Developer" + } + ], + "description": "RFC6455 WebSocket protocol handler", + "homepage": "http://socketo.me", + "keywords": [ + "WebSockets", + "rfc6455", + "websocket" + ], + "support": { + "chat": "https://gitter.im/reactphp/reactphp", + "issues": "https://github.com/ratchetphp/RFC6455/issues", + "source": "https://github.com/ratchetphp/RFC6455/tree/v0.4.1" + }, + "time": "2026-06-06T14:34:23+00:00" + }, + { + "name": "react/cache", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/cache.git", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/cache/zipball/d47c472b64aa5608225f47965a484b75c7817d5b", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "react/promise": "^3.0 || ^2.0 || ^1.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async, Promise-based cache interface for ReactPHP", + "keywords": [ + "cache", + "caching", + "promise", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/cache/issues", + "source": "https://github.com/reactphp/cache/tree/v1.2.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2022-11-30T15:59:55+00:00" + }, + { + "name": "react/dns", + "version": "v1.14.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/dns.git", + "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/dns/zipball/7562c05391f42701c1fccf189c8225fece1cd7c3", + "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "react/cache": "^1.0 || ^0.6 || ^0.5", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.7 || ^1.2.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3 || ^2", + "react/promise-timer": "^1.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Dns\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async DNS resolver for ReactPHP", + "keywords": [ + "async", + "dns", + "dns-resolver", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/dns/issues", + "source": "https://github.com/reactphp/dns/tree/v1.14.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-18T19:34:28+00:00" + }, + { + "name": "react/event-loop", + "version": "v1.6.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/event-loop.git", + "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/event-loop/zipball/ba276bda6083df7e0050fd9b33f66ad7a4ac747a", + "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "suggest": { + "ext-pcntl": "For signal handling support when using the StreamSelectLoop" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\EventLoop\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "ReactPHP's core reactor event loop that libraries can use for evented I/O.", + "keywords": [ + "asynchronous", + "event-loop" + ], + "support": { + "issues": "https://github.com/reactphp/event-loop/issues", + "source": "https://github.com/reactphp/event-loop/tree/v1.6.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-17T20:46:25+00:00" + }, + { + "name": "react/promise", + "version": "v3.3.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/promise.git", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/promise/zipball/23444f53a813a3296c1368bb104793ce8d88f04a", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpstan/phpstan": "1.12.28 || 1.4.10", + "phpunit/phpunit": "^9.6 || ^7.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "React\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "A lightweight implementation of CommonJS Promises/A for PHP", + "keywords": [ + "promise", + "promises" + ], + "support": { + "issues": "https://github.com/reactphp/promise/issues", + "source": "https://github.com/reactphp/promise/tree/v3.3.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-08-19T18:57:03+00:00" + }, + { + "name": "react/promise-timer", + "version": "v1.11.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/promise-timer.git", + "reference": "4f70306ed66b8b44768941ca7f142092600fafc1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/promise-timer/zipball/4f70306ed66b8b44768941ca7f142092600fafc1", + "reference": "4f70306ed66b8b44768941ca7f142092600fafc1", + "shasum": "" + }, + "require": { + "php": ">=5.3", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.7.0 || ^1.2.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "React\\Promise\\Timer\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "A trivial implementation of timeouts for Promises, built on top of ReactPHP.", + "homepage": "https://github.com/reactphp/promise-timer", + "keywords": [ + "async", + "event-loop", + "promise", + "reactphp", + "timeout", + "timer" + ], + "support": { + "issues": "https://github.com/reactphp/promise-timer/issues", + "source": "https://github.com/reactphp/promise-timer/tree/v1.11.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2024-06-04T14:27:45+00:00" + }, + { + "name": "react/socket", + "version": "v1.17.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/socket.git", + "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/socket/zipball/ef5b17b81f6f60504c539313f94f2d826c5faa08", + "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.0", + "react/dns": "^1.13", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.6 || ^1.2.1", + "react/stream": "^1.4" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3.3 || ^2", + "react/promise-stream": "^1.4", + "react/promise-timer": "^1.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Socket\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async, streaming plaintext TCP/IP and secure TLS socket server and client connections for ReactPHP", + "keywords": [ + "Connection", + "Socket", + "async", + "reactphp", + "stream" + ], + "support": { + "issues": "https://github.com/reactphp/socket/issues", + "source": "https://github.com/reactphp/socket/tree/v1.17.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-19T20:47:34+00:00" + }, + { + "name": "react/stream", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/stream.git", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/stream/zipball/1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.8", + "react/event-loop": "^1.2" + }, + "require-dev": { + "clue/stream-filter": "~1.2", + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Stream\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Event-driven readable and writable streams for non-blocking I/O in ReactPHP", + "keywords": [ + "event-driven", + "io", + "non-blocking", + "pipe", + "reactphp", + "readable", + "stream", + "writable" + ], + "support": { + "issues": "https://github.com/reactphp/stream/issues", + "source": "https://github.com/reactphp/stream/tree/v1.4.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2024-06-11T12:45:25+00:00" + }, + { + "name": "sabberworm/php-css-parser", + "version": "v9.3.0", + "source": { + "type": "git", + "url": "https://github.com/MyIntervals/PHP-CSS-Parser.git", + "reference": "88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949", + "reference": "88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": "^7.2.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "thecodingmachine/safe": "^1.3 || ^2.5 || ^3.4" + }, + "require-dev": { + "php-parallel-lint/php-parallel-lint": "1.4.0", + "phpstan/extension-installer": "1.4.3", + "phpstan/phpstan": "1.12.32 || 2.1.32", + "phpstan/phpstan-phpunit": "1.4.2 || 2.0.8", + "phpstan/phpstan-strict-rules": "1.6.2 || 2.0.7", + "phpunit/phpunit": "8.5.52", + "rawr/phpunit-data-provider": "3.3.1", + "rector/rector": "1.2.10 || 2.2.8", + "rector/type-perfect": "1.0.0 || 2.1.0", + "squizlabs/php_codesniffer": "4.0.1", + "thecodingmachine/phpstan-safe-rule": "1.2.0 || 1.4.1" + }, + "suggest": { + "ext-mbstring": "for parsing UTF-8 CSS" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.4.x-dev" + } + }, + "autoload": { + "files": [ + "src/Rule/Rule.php", + "src/RuleSet/RuleContainer.php" + ], + "psr-4": { + "Sabberworm\\CSS\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Raphael Schweikert" + }, + { + "name": "Oliver Klee", + "email": "github@oliverklee.de" + }, + { + "name": "Jake Hotson", + "email": "jake.github@qzdesign.co.uk" + } + ], + "description": "Parser for CSS Files written in PHP", + "homepage": "https://www.sabberworm.com/blog/2010/6/10/php-css-parser", + "keywords": [ + "css", + "parser", + "stylesheet" + ], + "support": { + "issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues", + "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.3.0" + }, + "time": "2026-03-03T17:31:43+00:00" + }, + { + "name": "spatie/laravel-package-tools", + "version": "1.93.1", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-package-tools.git", + "reference": "d5552849801f2642aea710557463234b59ef65eb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-package-tools/zipball/d5552849801f2642aea710557463234b59ef65eb", + "reference": "d5552849801f2642aea710557463234b59ef65eb", + "shasum": "" + }, + "require": { + "illuminate/contracts": "^10.0|^11.0|^12.0|^13.0", + "php": "^8.1" + }, + "require-dev": { + "mockery/mockery": "^1.5", + "orchestra/testbench": "^8.0|^9.2|^10.0|^11.0", + "pestphp/pest": "^2.1|^3.1|^4.0", + "phpunit/php-code-coverage": "^10.0|^11.0|^12.0", + "phpunit/phpunit": "^10.5|^11.5|^12.5", + "spatie/pest-plugin-test-time": "^2.2|^3.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Spatie\\LaravelPackageTools\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "role": "Developer" + } + ], + "description": "Tools for creating Laravel packages", + "homepage": "https://github.com/spatie/laravel-package-tools", + "keywords": [ + "laravel-package-tools", + "spatie" + ], + "support": { + "issues": "https://github.com/spatie/laravel-package-tools/issues", + "source": "https://github.com/spatie/laravel-package-tools/tree/1.93.1" + }, + "funding": [ + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2026-05-19T14:06:37+00:00" + }, + { + "name": "spatie/laravel-permission", + "version": "7.4.1", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-permission.git", + "reference": "ef42ecb781e5534d368a3853fa161e420ad51397" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-permission/zipball/ef42ecb781e5534d368a3853fa161e420ad51397", + "reference": "ef42ecb781e5534d368a3853fa161e420ad51397", + "shasum": "" + }, + "require": { + "illuminate/auth": "^12.0|^13.0", + "illuminate/container": "^12.0|^13.0", + "illuminate/contracts": "^12.0|^13.0", + "illuminate/database": "^12.0|^13.0", + "php": "^8.3", + "spatie/laravel-package-tools": "^1.0" + }, + "require-dev": { + "larastan/larastan": "^3.9", + "laravel/passport": "^13.0", + "laravel/pint": "^1.0", + "orchestra/testbench": "^10.0|^11.0", + "pestphp/pest": "^3.0|^4.0", + "pestphp/pest-plugin-laravel": "^3.0|^4.1", + "phpstan/phpstan": "^2.1" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Spatie\\Permission\\PermissionServiceProvider" + ] + }, + "branch-alias": { + "dev-main": "7.x-dev", + "dev-master": "7.x-dev" + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Spatie\\Permission\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + } + ], + "description": "Permission handling for Laravel 12 and up", + "homepage": "https://github.com/spatie/laravel-permission", + "keywords": [ + "acl", + "laravel", + "permission", + "permissions", + "rbac", + "roles", + "security", + "spatie" + ], + "support": { + "issues": "https://github.com/spatie/laravel-permission/issues", + "source": "https://github.com/spatie/laravel-permission/tree/7.4.1" + }, + "funding": [ + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2026-04-29T07:59:45+00:00" + }, + { + "name": "symfony/clock", + "version": "v8.0.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/clock.git", + "reference": "b55a638b189a6faa875e0ccdb00908fb87af95b3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/clock/zipball/b55a638b189a6faa875e0ccdb00908fb87af95b3", + "reference": "b55a638b189a6faa875e0ccdb00908fb87af95b3", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "psr/clock": "^1.0" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/now.php" + ], + "psr-4": { + "Symfony\\Component\\Clock\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Decouples applications from the system clock", + "homepage": "https://symfony.com", + "keywords": [ + "clock", + "psr20", + "time" + ], + "support": { + "source": "https://github.com/symfony/clock/tree/v8.0.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-30T15:14:47+00:00" + }, + { + "name": "symfony/console", + "version": "v8.0.11", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "3156577f46a38aa1b9323aad223de7a9cd426782" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/3156577f46a38aa1b9323aad223de7a9cd426782", + "reference": "3156577f46a38aa1b9323aad223de7a9cd426782", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-mbstring": "^1.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^7.4|^8.0" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/lock": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command-line", + "console", + "terminal" + ], + "support": { + "source": "https://github.com/symfony/console/tree/v8.0.11" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-13T12:07:53+00:00" + }, + { + "name": "symfony/css-selector", + "version": "v8.0.9", + "source": { + "type": "git", + "url": "https://github.com/symfony/css-selector.git", + "reference": "3665cfade90565430909b906394c73c8739e57d0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/3665cfade90565430909b906394c73c8739e57d0", + "reference": "3665cfade90565430909b906394c73c8739e57d0", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\CssSelector\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Jean-François Simon", + "email": "jeanfrancois.simon@sensiolabs.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Converts CSS selectors to XPath expressions", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/css-selector/tree/v8.0.9" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-18T13:51:42+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-13T15:52:40+00:00" + }, + { + "name": "symfony/error-handler", + "version": "v8.0.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/error-handler.git", + "reference": "c1119fe8dcfc3825ec74ec061b96ef0c8f281517" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/c1119fe8dcfc3825ec74ec061b96ef0c8f281517", + "reference": "c1119fe8dcfc3825ec74ec061b96ef0c8f281517", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "psr/log": "^1|^2|^3", + "symfony/polyfill-php85": "^1.32", + "symfony/var-dumper": "^7.4|^8.0" + }, + "conflict": { + "symfony/deprecation-contracts": "<2.5" + }, + "require-dev": { + "symfony/console": "^7.4|^8.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0", + "symfony/webpack-encore-bundle": "^1.0|^2.0" + }, + "bin": [ + "Resources/bin/patch-type-declarations" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\ErrorHandler\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to manage errors and ease debugging PHP code", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/error-handler/tree/v8.0.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-30T15:14:47+00:00" + }, + { + "name": "symfony/event-dispatcher", + "version": "v8.0.9", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "0c3c1a17604c4dbbec4b93fe162c538482096e1f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/0c3c1a17604c4dbbec4b93fe162c538482096e1f", + "reference": "0c3c1a17604c4dbbec4b93fe162c538482096e1f", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/event-dispatcher-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/security-http": "<7.4", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0", + "symfony/event-dispatcher-implementation": "2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/error-handler": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/framework-bundle": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\EventDispatcher\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/event-dispatcher/tree/v8.0.9" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-18T13:51:42+00:00" + }, + { + "name": "symfony/event-dispatcher-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher-contracts.git", + "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/ccba7060602b7fed0b03c85bf025257f76d9ef32", + "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/event-dispatcher": "^1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\EventDispatcher\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to dispatching event", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-05T13:30:16+00:00" + }, + { + "name": "symfony/finder", + "version": "v8.0.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "8da41214757b87d97f181e3d14a4179286151007" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/8da41214757b87d97f181e3d14a4179286151007", + "reference": "8da41214757b87d97f181e3d14a4179286151007", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "symfony/filesystem": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Finds files and directories via an intuitive fluent interface", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/finder/tree/v8.0.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-30T15:14:47+00:00" + }, + { + "name": "symfony/http-foundation", + "version": "v8.0.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-foundation.git", + "reference": "02656f7ebeae5c155d659e946f6b3a33df24051b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/02656f7ebeae5c155d659e946f6b3a33df24051b", + "reference": "02656f7ebeae5c155d659e946f6b3a33df24051b", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-mbstring": "^1.1" + }, + "conflict": { + "doctrine/dbal": "<4.3" + }, + "require-dev": { + "doctrine/dbal": "^4.3", + "predis/predis": "^1.1|^2.0", + "symfony/cache": "^7.4|^8.0", + "symfony/clock": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/rate-limiter": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpFoundation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Defines an object-oriented layer for the HTTP specification", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-foundation/tree/v8.0.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-30T15:14:47+00:00" + }, + { + "name": "symfony/http-kernel", + "version": "v8.0.12", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-kernel.git", + "reference": "c00291734c59c05c54c5a3abc2ab18e99b070157" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/c00291734c59c05c54c5a3abc2ab18e99b070157", + "reference": "c00291734c59c05c54c5a3abc2ab18e99b070157", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "psr/log": "^1|^2|^3", + "symfony/error-handler": "^7.4|^8.0", + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/flex": "<2.10", + "symfony/http-client-contracts": "<2.5", + "symfony/translation-contracts": "<2.5", + "twig/twig": "<3.21" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "symfony/browser-kit": "^7.4|^8.0", + "symfony/clock": "^7.4|^8.0", + "symfony/config": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/css-selector": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/dom-crawler": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/finder": "^7.4|^8.0", + "symfony/http-client-contracts": "^2.5|^3", + "symfony/process": "^7.4|^8.0", + "symfony/property-access": "^7.4|^8.0", + "symfony/routing": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/translation": "^7.4|^8.0", + "symfony/translation-contracts": "^2.5|^3", + "symfony/uid": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0", + "symfony/var-exporter": "^7.4|^8.0", + "twig/twig": "^3.21" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpKernel\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a structured process for converting a Request into a Response", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-kernel/tree/v8.0.12" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-20T09:47:36+00:00" + }, + { + "name": "symfony/mailer", + "version": "v8.0.12", + "source": { + "type": "git", + "url": "https://github.com/symfony/mailer.git", + "reference": "5266d594e83593dff3492b5655ff6e8f38d67cfc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mailer/zipball/5266d594e83593dff3492b5655ff6e8f38d67cfc", + "reference": "5266d594e83593dff3492b5655ff6e8f38d67cfc", + "shasum": "" + }, + "require": { + "egulias/email-validator": "^2.1.10|^3|^4", + "php": ">=8.4", + "psr/event-dispatcher": "^1", + "psr/log": "^1|^2|^3", + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/http-client-contracts": "<2.5" + }, + "require-dev": { + "symfony/console": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/twig-bridge": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mailer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Helps sending emails", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/mailer/tree/v8.0.12" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-20T07:22:03+00:00" + }, + { + "name": "symfony/mime", + "version": "v8.0.12", + "source": { + "type": "git", + "url": "https://github.com/symfony/mime.git", + "reference": "7d9a72bbf0a9cb169ed1cbbbbbf709a592207fc1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mime/zipball/7d9a72bbf0a9cb169ed1cbbbbbf709a592207fc1", + "reference": "7d9a72bbf0a9cb169ed1cbbbbbf709a592207fc1", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-intl-idn": "^1.10", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "egulias/email-validator": "~3.0.0", + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1" + }, + "require-dev": { + "egulias/email-validator": "^2.1.10|^3.1|^4", + "league/html-to-markdown": "^5.0", + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/property-access": "^7.4|^8.0", + "symfony/property-info": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mime\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows manipulating MIME messages", + "homepage": "https://symfony.com", + "keywords": [ + "mime", + "mime-type" + ], + "support": { + "source": "https://github.com/symfony/mime/tree/v8.0.12" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-20T07:22:03+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/4864388bfbd3001ce88e234fab652acd91fdc57e", + "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-26T13:13:48+00:00" + }, + { + "name": "symfony/polyfill-intl-idn", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-idn.git", + "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/9614ac4d8061dc257ecc64cba1b140873dce8ad3", + "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "symfony/polyfill-intl-normalizer": "^1.10" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Idn\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Laurent Bassin", + "email": "laurent@bassin.info" + }, + { + "name": "Trevor Rowbotham", + "email": "trevor.rowbotham@pm.me" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "idn", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-10T14:38:51+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "3833d7255cc303546435cb650316bff708a1c75c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c", + "reference": "3833d7255cc303546435cb650316bff708a1c75c", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6a21eb99c6973357967f6ce3708cd55a6bec6315", + "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T17:25:58+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/polyfill-php84", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "88486db2c389b290bf87ff1de7ebc1e13e42bb06" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/88486db2c389b290bf87ff1de7ebc1e13e42bb06", + "reference": "88486db2c389b290bf87ff1de7ebc1e13e42bb06", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php84\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php84/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T18:47:49+00:00" + }, + { + "name": "symfony/polyfill-php85", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "fcfa4973a9917cef23f2e38774da74a2b7d115ee" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/fcfa4973a9917cef23f2e38774da74a2b7d115ee", + "reference": "fcfa4973a9917cef23f2e38774da74a2b7d115ee", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php85\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php85/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-26T13:10:57+00:00" + }, + { + "name": "symfony/polyfill-php86", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php86.git", + "reference": "33d8fc5a705481e21fe3a81212b26f9b1f61749c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php86/zipball/33d8fc5a705481e21fe3a81212b26f9b1f61749c", + "reference": "33d8fc5a705481e21fe3a81212b26f9b1f61749c", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php86\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.6+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php86/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-26T13:13:48+00:00" + }, + { + "name": "symfony/polyfill-uuid", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-uuid.git", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-uuid": "*" + }, + "suggest": { + "ext-uuid": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Uuid\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for uuid functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/process", + "version": "v8.0.11", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "26d89e459f037d2873300605d0a07e7a8ef84db0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/26d89e459f037d2873300605d0a07e7a8ef84db0", + "reference": "26d89e459f037d2873300605d0a07e7a8ef84db0", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Executes commands in sub-processes", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/process/tree/v8.0.11" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-11T16:56:32+00:00" + }, + { + "name": "symfony/routing", + "version": "v8.0.12", + "source": { + "type": "git", + "url": "https://github.com/symfony/routing.git", + "reference": "c7f22a665faa3e5212b8f042e0c5831a6b85492f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/routing/zipball/c7f22a665faa3e5212b8f042e0c5831a6b85492f", + "reference": "c7f22a665faa3e5212b8f042e0c5831a6b85492f", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/yaml": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Routing\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Maps an HTTP request to a set of configuration variables", + "homepage": "https://symfony.com", + "keywords": [ + "router", + "routing", + "uri", + "url" + ], + "support": { + "source": "https://github.com/symfony/routing/tree/v8.0.12" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-20T07:22:03+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d25d82433a80eba6aa0e6c24b61d7370d99e444a", + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.7.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-28T09:44:51+00:00" + }, + { + "name": "symfony/string", + "version": "v8.0.11", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "39be2ad058a3c0bd558edca23e65f009865d75ff" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/39be2ad058a3c0bd558edca23e65f009865d75ff", + "reference": "39be2ad058a3c0bd558edca23e65f009865d75ff", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-intl-grapheme": "^1.33", + "symfony/polyfill-intl-normalizer": "^1.0", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.5" + }, + "require-dev": { + "symfony/emoji": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v8.0.11" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-13T12:07:53+00:00" + }, + { + "name": "symfony/translation", + "version": "v8.0.10", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation.git", + "reference": "f63e9342e12646a57c91ef8a366a4f9d8e557b67" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation/zipball/f63e9342e12646a57c91ef8a366a4f9d8e557b67", + "reference": "f63e9342e12646a57c91ef8a366a4f9d8e557b67", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-mbstring": "^1.0", + "symfony/translation-contracts": "^3.6.1" + }, + "conflict": { + "nikic/php-parser": "<5.0", + "symfony/http-client-contracts": "<2.5", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "symfony/translation-implementation": "2.3|3.0" + }, + "require-dev": { + "nikic/php-parser": "^5.0", + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/finder": "^7.4|^8.0", + "symfony/http-client-contracts": "^2.5|^3.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", + "symfony/polyfill-intl-icu": "^1.21", + "symfony/routing": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to internationalize your application", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/translation/tree/v8.0.10" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-06T11:30:54+00:00" + }, + { + "name": "symfony/translation-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation-contracts.git", + "reference": "0ab302977a952b42fd51475c4ebac81f8da0a95d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/0ab302977a952b42fd51475c4ebac81f8da0a95d", + "reference": "0ab302977a952b42fd51475c4ebac81f8da0a95d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to translation", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/translation-contracts/tree/v3.7.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-05T13:30:16+00:00" + }, + { + "name": "symfony/uid", + "version": "v8.0.9", + "source": { + "type": "git", + "url": "https://github.com/symfony/uid.git", + "reference": "4d9d6510bbe88ebb4608b7200d18606cdf80825c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/uid/zipball/4d9d6510bbe88ebb4608b7200d18606cdf80825c", + "reference": "4d9d6510bbe88ebb4608b7200d18606cdf80825c", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-uuid": "^1.15" + }, + "require-dev": { + "symfony/console": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Uid\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to generate and represent UIDs", + "homepage": "https://symfony.com", + "keywords": [ + "UID", + "ulid", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/uid/tree/v8.0.9" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-30T16:10:06+00:00" + }, + { + "name": "symfony/var-dumper", + "version": "v8.0.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-dumper.git", + "reference": "cfb7badd53bf4177f6e9416cfbbccc13c0e773a1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/cfb7badd53bf4177f6e9416cfbbccc13c0e773a1", + "reference": "cfb7badd53bf4177f6e9416cfbbccc13c0e773a1", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "symfony/console": "<7.4", + "symfony/error-handler": "<7.4" + }, + "require-dev": { + "symfony/console": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/uid": "^7.4|^8.0", + "twig/twig": "^3.12" + }, + "bin": [ + "Resources/bin/var-dump-server" + ], + "type": "library", + "autoload": { + "files": [ + "Resources/functions/dump.php" + ], + "psr-4": { + "Symfony\\Component\\VarDumper\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides mechanisms for walking through any arbitrary PHP variable", + "homepage": "https://symfony.com", + "keywords": [ + "debug", + "dump" + ], + "support": { + "source": "https://github.com/symfony/var-dumper/tree/v8.0.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-31T07:15:36+00:00" + }, + { + "name": "thecodingmachine/safe", + "version": "v3.4.0", + "source": { + "type": "git", + "url": "https://github.com/thecodingmachine/safe.git", + "reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thecodingmachine/safe/zipball/705683a25bacf0d4860c7dea4d7947bfd09eea19", + "reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpstan/phpstan": "^2", + "phpunit/phpunit": "^10", + "squizlabs/php_codesniffer": "^3.2" + }, + "type": "library", + "autoload": { + "files": [ + "lib/special_cases.php", + "generated/apache.php", + "generated/apcu.php", + "generated/array.php", + "generated/bzip2.php", + "generated/calendar.php", + "generated/classobj.php", + "generated/com.php", + "generated/cubrid.php", + "generated/curl.php", + "generated/datetime.php", + "generated/dir.php", + "generated/eio.php", + "generated/errorfunc.php", + "generated/exec.php", + "generated/fileinfo.php", + "generated/filesystem.php", + "generated/filter.php", + "generated/fpm.php", + "generated/ftp.php", + "generated/funchand.php", + "generated/gettext.php", + "generated/gmp.php", + "generated/gnupg.php", + "generated/hash.php", + "generated/ibase.php", + "generated/ibmDb2.php", + "generated/iconv.php", + "generated/image.php", + "generated/imap.php", + "generated/info.php", + "generated/inotify.php", + "generated/json.php", + "generated/ldap.php", + "generated/libxml.php", + "generated/lzf.php", + "generated/mailparse.php", + "generated/mbstring.php", + "generated/misc.php", + "generated/mysql.php", + "generated/mysqli.php", + "generated/network.php", + "generated/oci8.php", + "generated/opcache.php", + "generated/openssl.php", + "generated/outcontrol.php", + "generated/pcntl.php", + "generated/pcre.php", + "generated/pgsql.php", + "generated/posix.php", + "generated/ps.php", + "generated/pspell.php", + "generated/readline.php", + "generated/rnp.php", + "generated/rpminfo.php", + "generated/rrd.php", + "generated/sem.php", + "generated/session.php", + "generated/shmop.php", + "generated/sockets.php", + "generated/sodium.php", + "generated/solr.php", + "generated/spl.php", + "generated/sqlsrv.php", + "generated/ssdeep.php", + "generated/ssh2.php", + "generated/stream.php", + "generated/strings.php", + "generated/swoole.php", + "generated/uodbc.php", + "generated/uopz.php", + "generated/url.php", + "generated/var.php", + "generated/xdiff.php", + "generated/xml.php", + "generated/xmlrpc.php", + "generated/yaml.php", + "generated/yaz.php", + "generated/zip.php", + "generated/zlib.php" + ], + "classmap": [ + "lib/DateTime.php", + "lib/DateTimeImmutable.php", + "lib/Exceptions/", + "generated/Exceptions/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHP core functions that throw exceptions instead of returning FALSE on error", + "support": { + "issues": "https://github.com/thecodingmachine/safe/issues", + "source": "https://github.com/thecodingmachine/safe/tree/v3.4.0" + }, + "funding": [ + { + "url": "https://github.com/OskarStark", + "type": "github" + }, + { + "url": "https://github.com/shish", + "type": "github" + }, + { + "url": "https://github.com/silasjoisten", + "type": "github" + }, + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2026-02-04T18:08:13+00:00" + }, + { + "name": "tightenco/ziggy", + "version": "v2.6.2", + "source": { + "type": "git", + "url": "https://github.com/tighten/ziggy.git", + "reference": "8a0b645921623f77dceaf543d61ecd51a391d96e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/tighten/ziggy/zipball/8a0b645921623f77dceaf543d61ecd51a391d96e", + "reference": "8a0b645921623f77dceaf543d61ecd51a391d96e", + "shasum": "" + }, + "require": { + "ext-json": "*", + "laravel/framework": ">=9.0", + "php": ">=8.1" + }, + "require-dev": { + "laravel/folio": "^1.1", + "orchestra/testbench": "^8.0 || ^9.0 || ^10.0", + "pestphp/pest": "^2.0 || ^3.0 || ^4.0", + "pestphp/pest-plugin-laravel": "^2.0 || ^3.0 || ^4.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Tighten\\Ziggy\\ZiggyServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Tighten\\Ziggy\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Daniel Coulbourne", + "email": "daniel@tighten.co" + }, + { + "name": "Jake Bathman", + "email": "jake@tighten.co" + }, + { + "name": "Jacob Baker-Kretzmar", + "email": "jacob@tighten.co" + } + ], + "description": "Use your Laravel named routes in JavaScript.", + "homepage": "https://github.com/tighten/ziggy", + "keywords": [ + "Ziggy", + "javascript", + "laravel", + "routes" + ], + "support": { + "issues": "https://github.com/tighten/ziggy/issues", + "source": "https://github.com/tighten/ziggy/tree/v2.6.2" + }, + "time": "2026-03-05T14:41:19+00:00" + }, + { + "name": "tijsverkoyen/css-to-inline-styles", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/tijsverkoyen/CssToInlineStyles.git", + "reference": "f0292ccf0ec75843d65027214426b6b163b48b41" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/f0292ccf0ec75843d65027214426b6b163b48b41", + "reference": "f0292ccf0ec75843d65027214426b6b163b48b41", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "php": "^7.4 || ^8.0", + "symfony/css-selector": "^5.4 || ^6.0 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^8.5.21 || ^9.5.10" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "TijsVerkoyen\\CssToInlineStyles\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Tijs Verkoyen", + "email": "css_to_inline_styles@verkoyen.eu", + "role": "Developer" + } + ], + "description": "CssToInlineStyles is a class that enables you to convert HTML-pages/files into HTML-pages/files with inline styles. This is very useful when you're sending emails.", + "homepage": "https://github.com/tijsverkoyen/CssToInlineStyles", + "support": { + "issues": "https://github.com/tijsverkoyen/CssToInlineStyles/issues", + "source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/v2.4.0" + }, + "time": "2025-12-02T11:56:42+00:00" + }, + { + "name": "vlucas/phpdotenv", + "version": "v5.6.3", + "source": { + "type": "git", + "url": "https://github.com/vlucas/phpdotenv.git", + "reference": "955e7815d677a3eaa7075231212f2110983adecc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/955e7815d677a3eaa7075231212f2110983adecc", + "reference": "955e7815d677a3eaa7075231212f2110983adecc", + "shasum": "" + }, + "require": { + "ext-pcre": "*", + "graham-campbell/result-type": "^1.1.4", + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.9.5", + "symfony/polyfill-ctype": "^1.26", + "symfony/polyfill-mbstring": "^1.26", + "symfony/polyfill-php80": "^1.26" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-filter": "*", + "phpunit/phpunit": "^8.5.34 || ^9.6.13 || ^10.4.2" + }, + "suggest": { + "ext-filter": "Required to use the boolean validator." + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "5.6-dev" + } + }, + "autoload": { + "psr-4": { + "Dotenv\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Vance Lucas", + "email": "vance@vancelucas.com", + "homepage": "https://github.com/vlucas" + } + ], + "description": "Loads environment variables from `.env` to `getenv()`, `$_ENV` and `$_SERVER` automagically.", + "keywords": [ + "dotenv", + "env", + "environment" + ], + "support": { + "issues": "https://github.com/vlucas/phpdotenv/issues", + "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.3" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/vlucas/phpdotenv", + "type": "tidelift" + } + ], + "time": "2025-12-27T19:49:13+00:00" + }, + { + "name": "voku/portable-ascii", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/voku/portable-ascii.git", + "reference": "8e1051fe39379367aecf014f41744ce7539a856f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/voku/portable-ascii/zipball/8e1051fe39379367aecf014f41744ce7539a856f", + "reference": "8e1051fe39379367aecf014f41744ce7539a856f", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpunit/phpunit": "~8.5 || ~9.6 || ~10.5 || ~11.5" + }, + "suggest": { + "ext-intl": "Use Intl for transliterator_transliterate() support" + }, + "type": "library", + "autoload": { + "psr-4": { + "voku\\": "src/voku/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Lars Moelleken", + "homepage": "https://www.moelleken.org/" + } + ], + "description": "Portable ASCII library - performance optimized (ascii) string functions for php.", + "homepage": "https://github.com/voku/portable-ascii", + "keywords": [ + "ascii", + "clean", + "php" + ], + "support": { + "issues": "https://github.com/voku/portable-ascii/issues", + "source": "https://github.com/voku/portable-ascii/tree/2.1.1" + }, + "funding": [ + { + "url": "https://www.paypal.me/moelleken", + "type": "custom" + }, + { + "url": "https://github.com/voku", + "type": "github" + }, + { + "url": "https://opencollective.com/portable-ascii", + "type": "open_collective" + }, + { + "url": "https://www.patreon.com/voku", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/voku/portable-ascii", + "type": "tidelift" + } + ], + "time": "2026-04-26T05:33:54+00:00" + } + ], + "packages-dev": [ + { + "name": "brianium/paratest", + "version": "v7.20.0", + "source": { + "type": "git", + "url": "https://github.com/paratestphp/paratest.git", + "reference": "81c80677c9ec0ed4ef16b246167f11dec81a6e3d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paratestphp/paratest/zipball/81c80677c9ec0ed4ef16b246167f11dec81a6e3d", + "reference": "81c80677c9ec0ed4ef16b246167f11dec81a6e3d", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-pcre": "*", + "ext-reflection": "*", + "ext-simplexml": "*", + "fidry/cpu-core-counter": "^1.3.0", + "jean85/pretty-package-versions": "^2.1.1", + "php": "~8.3.0 || ~8.4.0 || ~8.5.0", + "phpunit/php-code-coverage": "^12.5.3 || ^13.0.1", + "phpunit/php-file-iterator": "^6.0.1 || ^7", + "phpunit/php-timer": "^8 || ^9", + "phpunit/phpunit": "^12.5.14 || ^13.0.5", + "sebastian/environment": "^8.0.3 || ^9", + "symfony/console": "^7.4.7 || ^8.0.7", + "symfony/process": "^7.4.5 || ^8.0.5" + }, + "require-dev": { + "doctrine/coding-standard": "^14.0.0", + "ext-pcntl": "*", + "ext-pcov": "*", + "ext-posix": "*", + "phpstan/phpstan": "^2.1.44", + "phpstan/phpstan-deprecation-rules": "^2.0.4", + "phpstan/phpstan-phpunit": "^2.0.16", + "phpstan/phpstan-strict-rules": "^2.0.10", + "symfony/filesystem": "^7.4.6 || ^8.0.6" + }, + "bin": [ + "bin/paratest", + "bin/paratest_for_phpstorm" + ], + "type": "library", + "autoload": { + "psr-4": { + "ParaTest\\": [ + "src/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Brian Scaturro", + "email": "scaturrob@gmail.com", + "role": "Developer" + }, + { + "name": "Filippo Tessarotto", + "email": "zoeslam@gmail.com", + "role": "Developer" + } + ], + "description": "Parallel testing for PHP", + "homepage": "https://github.com/paratestphp/paratest", + "keywords": [ + "concurrent", + "parallel", + "phpunit", + "testing" + ], + "support": { + "issues": "https://github.com/paratestphp/paratest/issues", + "source": "https://github.com/paratestphp/paratest/tree/v7.20.0" + }, + "funding": [ + { + "url": "https://github.com/sponsors/Slamdunk", + "type": "github" + }, + { + "url": "https://paypal.me/filippotessarotto", + "type": "paypal" + } + ], + "time": "2026-03-29T15:46:14+00:00" + }, + { + "name": "composer/xdebug-handler", + "version": "3.0.5", + "source": { + "type": "git", + "url": "https://github.com/composer/xdebug-handler.git", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/6c1925561632e83d60a44492e0b344cf48ab85ef", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef", + "shasum": "" + }, + "require": { + "composer/pcre": "^1 || ^2 || ^3", + "php": "^7.2.5 || ^8.0", + "psr/log": "^1 || ^2 || ^3" + }, + "require-dev": { + "phpstan/phpstan": "^1.0", + "phpstan/phpstan-strict-rules": "^1.1", + "phpunit/phpunit": "^8.5 || ^9.6 || ^10.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Composer\\XdebugHandler\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "John Stevenson", + "email": "john-stevenson@blueyonder.co.uk" + } + ], + "description": "Restarts a process without Xdebug.", + "keywords": [ + "Xdebug", + "performance" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/xdebug-handler/issues", + "source": "https://github.com/composer/xdebug-handler/tree/3.0.5" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-05-06T16:37:16+00:00" + }, + { + "name": "doctrine/deprecations", + "version": "1.1.6", + "source": { + "type": "git", + "url": "https://github.com/doctrine/deprecations.git", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "phpunit/phpunit": "<=7.5 || >=14" + }, + "require-dev": { + "doctrine/coding-standard": "^9 || ^12 || ^14", + "phpstan/phpstan": "1.4.10 || 2.1.30", + "phpstan/phpstan-phpunit": "^1.0 || ^2", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0", + "psr/log": "^1 || ^2 || ^3" + }, + "suggest": { + "psr/log": "Allows logging deprecations via PSR-3 logger implementation" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Deprecations\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.", + "homepage": "https://www.doctrine-project.org/", + "support": { + "issues": "https://github.com/doctrine/deprecations/issues", + "source": "https://github.com/doctrine/deprecations/tree/1.1.6" + }, + "time": "2026-02-07T07:09:04+00:00" + }, + { + "name": "fakerphp/faker", + "version": "v1.24.1", + "source": { + "type": "git", + "url": "https://github.com/FakerPHP/Faker.git", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "psr/container": "^1.0 || ^2.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" + }, + "conflict": { + "fzaninotto/faker": "*" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.4.1", + "doctrine/persistence": "^1.3 || ^2.0", + "ext-intl": "*", + "phpunit/phpunit": "^9.5.26", + "symfony/phpunit-bridge": "^5.4.16" + }, + "suggest": { + "doctrine/orm": "Required to use Faker\\ORM\\Doctrine", + "ext-curl": "Required by Faker\\Provider\\Image to download images.", + "ext-dom": "Required by Faker\\Provider\\HtmlLorem for generating random HTML.", + "ext-iconv": "Required by Faker\\Provider\\ru_RU\\Text::realText() for generating real Russian text.", + "ext-mbstring": "Required for multibyte Unicode string functionality." + }, + "type": "library", + "autoload": { + "psr-4": { + "Faker\\": "src/Faker/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "François Zaninotto" + } + ], + "description": "Faker is a PHP library that generates fake data for you.", + "keywords": [ + "data", + "faker", + "fixtures" + ], + "support": { + "issues": "https://github.com/FakerPHP/Faker/issues", + "source": "https://github.com/FakerPHP/Faker/tree/v1.24.1" + }, + "time": "2024-11-21T13:46:39+00:00" + }, + { + "name": "fidry/cpu-core-counter", + "version": "1.3.0", + "source": { + "type": "git", + "url": "https://github.com/theofidry/cpu-core-counter.git", + "reference": "db9508f7b1474469d9d3c53b86f817e344732678" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/db9508f7b1474469d9d3c53b86f817e344732678", + "reference": "db9508f7b1474469d9d3c53b86f817e344732678", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "fidry/makefile": "^0.2.0", + "fidry/php-cs-fixer-config": "^1.1.2", + "phpstan/extension-installer": "^1.2.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-deprecation-rules": "^2.0.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^8.5.31 || ^9.5.26", + "webmozarts/strict-phpunit": "^7.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Fidry\\CpuCoreCounter\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Théo FIDRY", + "email": "theo.fidry@gmail.com" + } + ], + "description": "Tiny utility to get the number of CPU cores.", + "keywords": [ + "CPU", + "core" + ], + "support": { + "issues": "https://github.com/theofidry/cpu-core-counter/issues", + "source": "https://github.com/theofidry/cpu-core-counter/tree/1.3.0" + }, + "funding": [ + { + "url": "https://github.com/theofidry", + "type": "github" + } + ], + "time": "2025-08-14T07:29:31+00:00" + }, + { + "name": "filp/whoops", + "version": "2.18.4", + "source": { + "type": "git", + "url": "https://github.com/filp/whoops.git", + "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/filp/whoops/zipball/d2102955e48b9fd9ab24280a7ad12ed552752c4d", + "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0", + "psr/log": "^1.0.1 || ^2.0 || ^3.0" + }, + "require-dev": { + "mockery/mockery": "^1.0", + "phpunit/phpunit": "^7.5.20 || ^8.5.8 || ^9.3.3", + "symfony/var-dumper": "^4.0 || ^5.0" + }, + "suggest": { + "symfony/var-dumper": "Pretty print complex values better with var-dumper available", + "whoops/soap": "Formats errors as SOAP responses" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Whoops\\": "src/Whoops/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Filipe Dobreira", + "homepage": "https://github.com/filp", + "role": "Developer" + } + ], + "description": "php error handling for cool kids", + "homepage": "https://filp.github.io/whoops/", + "keywords": [ + "error", + "exception", + "handling", + "library", + "throwable", + "whoops" + ], + "support": { + "issues": "https://github.com/filp/whoops/issues", + "source": "https://github.com/filp/whoops/tree/2.18.4" + }, + "funding": [ + { + "url": "https://github.com/denis-sokolov", + "type": "github" + } + ], + "time": "2025-08-08T12:00:00+00:00" + }, + { + "name": "hamcrest/hamcrest-php", + "version": "v2.1.1", + "source": { + "type": "git", + "url": "https://github.com/hamcrest/hamcrest-php.git", + "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", + "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "replace": { + "cordoval/hamcrest-php": "*", + "davedevelopment/hamcrest-php": "*", + "kodova/hamcrest-php": "*" + }, + "require-dev": { + "phpunit/php-file-iterator": "^1.4 || ^2.0 || ^3.0", + "phpunit/phpunit": "^4.8.36 || ^5.7 || ^6.5 || ^7.0 || ^8.0 || ^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.1-dev" + } + }, + "autoload": { + "classmap": [ + "hamcrest" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "This is the PHP port of Hamcrest Matchers", + "keywords": [ + "test" + ], + "support": { + "issues": "https://github.com/hamcrest/hamcrest-php/issues", + "source": "https://github.com/hamcrest/hamcrest-php/tree/v2.1.1" + }, + "time": "2025-04-30T06:54:44+00:00" + }, + { + "name": "jean85/pretty-package-versions", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/Jean85/pretty-package-versions.git", + "reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Jean85/pretty-package-versions/zipball/4d7aa5dab42e2a76d99559706022885de0e18e1a", + "reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.1.0", + "php": "^7.4|^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.2", + "jean85/composer-provided-replaced-stub-package": "^1.0", + "phpstan/phpstan": "^2.0", + "phpunit/phpunit": "^7.5|^8.5|^9.6", + "rector/rector": "^2.0", + "vimeo/psalm": "^4.3 || ^5.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Jean85\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Alessandro Lai", + "email": "alessandro.lai85@gmail.com" + } + ], + "description": "A library to get pretty versions strings of installed dependencies", + "keywords": [ + "composer", + "package", + "release", + "versions" + ], + "support": { + "issues": "https://github.com/Jean85/pretty-package-versions/issues", + "source": "https://github.com/Jean85/pretty-package-versions/tree/2.1.1" + }, + "time": "2025-03-19T14:43:43+00:00" + }, + { + "name": "laravel/agent-detector", + "version": "v2.0.2", + "source": { + "type": "git", + "url": "https://github.com/laravel/agent-detector.git", + "reference": "90694b9256099591cf9e55d08c18ba7a00bf099f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/agent-detector/zipball/90694b9256099591cf9e55d08c18ba7a00bf099f", + "reference": "90694b9256099591cf9e55d08c18ba7a00bf099f", + "shasum": "" + }, + "require": { + "php": "^8.2.0" + }, + "require-dev": { + "laravel/pint": "^1.24.0", + "pestphp/pest": "^3.8.5|^4.1.0", + "pestphp/pest-plugin-type-coverage": "^3.0|^4.0.2", + "phpstan/phpstan": "^2.1.26", + "rector/rector": "^2.1.7", + "symfony/var-dumper": "^7.3.3" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Laravel\\AgentDetector\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Detect if code is running in an AI agent or automated development environment", + "homepage": "https://github.com/laravel/agent-detector", + "keywords": [ + "Agent", + "ai", + "automation", + "claude", + "cursor", + "detection", + "devin", + "php" + ], + "support": { + "issues": "https://github.com/laravel/agent-detector/issues", + "source": "https://github.com/laravel/agent-detector" + }, + "time": "2026-04-29T18:32:34+00:00" + }, + { + "name": "laravel/breeze", + "version": "v2.4.2", + "source": { + "type": "git", + "url": "https://github.com/laravel/breeze.git", + "reference": "4f20e7b2cc8d25daa85d8647241a89c8e0930305" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/breeze/zipball/4f20e7b2cc8d25daa85d8647241a89c8e0930305", + "reference": "4f20e7b2cc8d25daa85d8647241a89c8e0930305", + "shasum": "" + }, + "require": { + "illuminate/console": "^11.0|^12.0|^13.0", + "illuminate/filesystem": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", + "illuminate/validation": "^11.0|^12.0|^13.0", + "php": "^8.2.0", + "symfony/console": "^7.0|^8.0" + }, + "require-dev": { + "laravel/framework": "^11.0|^12.0|^13.0", + "orchestra/testbench-core": "^9.0|^10.0|^11.0", + "phpstan/phpstan": "^2.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Breeze\\BreezeServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Breeze\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Minimal Laravel authentication scaffolding with Blade and Tailwind.", + "keywords": [ + "auth", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/breeze/issues", + "source": "https://github.com/laravel/breeze" + }, + "time": "2026-05-14T16:54:25+00:00" + }, + { + "name": "laravel/pail", + "version": "v1.2.6", + "source": { + "type": "git", + "url": "https://github.com/laravel/pail.git", + "reference": "aa71a01c309e7f66bc2ec4fb1a59291b82eb4abf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pail/zipball/aa71a01c309e7f66bc2ec4fb1a59291b82eb4abf", + "reference": "aa71a01c309e7f66bc2ec4fb1a59291b82eb4abf", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "illuminate/console": "^10.24|^11.0|^12.0|^13.0", + "illuminate/contracts": "^10.24|^11.0|^12.0|^13.0", + "illuminate/log": "^10.24|^11.0|^12.0|^13.0", + "illuminate/process": "^10.24|^11.0|^12.0|^13.0", + "illuminate/support": "^10.24|^11.0|^12.0|^13.0", + "nunomaduro/termwind": "^1.15|^2.0", + "php": "^8.2", + "symfony/console": "^6.0|^7.0|^8.0" + }, + "require-dev": { + "laravel/framework": "^10.24|^11.0|^12.0|^13.0", + "laravel/pint": "^1.13", + "orchestra/testbench-core": "^8.13|^9.17|^10.8|^11.0", + "pestphp/pest": "^2.20|^3.0|^4.0", + "pestphp/pest-plugin-type-coverage": "^2.3|^3.0|^4.0", + "phpstan/phpstan": "^1.12.27", + "symfony/var-dumper": "^6.3|^7.0|^8.0", + "symfony/yaml": "^6.3|^7.0|^8.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Pail\\PailServiceProvider" + ] + }, + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Pail\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "Easily delve into your Laravel application's log files directly from the command line.", + "homepage": "https://github.com/laravel/pail", + "keywords": [ + "dev", + "laravel", + "logs", + "php", + "tail" + ], + "support": { + "issues": "https://github.com/laravel/pail/issues", + "source": "https://github.com/laravel/pail" + }, + "time": "2026-02-09T13:44:54+00:00" + }, + { + "name": "laravel/pao", + "version": "v1.0.6", + "source": { + "type": "git", + "url": "https://github.com/laravel/pao.git", + "reference": "02f62a64c2b60af44a418ee490fee193590d8269" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pao/zipball/02f62a64c2b60af44a418ee490fee193590d8269", + "reference": "02f62a64c2b60af44a418ee490fee193590d8269", + "shasum": "" + }, + "require": { + "laravel/agent-detector": "^2.0.0", + "php": "^8.3" + }, + "conflict": { + "laravel/framework": "<12.0.0", + "nunomaduro/collision": "<8.9.3", + "pestphp/pest": "<4.6.3 || >=6.0.0", + "phpunit/phpunit": "<12.5.23 || >=13.0.0 <13.1.7 || >=14.0.0" + }, + "require-dev": { + "brianium/paratest": "^7.20.0", + "laravel/pint": "^1.29.1", + "orchestra/testbench": "^10.11.0 || ^11.1.0", + "pestphp/pest": "^4.6.3 || ^5.0.0", + "pestphp/pest-plugin-type-coverage": "^4.0.4 || ^5.0.0", + "phpstan/phpstan": "^2.1.51", + "rector/rector": "^2.4.2", + "symfony/process": "^7.4.8 || ^8.1.0", + "symfony/var-dumper": "^7.4.8 || ^8.0.8" + }, + "type": "library", + "extra": { + "pest": { + "plugins": [ + "Laravel\\Pao\\Drivers\\Pest\\Plugin" + ] + }, + "laravel": { + "providers": [ + "Laravel\\Pao\\Laravel\\ServiceProvider" + ] + } + }, + "autoload": { + "files": [ + "src/Autoload.php" + ], + "psr-4": { + "Laravel\\Pao\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Agent-optimized output for PHP testing tools", + "keywords": [ + "Agent", + "PHPStan", + "ai", + "dev", + "paratest", + "pest", + "php", + "phpunit", + "testing" + ], + "support": { + "issues": "https://github.com/laravel/pao/issues", + "source": "https://github.com/laravel/pao" + }, + "time": "2026-04-27T22:37:26+00:00" + }, + { + "name": "laravel/pint", + "version": "v1.29.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/pint.git", + "reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pint/zipball/0770e9b7fafd50d4586881d456d6eb41c9247a80", + "reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "ext-tokenizer": "*", + "ext-xml": "*", + "php": "^8.2.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.95.1", + "illuminate/view": "^12.56.0", + "larastan/larastan": "^3.9.6", + "laravel-zero/framework": "^12.1.0", + "mockery/mockery": "^1.6.12", + "nunomaduro/termwind": "^2.4.0", + "pestphp/pest": "^3.8.6", + "shipfastlabs/agent-detector": "^1.1.3" + }, + "bin": [ + "builds/pint" + ], + "type": "project", + "autoload": { + "psr-4": { + "App\\": "app/", + "Database\\Seeders\\": "database/seeders/", + "Database\\Factories\\": "database/factories/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "An opinionated code formatter for PHP.", + "homepage": "https://laravel.com", + "keywords": [ + "dev", + "format", + "formatter", + "lint", + "linter", + "php" + ], + "support": { + "issues": "https://github.com/laravel/pint/issues", + "source": "https://github.com/laravel/pint" + }, + "time": "2026-04-20T15:26:14+00:00" + }, + { + "name": "mockery/mockery", + "version": "1.6.12", + "source": { + "type": "git", + "url": "https://github.com/mockery/mockery.git", + "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/mockery/mockery/zipball/1f4efdd7d3beafe9807b08156dfcb176d18f1699", + "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699", + "shasum": "" + }, + "require": { + "hamcrest/hamcrest-php": "^2.0.1", + "lib-pcre": ">=7.0", + "php": ">=7.3" + }, + "conflict": { + "phpunit/phpunit": "<8.0" + }, + "require-dev": { + "phpunit/phpunit": "^8.5 || ^9.6.17", + "symplify/easy-coding-standard": "^12.1.14" + }, + "type": "library", + "autoload": { + "files": [ + "library/helpers.php", + "library/Mockery.php" + ], + "psr-4": { + "Mockery\\": "library/Mockery" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Pádraic Brady", + "email": "padraic.brady@gmail.com", + "homepage": "https://github.com/padraic", + "role": "Author" + }, + { + "name": "Dave Marshall", + "email": "dave.marshall@atstsolutions.co.uk", + "homepage": "https://davedevelopment.co.uk", + "role": "Developer" + }, + { + "name": "Nathanael Esayeas", + "email": "nathanael.esayeas@protonmail.com", + "homepage": "https://github.com/ghostwriter", + "role": "Lead Developer" + } + ], + "description": "Mockery is a simple yet flexible PHP mock object framework", + "homepage": "https://github.com/mockery/mockery", + "keywords": [ + "BDD", + "TDD", + "library", + "mock", + "mock objects", + "mockery", + "stub", + "test", + "test double", + "testing" + ], + "support": { + "docs": "https://docs.mockery.io/", + "issues": "https://github.com/mockery/mockery/issues", + "rss": "https://github.com/mockery/mockery/releases.atom", + "security": "https://github.com/mockery/mockery/security/advisories", + "source": "https://github.com/mockery/mockery" + }, + "time": "2024-05-16T03:13:13+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.13.4", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-08-01T08:46:24+00:00" + }, + { + "name": "nunomaduro/collision", + "version": "v8.9.4", + "source": { + "type": "git", + "url": "https://github.com/nunomaduro/collision.git", + "reference": "716af8f95a470e9094cfca09ed897b023be191a5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/716af8f95a470e9094cfca09ed897b023be191a5", + "reference": "716af8f95a470e9094cfca09ed897b023be191a5", + "shasum": "" + }, + "require": { + "filp/whoops": "^2.18.4", + "nunomaduro/termwind": "^2.4.0", + "php": "^8.2.0", + "symfony/console": "^7.4.8 || ^8.0.8" + }, + "conflict": { + "laravel/framework": "<11.48.0 || >=14.0.0", + "phpunit/phpunit": "<11.5.50 || >=14.0.0" + }, + "require-dev": { + "brianium/paratest": "^7.8.5", + "larastan/larastan": "^3.9.6", + "laravel/framework": "^11.48.0 || ^12.56.0 || ^13.5.0", + "laravel/pint": "^1.29.1", + "orchestra/testbench-core": "^9.12.0 || ^10.12.1 || ^11.2.1", + "pestphp/pest": "^3.8.5 || ^4.4.3 || ^5.0.0", + "sebastian/environment": "^7.2.1 || ^8.0.4 || ^9.3.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider" + ] + }, + "branch-alias": { + "dev-8.x": "8.x-dev" + } + }, + "autoload": { + "files": [ + "./src/Adapters/Phpunit/Autoload.php" + ], + "psr-4": { + "NunoMaduro\\Collision\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "Cli error handling for console/command-line PHP applications.", + "keywords": [ + "artisan", + "cli", + "command-line", + "console", + "dev", + "error", + "handling", + "laravel", + "laravel-zero", + "php", + "symfony" + ], + "support": { + "issues": "https://github.com/nunomaduro/collision/issues", + "source": "https://github.com/nunomaduro/collision" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://www.patreon.com/nunomaduro", + "type": "patreon" + } + ], + "time": "2026-04-21T14:04:20+00:00" + }, + { + "name": "pestphp/pest", + "version": "v4.7.0", + "source": { + "type": "git", + "url": "https://github.com/pestphp/pest.git", + "reference": "2fc75cfcf03c041c804778fa894282234adc3c66" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/pestphp/pest/zipball/2fc75cfcf03c041c804778fa894282234adc3c66", + "reference": "2fc75cfcf03c041c804778fa894282234adc3c66", + "shasum": "" + }, + "require": { + "brianium/paratest": "^7.20.0", + "composer/xdebug-handler": "^3.0.5", + "nunomaduro/collision": "^8.9.4", + "nunomaduro/termwind": "^2.4.0", + "pestphp/pest-plugin": "^4.0.0", + "pestphp/pest-plugin-arch": "^4.0.2", + "pestphp/pest-plugin-mutate": "^4.0.1", + "pestphp/pest-plugin-profanity": "^4.2.1", + "php": "^8.3.0", + "phpunit/phpunit": "^12.5.24", + "symfony/process": "^7.4.8|^8.0.8" + }, + "conflict": { + "filp/whoops": "<2.18.3", + "phpunit/phpunit": ">12.5.24", + "sebastian/exporter": "<7.0.0", + "webmozart/assert": "<1.11.0" + }, + "require-dev": { + "mrpunyapal/peststan": "^0.2.9", + "pestphp/pest-dev-tools": "^4.1.0", + "pestphp/pest-plugin-browser": "^4.3.1", + "pestphp/pest-plugin-type-coverage": "^4.0.4", + "psy/psysh": "^0.12.22" + }, + "bin": [ + "bin/pest" + ], + "type": "library", + "extra": { + "pest": { + "plugins": [ + "Pest\\Mutate\\Plugins\\Mutate", + "Pest\\Plugins\\Configuration", + "Pest\\Plugins\\Bail", + "Pest\\Plugins\\Cache", + "Pest\\Plugins\\Coverage", + "Pest\\Plugins\\Init", + "Pest\\Plugins\\Environment", + "Pest\\Plugins\\Help", + "Pest\\Plugins\\Memory", + "Pest\\Plugins\\Only", + "Pest\\Plugins\\Printer", + "Pest\\Plugins\\ProcessIsolation", + "Pest\\Plugins\\Profile", + "Pest\\Plugins\\Retry", + "Pest\\Plugins\\Snapshot", + "Pest\\Plugins\\Verbose", + "Pest\\Plugins\\Version", + "Pest\\Plugins\\Shard", + "Pest\\Plugins\\Tia", + "Pest\\Plugins\\Parallel" + ] + }, + "phpstan": { + "includes": [ + "extension.neon" + ] + } + }, + "autoload": { + "files": [ + "src/Functions.php", + "src/Pest.php" + ], + "psr-4": { + "Pest\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "The elegant PHP Testing Framework.", + "keywords": [ + "framework", + "pest", + "php", + "test", + "testing", + "unit" + ], + "support": { + "issues": "https://github.com/pestphp/pest/issues", + "source": "https://github.com/pestphp/pest/tree/v4.7.0" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + } + ], + "time": "2026-05-03T16:09:32+00:00" + }, + { + "name": "pestphp/pest-plugin", + "version": "v4.0.0", + "source": { + "type": "git", + "url": "https://github.com/pestphp/pest-plugin.git", + "reference": "9d4b93d7f73d3f9c3189bb22c220fef271cdf568" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/pestphp/pest-plugin/zipball/9d4b93d7f73d3f9c3189bb22c220fef271cdf568", + "reference": "9d4b93d7f73d3f9c3189bb22c220fef271cdf568", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^2.0.0", + "composer-runtime-api": "^2.2.2", + "php": "^8.3" + }, + "conflict": { + "pestphp/pest": "<4.0.0" + }, + "require-dev": { + "composer/composer": "^2.8.10", + "pestphp/pest": "^4.0.0", + "pestphp/pest-dev-tools": "^4.0.0" + }, + "type": "composer-plugin", + "extra": { + "class": "Pest\\Plugin\\Manager" + }, + "autoload": { + "psr-4": { + "Pest\\Plugin\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "The Pest plugin manager", + "keywords": [ + "framework", + "manager", + "pest", + "php", + "plugin", + "test", + "testing", + "unit" + ], + "support": { + "source": "https://github.com/pestphp/pest-plugin/tree/v4.0.0" + }, + "funding": [ + { + "url": "https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=66BYDWAT92N6L", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://www.patreon.com/nunomaduro", + "type": "patreon" + } + ], + "time": "2025-08-20T12:35:58+00:00" + }, + { + "name": "pestphp/pest-plugin-arch", + "version": "v4.0.2", + "source": { + "type": "git", + "url": "https://github.com/pestphp/pest-plugin-arch.git", + "reference": "3fb0d02a91b9da504b139dc7ab2a31efb7c3215c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/pestphp/pest-plugin-arch/zipball/3fb0d02a91b9da504b139dc7ab2a31efb7c3215c", + "reference": "3fb0d02a91b9da504b139dc7ab2a31efb7c3215c", + "shasum": "" + }, + "require": { + "pestphp/pest-plugin": "^4.0.0", + "php": "^8.3", + "ta-tikoma/phpunit-architecture-test": "^0.8.7" + }, + "require-dev": { + "pestphp/pest": "^4.4.6", + "pestphp/pest-dev-tools": "^4.1.0" + }, + "type": "library", + "extra": { + "pest": { + "plugins": [ + "Pest\\Arch\\Plugin" + ] + } + }, + "autoload": { + "files": [ + "src/Autoload.php" + ], + "psr-4": { + "Pest\\Arch\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "The Arch plugin for Pest PHP.", + "keywords": [ + "arch", + "architecture", + "framework", + "pest", + "php", + "plugin", + "test", + "testing", + "unit" + ], + "support": { + "source": "https://github.com/pestphp/pest-plugin-arch/tree/v4.0.2" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + } + ], + "time": "2026-04-10T17:20:19+00:00" + }, + { + "name": "pestphp/pest-plugin-laravel", + "version": "v4.1.0", + "source": { + "type": "git", + "url": "https://github.com/pestphp/pest-plugin-laravel.git", + "reference": "3057a36669ff11416cc0dc2b521b3aec58c488d0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/pestphp/pest-plugin-laravel/zipball/3057a36669ff11416cc0dc2b521b3aec58c488d0", + "reference": "3057a36669ff11416cc0dc2b521b3aec58c488d0", + "shasum": "" + }, + "require": { + "laravel/framework": "^11.45.2|^12.52.0|^13.0", + "pestphp/pest": "^4.4.1", + "php": "^8.3.0" + }, + "require-dev": { + "laravel/dusk": "^8.3.6", + "orchestra/testbench": "^9.13.0|^10.9.0|^11.0", + "pestphp/pest-dev-tools": "^4.1.0" + }, + "type": "library", + "extra": { + "pest": { + "plugins": [ + "Pest\\Laravel\\Plugin" + ] + }, + "laravel": { + "providers": [ + "Pest\\Laravel\\PestServiceProvider" + ] + } + }, + "autoload": { + "files": [ + "src/Autoload.php" + ], + "psr-4": { + "Pest\\Laravel\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "The Pest Laravel Plugin", + "keywords": [ + "framework", + "laravel", + "pest", + "php", + "test", + "testing", + "unit" + ], + "support": { + "source": "https://github.com/pestphp/pest-plugin-laravel/tree/v4.1.0" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + } + ], + "time": "2026-02-21T00:29:45+00:00" + }, + { + "name": "pestphp/pest-plugin-mutate", + "version": "v4.0.1", + "source": { + "type": "git", + "url": "https://github.com/pestphp/pest-plugin-mutate.git", + "reference": "d9b32b60b2385e1688a68cc227594738ec26d96c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/pestphp/pest-plugin-mutate/zipball/d9b32b60b2385e1688a68cc227594738ec26d96c", + "reference": "d9b32b60b2385e1688a68cc227594738ec26d96c", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.6.1", + "pestphp/pest-plugin": "^4.0.0", + "php": "^8.3", + "psr/simple-cache": "^3.0.0" + }, + "require-dev": { + "pestphp/pest": "^4.0.0", + "pestphp/pest-dev-tools": "^4.0.0", + "pestphp/pest-plugin-type-coverage": "^4.0.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Pest\\Mutate\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + }, + { + "name": "Sandro Gehri", + "email": "sandrogehri@gmail.com" + } + ], + "description": "Mutates your code to find untested cases", + "keywords": [ + "framework", + "mutate", + "mutation", + "pest", + "php", + "plugin", + "test", + "testing", + "unit" + ], + "support": { + "source": "https://github.com/pestphp/pest-plugin-mutate/tree/v4.0.1" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/gehrisandro", + "type": "github" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + } + ], + "time": "2025-08-21T20:19:25+00:00" + }, + { + "name": "pestphp/pest-plugin-profanity", + "version": "v4.2.1", + "source": { + "type": "git", + "url": "https://github.com/pestphp/pest-plugin-profanity.git", + "reference": "343cfa6f3564b7e35df0ebb77b7fa97039f72b27" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/pestphp/pest-plugin-profanity/zipball/343cfa6f3564b7e35df0ebb77b7fa97039f72b27", + "reference": "343cfa6f3564b7e35df0ebb77b7fa97039f72b27", + "shasum": "" + }, + "require": { + "pestphp/pest-plugin": "^4.0.0", + "php": "^8.3" + }, + "require-dev": { + "faissaloux/pest-plugin-inside": "^1.9", + "pestphp/pest": "^4.0.0", + "pestphp/pest-dev-tools": "^4.0.0" + }, + "type": "library", + "extra": { + "pest": { + "plugins": [ + "Pest\\Profanity\\Plugin" + ] + } + }, + "autoload": { + "psr-4": { + "Pest\\Profanity\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "The Pest Profanity Plugin", + "keywords": [ + "framework", + "pest", + "php", + "plugin", + "profanity", + "test", + "testing", + "unit" + ], + "support": { + "source": "https://github.com/pestphp/pest-plugin-profanity/tree/v4.2.1" + }, + "time": "2025-12-08T00:13:17+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpdocumentor/reflection-common", + "version": "2.2.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionCommon.git", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" + } + ], + "description": "Common reflection classes used by phpdocumentor to reflect the code structure", + "homepage": "http://www.phpdoc.org", + "keywords": [ + "FQSEN", + "phpDocumentor", + "phpdoc", + "reflection", + "static analysis" + ], + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionCommon/issues", + "source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.x" + }, + "time": "2020-06-27T09:03:43+00:00" + }, + { + "name": "phpdocumentor/reflection-docblock", + "version": "6.0.3", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", + "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/7bae67520aa9f5ecc506d646810bd40d9da54582", + "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.1", + "ext-filter": "*", + "php": "^7.4 || ^8.0", + "phpdocumentor/reflection-common": "^2.2", + "phpdocumentor/type-resolver": "^2.0", + "phpstan/phpdoc-parser": "^2.0", + "webmozart/assert": "^1.9.1 || ^2" + }, + "require-dev": { + "mockery/mockery": "~1.3.5 || ~1.6.0", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "^1.8", + "phpstan/phpstan-mockery": "^1.1", + "phpstan/phpstan-webmozart-assert": "^1.2", + "phpunit/phpunit": "^9.5", + "psalm/phar": "^5.26", + "shipmonk/dead-code-detector": "^0.5.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + }, + { + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" + } + ], + "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/6.0.3" + }, + "time": "2026-03-18T20:49:53+00:00" + }, + { + "name": "phpdocumentor/type-resolver", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/TypeResolver.git", + "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/327a05bbee54120d4786a0dc67aad30226ad4cf9", + "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.0", + "php": "^7.4 || ^8.0", + "phpdocumentor/reflection-common": "^2.0", + "phpstan/phpdoc-parser": "^2.0" + }, + "require-dev": { + "ext-tokenizer": "*", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psalm/phar": "^4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-1.x": "1.x-dev", + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + } + ], + "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", + "support": { + "issues": "https://github.com/phpDocumentor/TypeResolver/issues", + "source": "https://github.com/phpDocumentor/TypeResolver/tree/2.0.0" + }, + "time": "2026-01-06T21:53:42+00:00" + }, + { + "name": "phpstan/phpdoc-parser", + "version": "2.3.2", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpdoc-parser.git", + "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/a004701b11273a26cd7955a61d67a7f1e525a45a", + "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "doctrine/annotations": "^2.0", + "nikic/php-parser": "^5.3.0", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6", + "symfony/process": "^5.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPStan\\PhpDocParser\\": [ + "src/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPDoc parser with support for nullable, intersection and generic types", + "support": { + "issues": "https://github.com/phpstan/phpdoc-parser/issues", + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.2" + }, + "time": "2026-01-25T14:56:51+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "12.5.6", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "876099a072646c7745f673d7aeab5382c4439691" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/876099a072646c7745f673d7aeab5382c4439691", + "reference": "876099a072646c7745f673d7aeab5382c4439691", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.7.0", + "php": ">=8.3", + "phpunit/php-text-template": "^5.0", + "sebastian/complexity": "^5.0", + "sebastian/environment": "^8.0.3", + "sebastian/lines-of-code": "^4.0", + "sebastian/version": "^6.0", + "theseer/tokenizer": "^2.0.1" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.1" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "12.5.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.6" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" + } + ], + "time": "2026-04-15T08:23:17+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "6.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5", + "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/6.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" + } + ], + "time": "2026-02-02T14:04:18+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/12b54e689b07a25a9b41e57736dfab6ec9ae5406", + "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^12.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:58:58+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "e1367a453f0eda562eedb4f659e13aa900d66c53" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/e1367a453f0eda562eedb4f659e13aa900d66c53", + "reference": "e1367a453f0eda562eedb4f659e13aa900d66c53", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:59:16+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "8.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", + "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/8.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:59:38+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "12.5.24", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "d75dd30597caa80e72fad2ef7904601a30ef1046" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/d75dd30597caa80e72fad2ef7904601a30ef1046", + "reference": "d75dd30597caa80e72fad2ef7904601a30ef1046", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xml": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.3", + "phpunit/php-code-coverage": "^12.5.6", + "phpunit/php-file-iterator": "^6.0.1", + "phpunit/php-invoker": "^6.0.0", + "phpunit/php-text-template": "^5.0.0", + "phpunit/php-timer": "^8.0.0", + "sebastian/cli-parser": "^4.2.0", + "sebastian/comparator": "^7.1.6", + "sebastian/diff": "^7.0.0", + "sebastian/environment": "^8.1.0", + "sebastian/exporter": "^7.0.2", + "sebastian/global-state": "^8.0.2", + "sebastian/object-enumerator": "^7.0.0", + "sebastian/recursion-context": "^7.0.1", + "sebastian/type": "^6.0.3", + "sebastian/version": "^6.0.0", + "staabm/side-effects-detector": "^1.0.5" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "12.5-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.24" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsoring.html", + "type": "other" + } + ], + "time": "2026-05-01T04:21:04+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "4.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "7d05781b13f7dec9043a629a21d086ed74582a15" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/7d05781b13f7dec9043a629a21d086ed74582a15", + "reference": "7d05781b13f7dec9043a629a21d086ed74582a15", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/4.2.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/cli-parser", + "type": "tidelift" + } + ], + "time": "2026-05-17T05:29:34+00:00" + }, + { + "name": "sebastian/comparator", + "version": "7.1.8", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "7c65c1e79836812819705b473a90c12399542485" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/7c65c1e79836812819705b473a90c12399542485", + "reference": "7c65c1e79836812819705b473a90c12399542485", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.3", + "sebastian/diff": "^7.0", + "sebastian/exporter": "^7.0.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.8" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-05-21T04:45:25+00:00" + }, + { + "name": "sebastian/complexity", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/bad4316aba5303d0221f43f8cee37eb58d384bbb", + "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:55:25+00:00" + }, + { + "name": "sebastian/diff", + "version": "7.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "7ab1ea946c012266ca32390913653d844ecd085f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/7ab1ea946c012266ca32390913653d844ecd085f", + "reference": "7ab1ea946c012266ca32390913653d844ecd085f", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0", + "symfony/process": "^7.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/7.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:55:46+00:00" + }, + { + "name": "sebastian/environment", + "version": "8.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "334bc42a97ec6fc44c59001dc3467e0d739a20e9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/334bc42a97ec6fc44c59001dc3467e0d739a20e9", + "reference": "334bc42a97ec6fc44c59001dc3467e0d739a20e9", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/8.1.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" + } + ], + "time": "2026-05-21T08:45:32+00:00" + }, + { + "name": "sebastian/exporter", + "version": "7.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", + "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.3", + "sebastian/recursion-context": "^7.0.1" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/7.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2026-05-20T04:37:17+00:00" + }, + { + "name": "sebastian/global-state", + "version": "8.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "ef1377171613d09edd25b7816f05be8313f9115d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/ef1377171613d09edd25b7816f05be8313f9115d", + "reference": "ef1377171613d09edd25b7816f05be8313f9115d", + "shasum": "" + }, + "require": { + "php": ">=8.3", + "sebastian/object-reflector": "^5.0", + "sebastian/recursion-context": "^7.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/8.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state", + "type": "tidelift" + } + ], + "time": "2025-08-29T11:29:25+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d543b8ef219dcd8da262cbb958639a96bedba10e", + "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.7.0", + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/lines-of-code", + "type": "tidelift" + } + ], + "time": "2026-05-19T16:22:07+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "7.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/1effe8e9b8e068e9ae228e542d5d11b5d16db894", + "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894", + "shasum": "" + }, + "require": { + "php": ">=8.3", + "sebastian/object-reflector": "^5.0", + "sebastian/recursion-context": "^7.0" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/7.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:57:48+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "4bfa827c969c98be1e527abd576533293c634f6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/4bfa827c969c98be1e527abd576533293c634f6a", + "reference": "4bfa827c969c98be1e527abd576533293c634f6a", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:58:17+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", + "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/7.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2025-08-13T04:44:59+00:00" + }, + { + "name": "sebastian/type", + "version": "6.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "82ff822c2edc46724be9f7411d3163021f602773" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/82ff822c2edc46724be9f7411d3163021f602773", + "reference": "82ff822c2edc46724be9f7411d3163021f602773", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/6.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" + } + ], + "time": "2026-05-20T06:45:45+00:00" + }, + { + "name": "sebastian/version", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "3e6ccf7657d4f0a59200564b08cead899313b53c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/3e6ccf7657d4f0a59200564b08cead899313b53c", + "reference": "3e6ccf7657d4f0a59200564b08cead899313b53c", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T05:00:38+00:00" + }, + { + "name": "staabm/side-effects-detector", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, + "funding": [ + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2024-10-20T05:08:20+00:00" + }, + { + "name": "ta-tikoma/phpunit-architecture-test", + "version": "0.8.7", + "source": { + "type": "git", + "url": "https://github.com/ta-tikoma/phpunit-architecture-test.git", + "reference": "1248f3f506ca9641d4f68cebcd538fa489754db8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ta-tikoma/phpunit-architecture-test/zipball/1248f3f506ca9641d4f68cebcd538fa489754db8", + "reference": "1248f3f506ca9641d4f68cebcd538fa489754db8", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^4.18.0 || ^5.0.0", + "php": "^8.1.0", + "phpdocumentor/reflection-docblock": "^5.3.0 || ^6.0.0", + "phpunit/phpunit": "^10.5.5 || ^11.0.0 || ^12.0.0 || ^13.0.0", + "symfony/finder": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "require-dev": { + "laravel/pint": "^1.13.7", + "phpstan/phpstan": "^1.10.52" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPUnit\\Architecture\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ni Shi", + "email": "futik0ma011@gmail.com" + }, + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "Methods for testing application architecture", + "keywords": [ + "architecture", + "phpunit", + "stucture", + "test", + "testing" + ], + "support": { + "issues": "https://github.com/ta-tikoma/phpunit-architecture-test/issues", + "source": "https://github.com/ta-tikoma/phpunit-architecture-test/tree/0.8.7" + }, + "time": "2026-02-17T17:25:14+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^8.1" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/2.0.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-12-08T11:19:18+00:00" + }, + { + "name": "webmozart/assert", + "version": "2.4.0", + "source": { + "type": "git", + "url": "https://github.com/webmozarts/assert.git", + "reference": "9007ea6f45ecf352a9422b36644e4bfc039b9155" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/9007ea6f45ecf352a9422b36644e4bfc039b9155", + "reference": "9007ea6f45ecf352a9422b36644e4bfc039b9155", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-date": "*", + "ext-filter": "*", + "php": "^8.2" + }, + "suggest": { + "ext-intl": "", + "ext-simplexml": "", + "ext-spl": "" + }, + "type": "library", + "extra": { + "psalm": { + "pluginClass": "Webmozart\\Assert\\PsalmPlugin" + }, + "branch-alias": { + "dev-master": "2.0-dev", + "dev-feature/2-0": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "Webmozart\\Assert\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + }, + { + "name": "Woody Gilk", + "email": "woody.gilk@gmail.com" + } + ], + "description": "Assertions to validate method input/output with nice error messages.", + "keywords": [ + "assert", + "check", + "validate" + ], + "support": { + "issues": "https://github.com/webmozarts/assert/issues", + "source": "https://github.com/webmozarts/assert/tree/2.4.0" + }, + "time": "2026-05-20T13:07:01+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": true, + "prefer-lowest": false, + "platform": { + "php": "^8.3" + }, + "platform-dev": {}, + "plugin-api-version": "2.6.0" +} diff --git a/erp/config/app.php b/erp/config/app.php new file mode 100644 index 00000000000..423eed59f1d --- /dev/null +++ b/erp/config/app.php @@ -0,0 +1,126 @@ + env('APP_NAME', 'Laravel'), + + /* + |-------------------------------------------------------------------------- + | Application Environment + |-------------------------------------------------------------------------- + | + | This value determines the "environment" your application is currently + | running in. This may determine how you prefer to configure various + | services the application utilizes. Set this in your ".env" file. + | + */ + + 'env' => env('APP_ENV', 'production'), + + /* + |-------------------------------------------------------------------------- + | Application Debug Mode + |-------------------------------------------------------------------------- + | + | When your application is in debug mode, detailed error messages with + | stack traces will be shown on every error that occurs within your + | application. If disabled, a simple generic error page is shown. + | + */ + + 'debug' => (bool) env('APP_DEBUG', false), + + /* + |-------------------------------------------------------------------------- + | Application URL + |-------------------------------------------------------------------------- + | + | This URL is used by the console to properly generate URLs when using + | the Artisan command line tool. You should set this to the root of + | the application so that it's available within Artisan commands. + | + */ + + 'url' => env('APP_URL', 'http://localhost'), + + /* + |-------------------------------------------------------------------------- + | Application Timezone + |-------------------------------------------------------------------------- + | + | Here you may specify the default timezone for your application, which + | will be used by the PHP date and date-time functions. The timezone + | is set to "UTC" by default as it is suitable for most use cases. + | + */ + + 'timezone' => 'UTC', + + /* + |-------------------------------------------------------------------------- + | Application Locale Configuration + |-------------------------------------------------------------------------- + | + | The application locale determines the default locale that will be used + | by Laravel's translation / localization methods. This option can be + | set to any locale for which you plan to have translation strings. + | + */ + + 'locale' => env('APP_LOCALE', 'en'), + + 'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'), + + 'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'), + + /* + |-------------------------------------------------------------------------- + | Encryption Key + |-------------------------------------------------------------------------- + | + | This key is utilized by Laravel's encryption services and should be set + | to a random, 32 character string to ensure that all encrypted values + | are secure. You should do this prior to deploying the application. + | + */ + + 'cipher' => 'AES-256-CBC', + + 'key' => env('APP_KEY'), + + 'previous_keys' => [ + ...array_filter( + explode(',', (string) env('APP_PREVIOUS_KEYS', '')) + ), + ], + + /* + |-------------------------------------------------------------------------- + | Maintenance Mode Driver + |-------------------------------------------------------------------------- + | + | These configuration options determine the driver used to determine and + | manage Laravel's "maintenance mode" status. The "cache" driver will + | allow maintenance mode to be controlled across multiple machines. + | + | Supported drivers: "file", "cache" + | + */ + + 'maintenance' => [ + 'driver' => env('APP_MAINTENANCE_DRIVER', 'file'), + 'store' => env('APP_MAINTENANCE_STORE', 'database'), + ], + +]; diff --git a/erp/config/auth.php b/erp/config/auth.php new file mode 100644 index 00000000000..d7568ff1942 --- /dev/null +++ b/erp/config/auth.php @@ -0,0 +1,117 @@ + [ + 'guard' => env('AUTH_GUARD', 'web'), + 'passwords' => env('AUTH_PASSWORD_BROKER', 'users'), + ], + + /* + |-------------------------------------------------------------------------- + | Authentication Guards + |-------------------------------------------------------------------------- + | + | Next, you may define every authentication guard for your application. + | Of course, a great default configuration has been defined for you + | which utilizes session storage plus the Eloquent user provider. + | + | All authentication guards have a user provider, which defines how the + | users are actually retrieved out of your database or other storage + | system used by the application. Typically, Eloquent is utilized. + | + | Supported: "session" + | + */ + + 'guards' => [ + 'web' => [ + 'driver' => 'session', + 'provider' => 'users', + ], + ], + + /* + |-------------------------------------------------------------------------- + | User Providers + |-------------------------------------------------------------------------- + | + | All authentication guards have a user provider, which defines how the + | users are actually retrieved out of your database or other storage + | system used by the application. Typically, Eloquent is utilized. + | + | If you have multiple user tables or models you may configure multiple + | providers to represent the model / table. These providers may then + | be assigned to any extra authentication guards you have defined. + | + | Supported: "database", "eloquent" + | + */ + + 'providers' => [ + 'users' => [ + 'driver' => 'eloquent', + 'model' => env('AUTH_MODEL', User::class), + ], + + // 'users' => [ + // 'driver' => 'database', + // 'table' => 'users', + // ], + ], + + /* + |-------------------------------------------------------------------------- + | Resetting Passwords + |-------------------------------------------------------------------------- + | + | These configuration options specify the behavior of Laravel's password + | reset functionality, including the table utilized for token storage + | and the user provider that is invoked to actually retrieve users. + | + | The expiry time is the number of minutes that each reset token will be + | considered valid. This security feature keeps tokens short-lived so + | they have less time to be guessed. You may change this as needed. + | + | The throttle setting is the number of seconds a user must wait before + | generating more password reset tokens. This prevents the user from + | quickly generating a very large amount of password reset tokens. + | + */ + + 'passwords' => [ + 'users' => [ + 'provider' => 'users', + 'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'), + 'expire' => 60, + 'throttle' => 60, + ], + ], + + /* + |-------------------------------------------------------------------------- + | Password Confirmation Timeout + |-------------------------------------------------------------------------- + | + | Here you may define the number of seconds before a password confirmation + | window expires and users are asked to re-enter their password via the + | confirmation screen. By default, the timeout lasts for three hours. + | + */ + + 'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800), + +]; diff --git a/erp/config/broadcasting.php b/erp/config/broadcasting.php new file mode 100644 index 00000000000..ebc3fb9cf13 --- /dev/null +++ b/erp/config/broadcasting.php @@ -0,0 +1,82 @@ + env('BROADCAST_CONNECTION', 'null'), + + /* + |-------------------------------------------------------------------------- + | Broadcast Connections + |-------------------------------------------------------------------------- + | + | Here you may define all of the broadcast connections that will be used + | to broadcast events to other systems or over WebSockets. Samples of + | each available type of connection are provided inside this array. + | + */ + + 'connections' => [ + + 'reverb' => [ + 'driver' => 'reverb', + 'key' => env('REVERB_APP_KEY'), + 'secret' => env('REVERB_APP_SECRET'), + 'app_id' => env('REVERB_APP_ID'), + 'options' => [ + 'host' => env('REVERB_HOST'), + 'port' => env('REVERB_PORT', 443), + 'scheme' => env('REVERB_SCHEME', 'https'), + 'useTLS' => env('REVERB_SCHEME', 'https') === 'https', + ], + 'client_options' => [ + // Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html + ], + ], + + 'pusher' => [ + 'driver' => 'pusher', + 'key' => env('PUSHER_APP_KEY'), + 'secret' => env('PUSHER_APP_SECRET'), + 'app_id' => env('PUSHER_APP_ID'), + 'options' => [ + 'cluster' => env('PUSHER_APP_CLUSTER'), + 'host' => env('PUSHER_HOST') ?: 'api-'.env('PUSHER_APP_CLUSTER', 'mt1').'.pusher.com', + 'port' => env('PUSHER_PORT', 443), + 'scheme' => env('PUSHER_SCHEME', 'https'), + 'encrypted' => true, + 'useTLS' => env('PUSHER_SCHEME', 'https') === 'https', + ], + 'client_options' => [ + // Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html + ], + ], + + 'ably' => [ + 'driver' => 'ably', + 'key' => env('ABLY_KEY'), + ], + + 'log' => [ + 'driver' => 'log', + ], + + 'null' => [ + 'driver' => 'null', + ], + + ], + +]; diff --git a/erp/config/cache.php b/erp/config/cache.php new file mode 100644 index 00000000000..d7eec61a399 --- /dev/null +++ b/erp/config/cache.php @@ -0,0 +1,136 @@ + env('CACHE_STORE', 'database'), + + /* + |-------------------------------------------------------------------------- + | Cache Stores + |-------------------------------------------------------------------------- + | + | Here you may define all of the cache "stores" for your application as + | well as their drivers. You may even define multiple stores for the + | same cache driver to group types of items stored in your caches. + | + | Supported drivers: "array", "database", "file", "memcached", + | "redis", "dynamodb", "storage", "octane", + | "session", "failover", "null" + | + */ + + 'stores' => [ + + 'array' => [ + 'driver' => 'array', + 'serialize' => false, + ], + + 'database' => [ + 'driver' => 'database', + 'connection' => env('DB_CACHE_CONNECTION'), + 'table' => env('DB_CACHE_TABLE', 'cache'), + 'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'), + 'lock_table' => env('DB_CACHE_LOCK_TABLE'), + ], + + 'file' => [ + 'driver' => 'file', + 'path' => storage_path('framework/cache/data'), + 'lock_path' => storage_path('framework/cache/data'), + ], + + 'storage' => [ + 'driver' => 'storage', + 'disk' => env('CACHE_STORAGE_DISK'), + 'path' => env('CACHE_STORAGE_PATH', 'framework/cache/data'), + ], + + 'memcached' => [ + 'driver' => 'memcached', + 'persistent_id' => env('MEMCACHED_PERSISTENT_ID'), + 'sasl' => [ + env('MEMCACHED_USERNAME'), + env('MEMCACHED_PASSWORD'), + ], + 'options' => [ + // Memcached::OPT_CONNECT_TIMEOUT => 2000, + ], + 'servers' => [ + [ + 'host' => env('MEMCACHED_HOST', '127.0.0.1'), + 'port' => env('MEMCACHED_PORT', 11211), + 'weight' => 100, + ], + ], + ], + + 'redis' => [ + 'driver' => 'redis', + 'connection' => env('REDIS_CACHE_CONNECTION', 'cache'), + 'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'), + ], + + 'dynamodb' => [ + 'driver' => 'dynamodb', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + 'table' => env('DYNAMODB_CACHE_TABLE', 'cache'), + 'endpoint' => env('DYNAMODB_ENDPOINT'), + ], + + 'octane' => [ + 'driver' => 'octane', + ], + + 'failover' => [ + 'driver' => 'failover', + 'stores' => [ + 'database', + 'array', + ], + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Cache Key Prefix + |-------------------------------------------------------------------------- + | + | When utilizing the APC, database, memcached, Redis, and DynamoDB cache + | stores, there might be other applications using the same cache. For + | that reason, you may prefix every cache key to avoid collisions. + | + */ + + 'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'), + + /* + |-------------------------------------------------------------------------- + | Serializable Classes + |-------------------------------------------------------------------------- + | + | This value determines the classes that can be unserialized from cache + | storage. By default, no PHP classes will be unserialized from your + | cache to prevent gadget chain attacks if your APP_KEY is leaked. + | + */ + + 'serializable_classes' => false, + +]; diff --git a/erp/config/database.php b/erp/config/database.php new file mode 100644 index 00000000000..abbb88e3724 --- /dev/null +++ b/erp/config/database.php @@ -0,0 +1,184 @@ + env('DB_CONNECTION', 'sqlite'), + + /* + |-------------------------------------------------------------------------- + | Database Connections + |-------------------------------------------------------------------------- + | + | Below are all of the database connections defined for your application. + | An example configuration is provided for each database system which + | is supported by Laravel. You're free to add / remove connections. + | + */ + + 'connections' => [ + + 'sqlite' => [ + 'driver' => 'sqlite', + 'url' => env('DB_URL'), + 'database' => env('DB_DATABASE', database_path('database.sqlite')), + 'prefix' => '', + 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), + 'busy_timeout' => null, + 'journal_mode' => null, + 'synchronous' => null, + 'transaction_mode' => 'DEFERRED', + ], + + 'mysql' => [ + 'driver' => 'mysql', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '3306'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'unix_socket' => env('DB_SOCKET', ''), + 'charset' => env('DB_CHARSET', 'utf8mb4'), + 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'), + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + 'options' => extension_loaded('pdo_mysql') ? array_filter([ + Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), + ]) : [], + ], + + 'mariadb' => [ + 'driver' => 'mariadb', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '3306'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'unix_socket' => env('DB_SOCKET', ''), + 'charset' => env('DB_CHARSET', 'utf8mb4'), + 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'), + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + 'options' => extension_loaded('pdo_mysql') ? array_filter([ + Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), + ]) : [], + ], + + 'pgsql' => [ + 'driver' => 'pgsql', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '5432'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'charset' => env('DB_CHARSET', 'utf8'), + 'prefix' => '', + 'prefix_indexes' => true, + 'search_path' => 'public', + 'sslmode' => env('DB_SSLMODE', 'prefer'), + ], + + 'sqlsrv' => [ + 'driver' => 'sqlsrv', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', 'localhost'), + 'port' => env('DB_PORT', '1433'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'charset' => env('DB_CHARSET', 'utf8'), + 'prefix' => '', + 'prefix_indexes' => true, + // 'encrypt' => env('DB_ENCRYPT', 'yes'), + // 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'), + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Migration Repository Table + |-------------------------------------------------------------------------- + | + | This table keeps track of all the migrations that have already run for + | your application. Using this information, we can determine which of + | the migrations on disk haven't actually been run on the database. + | + */ + + 'migrations' => [ + 'table' => 'migrations', + 'update_date_on_publish' => true, + ], + + /* + |-------------------------------------------------------------------------- + | Redis Databases + |-------------------------------------------------------------------------- + | + | Redis is an open source, fast, and advanced key-value store that also + | provides a richer body of commands than a typical key-value system + | such as Memcached. You may define your connection settings here. + | + */ + + 'redis' => [ + + 'client' => env('REDIS_CLIENT', 'phpredis'), + + 'options' => [ + 'cluster' => env('REDIS_CLUSTER', 'redis'), + 'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'), + 'persistent' => env('REDIS_PERSISTENT', false), + ], + + 'default' => [ + 'url' => env('REDIS_URL'), + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'username' => env('REDIS_USERNAME'), + 'password' => env('REDIS_PASSWORD'), + 'port' => env('REDIS_PORT', '6379'), + 'database' => env('REDIS_DB', '0'), + 'max_retries' => env('REDIS_MAX_RETRIES', 3), + 'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'), + 'backoff_base' => env('REDIS_BACKOFF_BASE', 100), + 'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000), + ], + + 'cache' => [ + 'url' => env('REDIS_URL'), + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'username' => env('REDIS_USERNAME'), + 'password' => env('REDIS_PASSWORD'), + 'port' => env('REDIS_PORT', '6379'), + 'database' => env('REDIS_CACHE_DB', '1'), + 'max_retries' => env('REDIS_MAX_RETRIES', 3), + 'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'), + 'backoff_base' => env('REDIS_BACKOFF_BASE', 100), + 'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000), + ], + + ], + +]; diff --git a/erp/config/dompdf.php b/erp/config/dompdf.php new file mode 100644 index 00000000000..677d93c7d03 --- /dev/null +++ b/erp/config/dompdf.php @@ -0,0 +1,301 @@ + false, // Throw an Exception on warnings from dompdf + + 'public_path' => null, // Override the public path if needed + + /* + * Dejavu Sans font is missing glyphs for converted entities, turn it off if you need to show € and £. + */ + 'convert_entities' => true, + + 'options' => [ + /** + * The location of the DOMPDF font directory + * + * The location of the directory where DOMPDF will store fonts and font metrics + * Note: This directory must exist and be writable by the webserver process. + * *Please note the trailing slash.* + * + * Notes regarding fonts: + * Additional .afm font metrics can be added by executing load_font.php from command line. + * + * Only the original "Base 14 fonts" are present on all pdf viewers. Additional fonts must + * be embedded in the pdf file or the PDF may not display correctly. This can significantly + * increase file size unless font subsetting is enabled. Before embedding a font please + * review your rights under the font license. + * + * Any font specification in the source HTML is translated to the closest font available + * in the font directory. + * + * The pdf standard "Base 14 fonts" are: + * Courier, Courier-Bold, Courier-BoldOblique, Courier-Oblique, + * Helvetica, Helvetica-Bold, Helvetica-BoldOblique, Helvetica-Oblique, + * Times-Roman, Times-Bold, Times-BoldItalic, Times-Italic, + * Symbol, ZapfDingbats. + */ + 'font_dir' => storage_path('fonts'), // advised by dompdf (https://github.com/dompdf/dompdf/pull/782) + + /** + * The location of the DOMPDF font cache directory + * + * This directory contains the cached font metrics for the fonts used by DOMPDF. + * This directory can be the same as DOMPDF_FONT_DIR + * + * Note: This directory must exist and be writable by the webserver process. + */ + 'font_cache' => storage_path('fonts'), + + /** + * The location of a temporary directory. + * + * The directory specified must be writeable by the webserver process. + * The temporary directory is required to download remote images and when + * using the PDFLib back end. + */ + 'temp_dir' => sys_get_temp_dir(), + + /** + * ==== IMPORTANT ==== + * + * dompdf's "chroot": Prevents dompdf from accessing system files or other + * files on the webserver. All local files opened by dompdf must be in a + * subdirectory of this directory. DO NOT set it to '/' since this could + * allow an attacker to use dompdf to read any files on the server. This + * should be an absolute path. + * This is only checked on command line call by dompdf.php, but not by + * direct class use like: + * $dompdf = new DOMPDF(); $dompdf->load_html($htmldata); $dompdf->render(); $pdfdata = $dompdf->output(); + */ + 'chroot' => realpath(base_path()), + + /** + * Protocol whitelist + * + * Protocols and PHP wrappers allowed in URIs, and the validation rules + * that determine if a resouce may be loaded. Full support is not guaranteed + * for the protocols/wrappers specified + * by this array. + * + * @var array + */ + 'allowed_protocols' => [ + 'data://' => ['rules' => []], + 'file://' => ['rules' => []], + 'http://' => ['rules' => []], + 'https://' => ['rules' => []], + ], + + /** + * Operational artifact (log files, temporary files) path validation + */ + 'artifactPathValidation' => null, + + /** + * @var string + */ + 'log_output_file' => null, + + /** + * Whether to enable font subsetting or not. + */ + 'enable_font_subsetting' => false, + + /** + * The PDF rendering backend to use + * + * Valid settings are 'PDFLib', 'CPDF' (the bundled R&OS PDF class), 'GD' and + * 'auto'. 'auto' will look for PDFLib and use it if found, or if not it will + * fall back on CPDF. 'GD' renders PDFs to graphic files. + * {@link * Canvas_Factory} ultimately determines which rendering class to + * instantiate based on this setting. + * + * Both PDFLib & CPDF rendering backends provide sufficient rendering + * capabilities for dompdf, however additional features (e.g. object, + * image and font support, etc.) differ between backends. Please see + * {@link PDFLib_Adapter} for more information on the PDFLib backend + * and {@link CPDF_Adapter} and lib/class.pdf.php for more information + * on CPDF. Also see the documentation for each backend at the links + * below. + * + * The GD rendering backend is a little different than PDFLib and + * CPDF. Several features of CPDF and PDFLib are not supported or do + * not make any sense when creating image files. For example, + * multiple pages are not supported, nor are PDF 'objects'. Have a + * look at {@link GD_Adapter} for more information. GD support is + * experimental, so use it at your own risk. + * + * @link http://www.pdflib.com + * @link http://www.ros.co.nz/pdf + * @link http://www.php.net/image + */ + 'pdf_backend' => 'CPDF', + + /** + * html target media view which should be rendered into pdf. + * List of types and parsing rules for future extensions: + * http://www.w3.org/TR/REC-html40/types.html + * screen, tty, tv, projection, handheld, print, braille, aural, all + * Note: aural is deprecated in CSS 2.1 because it is replaced by speech in CSS 3. + * Note, even though the generated pdf file is intended for print output, + * the desired content might be different (e.g. screen or projection view of html file). + * Therefore allow specification of content here. + */ + 'default_media_type' => 'screen', + + /** + * The default paper size. + * + * North America standard is "letter"; other countries generally "a4" + * + * @see CPDF_Adapter::PAPER_SIZES for valid sizes ('letter', 'legal', 'A4', etc.) + */ + 'default_paper_size' => 'a4', + + /** + * The default paper orientation. + * + * The orientation of the page (portrait or landscape). + * + * @var string + */ + 'default_paper_orientation' => 'portrait', + + /** + * The default font family + * + * Used if no suitable fonts can be found. This must exist in the font folder. + * + * @var string + */ + 'default_font' => 'serif', + + /** + * Image DPI setting + * + * This setting determines the default DPI setting for images and fonts. The + * DPI may be overridden for inline images by explictly setting the + * image's width & height style attributes (i.e. if the image's native + * width is 600 pixels and you specify the image's width as 72 points, + * the image will have a DPI of 600 in the rendered PDF. The DPI of + * background images can not be overridden and is controlled entirely + * via this parameter. + * + * For the purposes of DOMPDF, pixels per inch (PPI) = dots per inch (DPI). + * If a size in html is given as px (or without unit as image size), + * this tells the corresponding size in pt. + * This adjusts the relative sizes to be similar to the rendering of the + * html page in a reference browser. + * + * In pdf, always 1 pt = 1/72 inch + * + * Rendering resolution of various browsers in px per inch: + * Windows Firefox and Internet Explorer: + * SystemControl->Display properties->FontResolution: Default:96, largefonts:120, custom:? + * Linux Firefox: + * about:config *resolution: Default:96 + * (xorg screen dimension in mm and Desktop font dpi settings are ignored) + * + * Take care about extra font/image zoom factor of browser. + * + * In images, size in pixel attribute, img css style, are overriding + * the real image dimension in px for rendering. + * + * @var int + */ + 'dpi' => 96, + + /** + * Enable embedded PHP + * + * If this setting is set to true then DOMPDF will automatically evaluate embedded PHP contained + * within tags. + * + * ==== IMPORTANT ==== Enabling this for documents you do not trust (e.g. arbitrary remote html pages) + * is a security risk. + * Embedded scripts are run with the same level of system access available to dompdf. + * Set this option to false (recommended) if you wish to process untrusted documents. + * This setting may increase the risk of system exploit. + * Do not change this settings without understanding the consequences. + * Additional documentation is available on the dompdf wiki at: + * https://github.com/dompdf/dompdf/wiki + * + * @var bool + */ + 'enable_php' => false, + + /** + * Enable inline JavaScript + * + * If this setting is set to true then DOMPDF will automatically insert JavaScript code contained + * within tags as written into the PDF. + * NOTE: This is PDF-based JavaScript to be executed by the PDF viewer, + * not browser-based JavaScript executed by Dompdf. + * + * @var bool + */ + 'enable_javascript' => true, + + /** + * Enable remote file access + * + * If this setting is set to true, DOMPDF will access remote sites for + * images and CSS files as required. + * + * ==== IMPORTANT ==== + * This can be a security risk, in particular in combination with isPhpEnabled and + * allowing remote html code to be passed to $dompdf = new DOMPDF(); $dompdf->load_html(...); + * This allows anonymous users to download legally doubtful internet content which on + * tracing back appears to being downloaded by your server, or allows malicious php code + * in remote html pages to be executed by your server with your account privileges. + * + * This setting may increase the risk of system exploit. Do not change + * this settings without understanding the consequences. Additional + * documentation is available on the dompdf wiki at: + * https://github.com/dompdf/dompdf/wiki + * + * @var bool + */ + 'enable_remote' => false, + + /** + * List of allowed remote hosts + * + * Each value of the array must be a valid hostname. + * + * This will be used to filter which resources can be loaded in combination with + * isRemoteEnabled. If enable_remote is FALSE, then this will have no effect. + * + * Leave to NULL to allow any remote host. + * + * @var array|null + */ + 'allowed_remote_hosts' => null, + + /** + * A ratio applied to the fonts height to be more like browsers' line height + */ + 'font_height_ratio' => 1.1, + + /** + * Use the HTML5 Lib parser + * + * @deprecated This feature is now always on in dompdf 2.x + * + * @var bool + */ + 'enable_html5_parser' => true, + ], + +]; diff --git a/erp/config/filesystems.php b/erp/config/filesystems.php new file mode 100644 index 00000000000..37d8fca4f6f --- /dev/null +++ b/erp/config/filesystems.php @@ -0,0 +1,80 @@ + env('FILESYSTEM_DISK', 'local'), + + /* + |-------------------------------------------------------------------------- + | Filesystem Disks + |-------------------------------------------------------------------------- + | + | Below you may configure as many filesystem disks as necessary, and you + | may even configure multiple disks for the same driver. Examples for + | most supported storage drivers are configured here for reference. + | + | Supported drivers: "local", "ftp", "sftp", "s3" + | + */ + + 'disks' => [ + + 'local' => [ + 'driver' => 'local', + 'root' => storage_path('app/private'), + 'serve' => true, + 'throw' => false, + 'report' => false, + ], + + 'public' => [ + 'driver' => 'local', + 'root' => storage_path('app/public'), + 'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage', + 'visibility' => 'public', + 'throw' => false, + 'report' => false, + ], + + 's3' => [ + 'driver' => 's3', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION'), + 'bucket' => env('AWS_BUCKET'), + 'url' => env('AWS_URL'), + 'endpoint' => env('AWS_ENDPOINT'), + 'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false), + 'throw' => false, + 'report' => false, + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Symbolic Links + |-------------------------------------------------------------------------- + | + | Here you may configure the symbolic links that will be created when the + | `storage:link` Artisan command is executed. The array keys should be + | the locations of the links and the values should be their targets. + | + */ + + 'links' => [ + public_path('storage') => storage_path('app/public'), + ], + +]; diff --git a/erp/config/logging.php b/erp/config/logging.php new file mode 100644 index 00000000000..b09cb25d956 --- /dev/null +++ b/erp/config/logging.php @@ -0,0 +1,132 @@ + env('LOG_CHANNEL', 'stack'), + + /* + |-------------------------------------------------------------------------- + | Deprecations Log Channel + |-------------------------------------------------------------------------- + | + | This option controls the log channel that should be used to log warnings + | regarding deprecated PHP and library features. This allows you to get + | your application ready for upcoming major versions of dependencies. + | + */ + + 'deprecations' => [ + 'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'), + 'trace' => env('LOG_DEPRECATIONS_TRACE', false), + ], + + /* + |-------------------------------------------------------------------------- + | Log Channels + |-------------------------------------------------------------------------- + | + | Here you may configure the log channels for your application. Laravel + | utilizes the Monolog PHP logging library, which includes a variety + | of powerful log handlers and formatters that you're free to use. + | + | Available drivers: "single", "daily", "slack", "syslog", + | "errorlog", "monolog", "custom", "stack" + | + */ + + 'channels' => [ + + 'stack' => [ + 'driver' => 'stack', + 'channels' => explode(',', (string) env('LOG_STACK', 'single')), + 'ignore_exceptions' => false, + ], + + 'single' => [ + 'driver' => 'single', + 'path' => storage_path('logs/laravel.log'), + 'level' => env('LOG_LEVEL', 'debug'), + 'replace_placeholders' => true, + ], + + 'daily' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/laravel.log'), + 'level' => env('LOG_LEVEL', 'debug'), + 'days' => env('LOG_DAILY_DAYS', 14), + 'replace_placeholders' => true, + ], + + 'slack' => [ + 'driver' => 'slack', + 'url' => env('LOG_SLACK_WEBHOOK_URL'), + 'username' => env('LOG_SLACK_USERNAME', env('APP_NAME', 'Laravel')), + 'emoji' => env('LOG_SLACK_EMOJI', ':boom:'), + 'level' => env('LOG_LEVEL', 'critical'), + 'replace_placeholders' => true, + ], + + 'papertrail' => [ + 'driver' => 'monolog', + 'level' => env('LOG_LEVEL', 'debug'), + 'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class), + 'handler_with' => [ + 'host' => env('PAPERTRAIL_URL'), + 'port' => env('PAPERTRAIL_PORT'), + 'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'), + ], + 'processors' => [PsrLogMessageProcessor::class], + ], + + 'stderr' => [ + 'driver' => 'monolog', + 'level' => env('LOG_LEVEL', 'debug'), + 'handler' => StreamHandler::class, + 'handler_with' => [ + 'stream' => 'php://stderr', + ], + 'formatter' => env('LOG_STDERR_FORMATTER'), + 'processors' => [PsrLogMessageProcessor::class], + ], + + 'syslog' => [ + 'driver' => 'syslog', + 'level' => env('LOG_LEVEL', 'debug'), + 'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER), + 'replace_placeholders' => true, + ], + + 'errorlog' => [ + 'driver' => 'errorlog', + 'level' => env('LOG_LEVEL', 'debug'), + 'replace_placeholders' => true, + ], + + 'null' => [ + 'driver' => 'monolog', + 'handler' => NullHandler::class, + ], + + 'emergency' => [ + 'path' => storage_path('logs/laravel.log'), + ], + + ], + +]; diff --git a/erp/config/mail.php b/erp/config/mail.php new file mode 100644 index 00000000000..e32e88da2cc --- /dev/null +++ b/erp/config/mail.php @@ -0,0 +1,118 @@ + env('MAIL_MAILER', 'log'), + + /* + |-------------------------------------------------------------------------- + | Mailer Configurations + |-------------------------------------------------------------------------- + | + | Here you may configure all of the mailers used by your application plus + | their respective settings. Several examples have been configured for + | you and you are free to add your own as your application requires. + | + | Laravel supports a variety of mail "transport" drivers that can be used + | when delivering an email. You may specify which one you're using for + | your mailers below. You may also add additional mailers if needed. + | + | Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2", + | "postmark", "resend", "log", "array", + | "failover", "roundrobin" + | + */ + + 'mailers' => [ + + 'smtp' => [ + 'transport' => 'smtp', + 'scheme' => env('MAIL_SCHEME'), + 'url' => env('MAIL_URL'), + 'host' => env('MAIL_HOST', '127.0.0.1'), + 'port' => env('MAIL_PORT', 2525), + 'username' => env('MAIL_USERNAME'), + 'password' => env('MAIL_PASSWORD'), + 'timeout' => null, + 'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)), + ], + + 'ses' => [ + 'transport' => 'ses', + ], + + 'postmark' => [ + 'transport' => 'postmark', + // 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'), + // 'client' => [ + // 'timeout' => 5, + // ], + ], + + 'resend' => [ + 'transport' => 'resend', + ], + + 'sendmail' => [ + 'transport' => 'sendmail', + 'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'), + ], + + 'log' => [ + 'transport' => 'log', + 'channel' => env('MAIL_LOG_CHANNEL'), + ], + + 'array' => [ + 'transport' => 'array', + ], + + 'failover' => [ + 'transport' => 'failover', + 'mailers' => [ + 'smtp', + 'log', + ], + 'retry_after' => 60, + ], + + 'roundrobin' => [ + 'transport' => 'roundrobin', + 'mailers' => [ + 'ses', + 'postmark', + ], + 'retry_after' => 60, + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Global "From" Address + |-------------------------------------------------------------------------- + | + | You may wish for all emails sent by your application to be sent from + | the same address. Here you may specify a name and address that is + | used globally for all emails that are sent by your application. + | + */ + + 'from' => [ + 'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'), + 'name' => env('MAIL_FROM_NAME', env('APP_NAME', 'Laravel')), + ], + +]; diff --git a/erp/config/permission.php b/erp/config/permission.php new file mode 100644 index 00000000000..8f1f452dc1e --- /dev/null +++ b/erp/config/permission.php @@ -0,0 +1,219 @@ + [ + + /* + * When using the "HasPermissions" trait from this package, we need to know which + * Eloquent model should be used to retrieve your permissions. Of course, it + * is often just the "Permission" model but you may use whatever you like. + * + * The model you want to use as a Permission model needs to implement the + * `Spatie\Permission\Contracts\Permission` contract. + */ + + 'permission' => Permission::class, + + /* + * When using the "HasRoles" trait from this package, we need to know which + * Eloquent model should be used to retrieve your roles. Of course, it + * is often just the "Role" model but you may use whatever you like. + * + * The model you want to use as a Role model needs to implement the + * `Spatie\Permission\Contracts\Role` contract. + */ + + 'role' => Role::class, + + /* + * When using the "Teams" feature from this package, we need to know which + * Eloquent model should be used to retrieve your teams. Of course, it + * is often just the "Team" model but you may use whatever you like. + */ + 'team' => null, + + /* + * When using the "HasModels" trait and passing raw IDs to syncModels, + * attachModels, or detachModels, this model class will be used to + * resolve those IDs. If null, defaults to the guard's model. + */ + 'default_model' => null, + ], + + 'table_names' => [ + + /* + * When using the "HasRoles" trait from this package, we need to know which + * table should be used to retrieve your roles. We have chosen a basic + * default value but you may easily change it to any table you like. + */ + + 'roles' => 'roles', + + /* + * When using the "HasPermissions" trait from this package, we need to know which + * table should be used to retrieve your permissions. We have chosen a basic + * default value but you may easily change it to any table you like. + */ + + 'permissions' => 'permissions', + + /* + * When using the "HasPermissions" trait from this package, we need to know which + * table should be used to retrieve your models permissions. We have chosen a + * basic default value but you may easily change it to any table you like. + */ + + 'model_has_permissions' => 'model_has_permissions', + + /* + * When using the "HasRoles" trait from this package, we need to know which + * table should be used to retrieve your models roles. We have chosen a + * basic default value but you may easily change it to any table you like. + */ + + 'model_has_roles' => 'model_has_roles', + + /* + * When using the "HasRoles" trait from this package, we need to know which + * table should be used to retrieve your roles permissions. We have chosen a + * basic default value but you may easily change it to any table you like. + */ + + 'role_has_permissions' => 'role_has_permissions', + ], + + 'column_names' => [ + /* + * Change this if you want to name the related pivots other than defaults + */ + 'role_pivot_key' => null, // default 'role_id', + 'permission_pivot_key' => null, // default 'permission_id', + + /* + * Change this if you want to name the related model primary key other than + * `model_id`. + * + * For example, this would be nice if your primary keys are all UUIDs. In + * that case, name this `model_uuid`. + */ + + 'model_morph_key' => 'model_id', + + /* + * Change this if you want to use the teams feature and your related model's + * foreign key is other than `team_id`. + */ + + 'team_foreign_key' => 'team_id', + ], + + /* + * When set to true, the method for checking permissions will be registered on the gate. + * Set this to false if you want to implement custom logic for checking permissions. + */ + + 'register_permission_check_method' => true, + + /* + * When set to true, Laravel\Octane\Events\OperationTerminated event listener will be registered + * this will refresh permissions on every TickTerminated, TaskTerminated and RequestTerminated + * NOTE: This should not be needed in most cases, but an Octane/Vapor combination benefited from it. + */ + 'register_octane_reset_listener' => false, + + /* + * Events will fire when a role or permission is assigned/unassigned: + * \Spatie\Permission\Events\RoleAttachedEvent + * \Spatie\Permission\Events\RoleDetachedEvent + * \Spatie\Permission\Events\PermissionAttachedEvent + * \Spatie\Permission\Events\PermissionDetachedEvent + * + * To enable, set to true, and then create listeners to watch these events. + */ + 'events_enabled' => false, + + /* + * Teams Feature. + * When set to true the package implements teams using the 'team_foreign_key'. + * If you want the migrations to register the 'team_foreign_key', you must + * set this to true before doing the migration. + * If you already did the migration then you must make a new migration to also + * add 'team_foreign_key' to 'roles', 'model_has_roles', and 'model_has_permissions' + * (view the latest version of this package's migration file) + */ + + 'teams' => false, + + /* + * The class to use to resolve the permissions team id + */ + 'team_resolver' => DefaultTeamResolver::class, + + /* + * Passport Client Credentials Grant + * When set to true the package will use Passports Client to check permissions + */ + + 'use_passport_client_credentials' => false, + + /* + * When set to true, the required permission names are added to exception messages. + * This could be considered an information leak in some contexts, so the default + * setting is false here for optimum safety. + */ + + 'display_permission_in_exception' => false, + + /* + * When set to true, the required role names are added to exception messages. + * This could be considered an information leak in some contexts, so the default + * setting is false here for optimum safety. + */ + + 'display_role_in_exception' => false, + + /* + * By default wildcard permission lookups are disabled. + * See documentation to understand supported syntax. + */ + + 'enable_wildcard_permission' => false, + + /* + * The class to use for interpreting wildcard permissions. + * If you need to modify delimiters, override the class and specify its name here. + */ + // 'wildcard_permission' => Spatie\Permission\WildcardPermission::class, + + /* Cache-specific settings */ + + 'cache' => [ + + /* + * By default all permissions are cached for 24 hours to speed up performance. + * When permissions or roles are updated the cache is flushed automatically. + */ + + 'expiration_time' => DateInterval::createFromDateString('24 hours'), + + /* + * The cache key used to store all permissions. + */ + + 'key' => 'spatie.permission.cache', + + /* + * You may optionally indicate a specific cache driver to use for permission and + * role caching using any of the `store` drivers listed in the cache.php config + * file. Using 'default' here means to use the `default` set in cache.php. + */ + + 'store' => 'default', + ], +]; diff --git a/erp/config/queue.php b/erp/config/queue.php new file mode 100644 index 00000000000..79c2c0a23cd --- /dev/null +++ b/erp/config/queue.php @@ -0,0 +1,129 @@ + env('QUEUE_CONNECTION', 'database'), + + /* + |-------------------------------------------------------------------------- + | Queue Connections + |-------------------------------------------------------------------------- + | + | Here you may configure the connection options for every queue backend + | used by your application. An example configuration is provided for + | each backend supported by Laravel. You're also free to add more. + | + | Drivers: "sync", "database", "beanstalkd", "sqs", "redis", + | "deferred", "background", "failover", "null" + | + */ + + 'connections' => [ + + 'sync' => [ + 'driver' => 'sync', + ], + + 'database' => [ + 'driver' => 'database', + 'connection' => env('DB_QUEUE_CONNECTION'), + 'table' => env('DB_QUEUE_TABLE', 'jobs'), + 'queue' => env('DB_QUEUE', 'default'), + 'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90), + 'after_commit' => false, + ], + + 'beanstalkd' => [ + 'driver' => 'beanstalkd', + 'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'), + 'queue' => env('BEANSTALKD_QUEUE', 'default'), + 'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90), + 'block_for' => 0, + 'after_commit' => false, + ], + + 'sqs' => [ + 'driver' => 'sqs', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'), + 'queue' => env('SQS_QUEUE', 'default'), + 'suffix' => env('SQS_SUFFIX'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + 'after_commit' => false, + ], + + 'redis' => [ + 'driver' => 'redis', + 'connection' => env('REDIS_QUEUE_CONNECTION', 'default'), + 'queue' => env('REDIS_QUEUE', 'default'), + 'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90), + 'block_for' => null, + 'after_commit' => false, + ], + + 'deferred' => [ + 'driver' => 'deferred', + ], + + 'background' => [ + 'driver' => 'background', + ], + + 'failover' => [ + 'driver' => 'failover', + 'connections' => [ + 'database', + 'deferred', + ], + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Job Batching + |-------------------------------------------------------------------------- + | + | The following options configure the database and table that store job + | batching information. These options can be updated to any database + | connection and table which has been defined by your application. + | + */ + + 'batching' => [ + 'database' => env('DB_CONNECTION', 'sqlite'), + 'table' => 'job_batches', + ], + + /* + |-------------------------------------------------------------------------- + | Failed Queue Jobs + |-------------------------------------------------------------------------- + | + | These options configure the behavior of failed queue job logging so you + | can control how and where failed jobs are stored. Laravel ships with + | support for storing failed jobs in a simple file or in a database. + | + | Supported drivers: "database-uuids", "dynamodb", "file", "null" + | + */ + + 'failed' => [ + 'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'), + 'database' => env('DB_CONNECTION', 'sqlite'), + 'table' => 'failed_jobs', + ], + +]; diff --git a/erp/config/reverb.php b/erp/config/reverb.php new file mode 100644 index 00000000000..91f38808185 --- /dev/null +++ b/erp/config/reverb.php @@ -0,0 +1,102 @@ + env('REVERB_SERVER', 'reverb'), + + /* + |-------------------------------------------------------------------------- + | Reverb Servers + |-------------------------------------------------------------------------- + | + | Here you may define details for each of the supported Reverb servers. + | Each server has its own configuration options that are defined in + | the array below. You should ensure all the options are present. + | + */ + + 'servers' => [ + + 'reverb' => [ + 'host' => env('REVERB_SERVER_HOST', '0.0.0.0'), + 'port' => env('REVERB_SERVER_PORT', 8080), + 'path' => env('REVERB_SERVER_PATH', ''), + 'hostname' => env('REVERB_HOST'), + 'options' => [ + 'tls' => [], + ], + 'max_request_size' => env('REVERB_MAX_REQUEST_SIZE', 10_000), + 'scaling' => [ + 'enabled' => env('REVERB_SCALING_ENABLED', false), + 'channel' => env('REVERB_SCALING_CHANNEL', 'reverb'), + 'server' => [ + 'url' => env('REDIS_URL'), + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'port' => env('REDIS_PORT', '6379'), + 'username' => env('REDIS_USERNAME'), + 'password' => env('REDIS_PASSWORD'), + 'database' => env('REDIS_DB', '0'), + 'timeout' => env('REDIS_TIMEOUT', 60), + ], + ], + 'pulse_ingest_interval' => env('REVERB_PULSE_INGEST_INTERVAL', 15), + 'telescope_ingest_interval' => env('REVERB_TELESCOPE_INGEST_INTERVAL', 15), + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Reverb Applications + |-------------------------------------------------------------------------- + | + | Here you may define how Reverb applications are managed. If you choose + | to use the "config" provider, you may define an array of apps which + | your server will support, including their connection credentials. + | + */ + + 'apps' => [ + + 'provider' => 'config', + + 'apps' => [ + [ + 'key' => env('REVERB_APP_KEY'), + 'secret' => env('REVERB_APP_SECRET'), + 'app_id' => env('REVERB_APP_ID'), + 'options' => [ + 'host' => env('REVERB_HOST'), + 'port' => env('REVERB_PORT', 443), + 'scheme' => env('REVERB_SCHEME', 'https'), + 'useTLS' => env('REVERB_SCHEME', 'https') === 'https', + ], + 'allowed_origins' => ['*'], + 'ping_interval' => env('REVERB_APP_PING_INTERVAL', 60), + 'activity_timeout' => env('REVERB_APP_ACTIVITY_TIMEOUT', 30), + 'max_connections' => env('REVERB_APP_MAX_CONNECTIONS'), + 'max_message_size' => env('REVERB_APP_MAX_MESSAGE_SIZE', 10_000), + 'accept_client_events_from' => env('REVERB_APP_ACCEPT_CLIENT_EVENTS_FROM', 'members'), + 'rate_limiting' => [ + 'enabled' => env('REVERB_APP_RATE_LIMITING_ENABLED', false), + 'max_attempts' => env('REVERB_APP_RATE_LIMIT_MAX_ATTEMPTS', 60), + 'decay_seconds' => env('REVERB_APP_RATE_LIMIT_DECAY_SECONDS', 60), + 'terminate_on_limit' => env('REVERB_APP_RATE_LIMIT_TERMINATE', false), + ], + ], + ], + + ], + +]; diff --git a/erp/config/services.php b/erp/config/services.php new file mode 100644 index 00000000000..6a90eb8305b --- /dev/null +++ b/erp/config/services.php @@ -0,0 +1,38 @@ + [ + 'key' => env('POSTMARK_API_KEY'), + ], + + 'resend' => [ + 'key' => env('RESEND_API_KEY'), + ], + + 'ses' => [ + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + ], + + 'slack' => [ + 'notifications' => [ + 'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'), + 'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'), + ], + ], + +]; diff --git a/erp/config/session.php b/erp/config/session.php new file mode 100644 index 00000000000..f5744827f90 --- /dev/null +++ b/erp/config/session.php @@ -0,0 +1,233 @@ + env('SESSION_DRIVER', 'database'), + + /* + |-------------------------------------------------------------------------- + | Session Lifetime + |-------------------------------------------------------------------------- + | + | Here you may specify the number of minutes that you wish the session + | to be allowed to remain idle before it expires. If you want them + | to expire immediately when the browser is closed then you may + | indicate that via the expire_on_close configuration option. + | + */ + + 'lifetime' => (int) env('SESSION_LIFETIME', 120), + + 'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false), + + /* + |-------------------------------------------------------------------------- + | Session Encryption + |-------------------------------------------------------------------------- + | + | This option allows you to easily specify that all of your session data + | should be encrypted before it's stored. All encryption is performed + | automatically by Laravel and you may use the session like normal. + | + */ + + 'encrypt' => env('SESSION_ENCRYPT', false), + + /* + |-------------------------------------------------------------------------- + | Session File Location + |-------------------------------------------------------------------------- + | + | When utilizing the "file" session driver, the session files are placed + | on disk. The default storage location is defined here; however, you + | are free to provide another location where they should be stored. + | + */ + + 'files' => storage_path('framework/sessions'), + + /* + |-------------------------------------------------------------------------- + | Session Database Connection + |-------------------------------------------------------------------------- + | + | When using the "database" or "redis" session drivers, you may specify a + | connection that should be used to manage these sessions. This should + | correspond to a connection in your database configuration options. + | + */ + + 'connection' => env('SESSION_CONNECTION'), + + /* + |-------------------------------------------------------------------------- + | Session Database Table + |-------------------------------------------------------------------------- + | + | When using the "database" session driver, you may specify the table to + | be used to store sessions. Of course, a sensible default is defined + | for you; however, you're welcome to change this to another table. + | + */ + + 'table' => env('SESSION_TABLE', 'sessions'), + + /* + |-------------------------------------------------------------------------- + | Session Cache Store + |-------------------------------------------------------------------------- + | + | When using one of the framework's cache driven session backends, you may + | define the cache store which should be used to store the session data + | between requests. This must match one of your defined cache stores. + | + | Affects: "dynamodb", "memcached", "redis" + | + */ + + 'store' => env('SESSION_STORE'), + + /* + |-------------------------------------------------------------------------- + | Session Sweeping Lottery + |-------------------------------------------------------------------------- + | + | Some session drivers must manually sweep their storage location to get + | rid of old sessions from storage. Here are the chances that it will + | happen on a given request. By default, the odds are 2 out of 100. + | + */ + + 'lottery' => [2, 100], + + /* + |-------------------------------------------------------------------------- + | Session Cookie Name + |-------------------------------------------------------------------------- + | + | Here you may change the name of the session cookie that is created by + | the framework. Typically, you should not need to change this value + | since doing so does not grant a meaningful security improvement. + | + */ + + 'cookie' => env( + 'SESSION_COOKIE', + Str::slug((string) env('APP_NAME', 'laravel')).'-session' + ), + + /* + |-------------------------------------------------------------------------- + | Session Cookie Path + |-------------------------------------------------------------------------- + | + | The session cookie path determines the path for which the cookie will + | be regarded as available. Typically, this will be the root path of + | your application, but you're free to change this when necessary. + | + */ + + 'path' => env('SESSION_PATH', '/'), + + /* + |-------------------------------------------------------------------------- + | Session Cookie Domain + |-------------------------------------------------------------------------- + | + | This value determines the domain and subdomains the session cookie is + | available to. By default, the cookie will be available to the root + | domain without subdomains. Typically, this shouldn't be changed. + | + */ + + 'domain' => env('SESSION_DOMAIN'), + + /* + |-------------------------------------------------------------------------- + | HTTPS Only Cookies + |-------------------------------------------------------------------------- + | + | By setting this option to true, session cookies will only be sent back + | to the server if the browser has a HTTPS connection. This will keep + | the cookie from being sent to you when it can't be done securely. + | + */ + + 'secure' => env('SESSION_SECURE_COOKIE'), + + /* + |-------------------------------------------------------------------------- + | HTTP Access Only + |-------------------------------------------------------------------------- + | + | Setting this value to true will prevent JavaScript from accessing the + | value of the cookie and the cookie will only be accessible through + | the HTTP protocol. It's unlikely you should disable this option. + | + */ + + 'http_only' => env('SESSION_HTTP_ONLY', true), + + /* + |-------------------------------------------------------------------------- + | Same-Site Cookies + |-------------------------------------------------------------------------- + | + | This option determines how your cookies behave when cross-site requests + | take place, and can be used to mitigate CSRF attacks. By default, we + | will set this value to "lax" to permit secure cross-site requests. + | + | See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value + | + | Supported: "lax", "strict", "none", null + | + */ + + 'same_site' => env('SESSION_SAME_SITE', 'lax'), + + /* + |-------------------------------------------------------------------------- + | Partitioned Cookies + |-------------------------------------------------------------------------- + | + | Setting this value to true will tie the cookie to the top-level site for + | a cross-site context. Partitioned cookies are accepted by the browser + | when flagged "secure" and the Same-Site attribute is set to "none". + | + */ + + 'partitioned' => env('SESSION_PARTITIONED_COOKIE', false), + + /* + |-------------------------------------------------------------------------- + | Session Serialization + |-------------------------------------------------------------------------- + | + | This value controls the serialization strategy for session data, which + | is JSON by default. Setting this to "php" allows the storage of PHP + | objects in the session but can make an application vulnerable to + | "gadget chain" serialization attacks if the APP_KEY is leaked. + | + | Supported: "json", "php" + | + */ + + 'serialization' => 'json', + +]; diff --git a/erp/database/.gitignore b/erp/database/.gitignore new file mode 100644 index 00000000000..9b19b93c9f1 --- /dev/null +++ b/erp/database/.gitignore @@ -0,0 +1 @@ +*.sqlite* diff --git a/erp/database/factories/Modules/Core/Models/TenantFactory.php b/erp/database/factories/Modules/Core/Models/TenantFactory.php new file mode 100644 index 00000000000..446fd77144e --- /dev/null +++ b/erp/database/factories/Modules/Core/Models/TenantFactory.php @@ -0,0 +1,20 @@ + $this->faker->company(), + 'slug' => $this->faker->unique()->slug(2), + 'is_active' => true, + ]; + } +} diff --git a/erp/database/factories/UserFactory.php b/erp/database/factories/UserFactory.php new file mode 100644 index 00000000000..c4ceb0745b7 --- /dev/null +++ b/erp/database/factories/UserFactory.php @@ -0,0 +1,45 @@ + + */ +class UserFactory extends Factory +{ + /** + * The current password being used by the factory. + */ + protected static ?string $password; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'name' => fake()->name(), + 'email' => fake()->unique()->safeEmail(), + 'email_verified_at' => now(), + 'password' => static::$password ??= Hash::make('password'), + 'remember_token' => Str::random(10), + ]; + } + + /** + * Indicate that the model's email address should be unverified. + */ + public function unverified(): static + { + return $this->state(fn (array $attributes) => [ + 'email_verified_at' => null, + ]); + } +} diff --git a/erp/database/migrations/0001_01_01_000000_create_users_table.php b/erp/database/migrations/0001_01_01_000000_create_users_table.php new file mode 100644 index 00000000000..05fb5d9ea95 --- /dev/null +++ b/erp/database/migrations/0001_01_01_000000_create_users_table.php @@ -0,0 +1,49 @@ +id(); + $table->string('name'); + $table->string('email')->unique(); + $table->timestamp('email_verified_at')->nullable(); + $table->string('password'); + $table->rememberToken(); + $table->timestamps(); + }); + + Schema::create('password_reset_tokens', function (Blueprint $table) { + $table->string('email')->primary(); + $table->string('token'); + $table->timestamp('created_at')->nullable(); + }); + + Schema::create('sessions', function (Blueprint $table) { + $table->string('id')->primary(); + $table->foreignId('user_id')->nullable()->index(); + $table->string('ip_address', 45)->nullable(); + $table->text('user_agent')->nullable(); + $table->longText('payload'); + $table->integer('last_activity')->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('users'); + Schema::dropIfExists('password_reset_tokens'); + Schema::dropIfExists('sessions'); + } +}; diff --git a/erp/database/migrations/0001_01_01_000001_create_cache_table.php b/erp/database/migrations/0001_01_01_000001_create_cache_table.php new file mode 100644 index 00000000000..06dc7a5ee78 --- /dev/null +++ b/erp/database/migrations/0001_01_01_000001_create_cache_table.php @@ -0,0 +1,35 @@ +string('key')->primary(); + $table->mediumText('value'); + $table->bigInteger('expiration')->index(); + }); + + Schema::create('cache_locks', function (Blueprint $table) { + $table->string('key')->primary(); + $table->string('owner'); + $table->bigInteger('expiration')->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('cache'); + Schema::dropIfExists('cache_locks'); + } +}; diff --git a/erp/database/migrations/0001_01_01_000002_create_jobs_table.php b/erp/database/migrations/0001_01_01_000002_create_jobs_table.php new file mode 100644 index 00000000000..edac6fee599 --- /dev/null +++ b/erp/database/migrations/0001_01_01_000002_create_jobs_table.php @@ -0,0 +1,59 @@ +id(); + $table->string('queue')->index(); + $table->longText('payload'); + $table->unsignedSmallInteger('attempts'); + $table->unsignedInteger('reserved_at')->nullable(); + $table->unsignedInteger('available_at'); + $table->unsignedInteger('created_at'); + }); + + Schema::create('job_batches', function (Blueprint $table) { + $table->string('id')->primary(); + $table->string('name'); + $table->integer('total_jobs'); + $table->integer('pending_jobs'); + $table->integer('failed_jobs'); + $table->longText('failed_job_ids'); + $table->mediumText('options')->nullable(); + $table->integer('cancelled_at')->nullable(); + $table->integer('created_at'); + $table->integer('finished_at')->nullable(); + }); + + Schema::create('failed_jobs', function (Blueprint $table) { + $table->id(); + $table->string('uuid')->unique(); + $table->string('connection'); + $table->string('queue'); + $table->longText('payload'); + $table->longText('exception'); + $table->timestamp('failed_at')->useCurrent(); + + $table->index(['connection', 'queue', 'failed_at']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('jobs'); + Schema::dropIfExists('job_batches'); + Schema::dropIfExists('failed_jobs'); + } +}; diff --git a/erp/database/migrations/2026_05_24_000001_create_tenants_table.php b/erp/database/migrations/2026_05_24_000001_create_tenants_table.php new file mode 100644 index 00000000000..a5e4400467d --- /dev/null +++ b/erp/database/migrations/2026_05_24_000001_create_tenants_table.php @@ -0,0 +1,27 @@ +id(); + $table->string('name'); + $table->string('slug')->unique(); + $table->string('domain')->unique()->nullable(); + $table->json('settings')->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('tenants'); + } +}; diff --git a/erp/database/migrations/2026_05_24_000002_add_tenant_to_users_table.php b/erp/database/migrations/2026_05_24_000002_add_tenant_to_users_table.php new file mode 100644 index 00000000000..3deabaed80c --- /dev/null +++ b/erp/database/migrations/2026_05_24_000002_add_tenant_to_users_table.php @@ -0,0 +1,29 @@ +foreignId('tenant_id') + ->nullable() + ->after('id') + ->constrained() + ->nullOnDelete(); + $table->string('avatar')->nullable()->after('email'); + $table->timestamp('last_login_at')->nullable()->after('remember_token'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropConstrainedForeignId('tenant_id'); + $table->dropColumn(['avatar', 'last_login_at']); + }); + } +}; diff --git a/erp/database/migrations/2026_05_24_000003_create_audit_logs_table.php b/erp/database/migrations/2026_05_24_000003_create_audit_logs_table.php new file mode 100644 index 00000000000..bd425ec15ea --- /dev/null +++ b/erp/database/migrations/2026_05_24_000003_create_audit_logs_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('tenant_id')->nullable()->constrained()->nullOnDelete(); + $table->string('event', 32); + $table->string('auditable_type'); + $table->unsignedBigInteger('auditable_id'); + $table->json('old_values')->nullable(); + $table->json('new_values')->nullable(); + $table->ipAddress('ip_address')->nullable(); + $table->string('user_agent')->nullable(); + $table->timestamp('created_at')->useCurrent(); + + $table->index(['auditable_type', 'auditable_id']); + $table->index(['user_id', 'created_at']); + $table->index('tenant_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('audit_logs'); + } +}; diff --git a/erp/database/migrations/2026_05_24_120902_create_permission_tables.php b/erp/database/migrations/2026_05_24_120902_create_permission_tables.php new file mode 100644 index 00000000000..89862751975 --- /dev/null +++ b/erp/database/migrations/2026_05_24_120902_create_permission_tables.php @@ -0,0 +1,137 @@ +id(); // permission id + $table->string('name'); + $table->string('guard_name'); + $table->timestamps(); + + $table->unique(['name', 'guard_name']); + }); + + /** + * See `docs/prerequisites.md` for suggested lengths on 'name' and 'guard_name' if "1071 Specified key was too long" errors are encountered. + */ + Schema::create($tableNames['roles'], static function (Blueprint $table) use ($teams, $columnNames) { + $table->id(); // role id + if ($teams || config('permission.testing')) { // permission.testing is a fix for sqlite testing + $table->unsignedBigInteger($columnNames['team_foreign_key'])->nullable(); + $table->index($columnNames['team_foreign_key'], 'roles_team_foreign_key_index'); + } + $table->string('name'); + $table->string('guard_name'); + $table->timestamps(); + if ($teams || config('permission.testing')) { + $table->unique([$columnNames['team_foreign_key'], 'name', 'guard_name']); + } else { + $table->unique(['name', 'guard_name']); + } + }); + + Schema::create($tableNames['model_has_permissions'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotPermission, $teams) { + $table->unsignedBigInteger($pivotPermission); + + $table->string('model_type'); + $table->unsignedBigInteger($columnNames['model_morph_key']); + $table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_permissions_model_id_model_type_index'); + + $table->foreign($pivotPermission) + ->references('id') // permission id + ->on($tableNames['permissions']) + ->cascadeOnDelete(); + if ($teams) { + $table->unsignedBigInteger($columnNames['team_foreign_key']); + $table->index($columnNames['team_foreign_key'], 'model_has_permissions_team_foreign_key_index'); + + $table->primary([$columnNames['team_foreign_key'], $pivotPermission, $columnNames['model_morph_key'], 'model_type'], + 'model_has_permissions_permission_model_type_primary'); + } else { + $table->primary([$pivotPermission, $columnNames['model_morph_key'], 'model_type'], + 'model_has_permissions_permission_model_type_primary'); + } + }); + + Schema::create($tableNames['model_has_roles'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotRole, $teams) { + $table->unsignedBigInteger($pivotRole); + + $table->string('model_type'); + $table->unsignedBigInteger($columnNames['model_morph_key']); + $table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_roles_model_id_model_type_index'); + + $table->foreign($pivotRole) + ->references('id') // role id + ->on($tableNames['roles']) + ->cascadeOnDelete(); + if ($teams) { + $table->unsignedBigInteger($columnNames['team_foreign_key']); + $table->index($columnNames['team_foreign_key'], 'model_has_roles_team_foreign_key_index'); + + $table->primary([$columnNames['team_foreign_key'], $pivotRole, $columnNames['model_morph_key'], 'model_type'], + 'model_has_roles_role_model_type_primary'); + } else { + $table->primary([$pivotRole, $columnNames['model_morph_key'], 'model_type'], + 'model_has_roles_role_model_type_primary'); + } + }); + + Schema::create($tableNames['role_has_permissions'], static function (Blueprint $table) use ($tableNames, $pivotRole, $pivotPermission) { + $table->unsignedBigInteger($pivotPermission); + $table->unsignedBigInteger($pivotRole); + + $table->foreign($pivotPermission) + ->references('id') // permission id + ->on($tableNames['permissions']) + ->cascadeOnDelete(); + + $table->foreign($pivotRole) + ->references('id') // role id + ->on($tableNames['roles']) + ->cascadeOnDelete(); + + $table->primary([$pivotPermission, $pivotRole], 'role_has_permissions_permission_id_role_id_primary'); + }); + + app('cache') + ->store(config('permission.cache.store') != 'default' ? config('permission.cache.store') : null) + ->forget(config('permission.cache.key')); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + $tableNames = config('permission.table_names'); + + throw_if(empty($tableNames), 'Error: config/permission.php not found and defaults could not be merged. Please publish the package configuration before proceeding, or drop the tables manually.'); + + Schema::dropIfExists($tableNames['role_has_permissions']); + Schema::dropIfExists($tableNames['model_has_roles']); + Schema::dropIfExists($tableNames['model_has_permissions']); + Schema::dropIfExists($tableNames['roles']); + Schema::dropIfExists($tableNames['permissions']); + } +}; diff --git a/erp/database/migrations/2026_05_25_000001_create_categories_table.php b/erp/database/migrations/2026_05_25_000001_create_categories_table.php new file mode 100644 index 00000000000..c85ea316a6b --- /dev/null +++ b/erp/database/migrations/2026_05_25_000001_create_categories_table.php @@ -0,0 +1,25 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->foreignId('parent_id')->nullable()->constrained('categories')->nullOnDelete(); + $table->string('name'); + $table->string('slug'); + $table->text('description')->nullable(); + $table->timestamps(); + + $table->unique(['tenant_id', 'slug']); + }); + } + + public function down(): void { Schema::dropIfExists('categories'); } +}; diff --git a/erp/database/migrations/2026_05_25_000002_create_units_of_measure_table.php b/erp/database/migrations/2026_05_25_000002_create_units_of_measure_table.php new file mode 100644 index 00000000000..d0c3337cf45 --- /dev/null +++ b/erp/database/migrations/2026_05_25_000002_create_units_of_measure_table.php @@ -0,0 +1,23 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('abbreviation', 20); + $table->timestamps(); + + $table->unique(['tenant_id', 'abbreviation']); + }); + } + + public function down(): void { Schema::dropIfExists('units_of_measure'); } +}; diff --git a/erp/database/migrations/2026_05_25_000003_create_suppliers_table.php b/erp/database/migrations/2026_05_25_000003_create_suppliers_table.php new file mode 100644 index 00000000000..174e8e97ea1 --- /dev/null +++ b/erp/database/migrations/2026_05_25_000003_create_suppliers_table.php @@ -0,0 +1,26 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('contact_person')->nullable(); + $table->string('email')->nullable(); + $table->string('phone')->nullable(); + $table->text('address')->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void { Schema::dropIfExists('suppliers'); } +}; diff --git a/erp/database/migrations/2026_05_25_000004_create_warehouses_table.php b/erp/database/migrations/2026_05_25_000004_create_warehouses_table.php new file mode 100644 index 00000000000..cb8a5920e46 --- /dev/null +++ b/erp/database/migrations/2026_05_25_000004_create_warehouses_table.php @@ -0,0 +1,23 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('location')->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void { Schema::dropIfExists('warehouses'); } +}; diff --git a/erp/database/migrations/2026_05_25_000005_create_products_table.php b/erp/database/migrations/2026_05_25_000005_create_products_table.php new file mode 100644 index 00000000000..6384b28e093 --- /dev/null +++ b/erp/database/migrations/2026_05_25_000005_create_products_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->string('sku'); + $table->string('name'); + $table->text('description')->nullable(); + $table->foreignId('category_id')->nullable()->constrained('categories')->nullOnDelete(); + $table->foreignId('uom_id')->nullable()->constrained('units_of_measure')->nullOnDelete(); + $table->decimal('cost_price', 12, 2)->default(0); + $table->decimal('sale_price', 12, 2)->default(0); + $table->integer('reorder_point')->default(0); + $table->boolean('is_active')->default(true); + $table->timestamps(); + $table->softDeletes(); + + $table->unique(['tenant_id', 'sku']); + }); + } + + public function down(): void { Schema::dropIfExists('products'); } +}; diff --git a/erp/database/migrations/2026_05_25_000006_create_stock_levels_table.php b/erp/database/migrations/2026_05_25_000006_create_stock_levels_table.php new file mode 100644 index 00000000000..d5549def4bb --- /dev/null +++ b/erp/database/migrations/2026_05_25_000006_create_stock_levels_table.php @@ -0,0 +1,25 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->foreignId('product_id')->constrained()->cascadeOnDelete(); + $table->foreignId('warehouse_id')->constrained()->cascadeOnDelete(); + $table->decimal('quantity', 12, 2)->default(0); + $table->decimal('reserved_quantity', 12, 2)->default(0); + $table->timestamps(); + + $table->unique(['product_id', 'warehouse_id']); + }); + } + + public function down(): void { Schema::dropIfExists('stock_levels'); } +}; diff --git a/erp/database/migrations/2026_05_25_000007_create_stock_movements_table.php b/erp/database/migrations/2026_05_25_000007_create_stock_movements_table.php new file mode 100644 index 00000000000..77331f9e72d --- /dev/null +++ b/erp/database/migrations/2026_05_25_000007_create_stock_movements_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->foreignId('product_id')->constrained()->cascadeOnDelete(); + $table->foreignId('warehouse_id')->constrained()->cascadeOnDelete(); + $table->enum('type', ['in', 'out', 'transfer', 'adjustment']); + $table->decimal('quantity', 12, 2); + $table->string('reference')->nullable(); + $table->text('notes')->nullable(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('created_at')->useCurrent(); + + $table->index(['product_id', 'created_at']); + $table->index(['warehouse_id', 'created_at']); + $table->index('tenant_id'); + }); + } + + public function down(): void { Schema::dropIfExists('stock_movements'); } +}; diff --git a/erp/database/migrations/2026_05_25_000008_create_purchase_orders_table.php b/erp/database/migrations/2026_05_25_000008_create_purchase_orders_table.php new file mode 100644 index 00000000000..dab53c6c312 --- /dev/null +++ b/erp/database/migrations/2026_05_25_000008_create_purchase_orders_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->foreignId('supplier_id')->constrained()->cascadeOnDelete(); + $table->foreignId('warehouse_id')->constrained()->cascadeOnDelete(); + $table->enum('status', ['draft', 'submitted', 'approved', 'received', 'cancelled']) + ->default('draft'); + $table->date('expected_date')->nullable(); + $table->text('notes')->nullable(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['tenant_id', 'status']); + }); + } + + public function down(): void { Schema::dropIfExists('purchase_orders'); } +}; diff --git a/erp/database/migrations/2026_05_25_000009_create_purchase_order_items_table.php b/erp/database/migrations/2026_05_25_000009_create_purchase_order_items_table.php new file mode 100644 index 00000000000..ee3147814ed --- /dev/null +++ b/erp/database/migrations/2026_05_25_000009_create_purchase_order_items_table.php @@ -0,0 +1,23 @@ +id(); + $table->foreignId('purchase_order_id')->constrained()->cascadeOnDelete(); + $table->foreignId('product_id')->constrained()->cascadeOnDelete(); + $table->decimal('quantity', 12, 2); + $table->decimal('unit_cost', 12, 2); + $table->decimal('received_quantity', 12, 2)->default(0); + $table->timestamps(); + }); + } + + public function down(): void { Schema::dropIfExists('purchase_order_items'); } +}; diff --git a/erp/database/migrations/2026_05_26_000001_create_accounts_table.php b/erp/database/migrations/2026_05_26_000001_create_accounts_table.php new file mode 100644 index 00000000000..20005348e8a --- /dev/null +++ b/erp/database/migrations/2026_05_26_000001_create_accounts_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->foreignId('parent_id')->nullable()->constrained('accounts')->nullOnDelete(); + $table->string('code', 20); + $table->string('name'); + $table->enum('type', ['asset', 'liability', 'equity', 'income', 'expense']); + $table->text('description')->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + $table->softDeletes(); + + $table->unique(['tenant_id', 'code']); + }); + } + + public function down(): void + { + Schema::dropIfExists('accounts'); + } +}; diff --git a/erp/database/migrations/2026_05_26_000002_create_journal_entries_table.php b/erp/database/migrations/2026_05_26_000002_create_journal_entries_table.php new file mode 100644 index 00000000000..4d631acdd44 --- /dev/null +++ b/erp/database/migrations/2026_05_26_000002_create_journal_entries_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->date('date'); + $table->string('reference')->nullable(); + $table->string('description'); + $table->enum('status', ['draft', 'posted'])->default('draft'); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('journal_entries'); + } +}; diff --git a/erp/database/migrations/2026_05_26_000003_create_journal_lines_table.php b/erp/database/migrations/2026_05_26_000003_create_journal_lines_table.php new file mode 100644 index 00000000000..a73048e2b7a --- /dev/null +++ b/erp/database/migrations/2026_05_26_000003_create_journal_lines_table.php @@ -0,0 +1,26 @@ +id(); + $table->foreignId('journal_entry_id')->constrained()->cascadeOnDelete(); + $table->foreignId('account_id')->constrained()->restrictOnDelete(); + $table->decimal('debit', 15, 2)->default(0); + $table->decimal('credit', 15, 2)->default(0); + $table->string('description')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('journal_lines'); + } +}; diff --git a/erp/database/migrations/2026_05_26_000004_create_contacts_table.php b/erp/database/migrations/2026_05_26_000004_create_contacts_table.php new file mode 100644 index 00000000000..51e3fb7afed --- /dev/null +++ b/erp/database/migrations/2026_05_26_000004_create_contacts_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('email')->nullable(); + $table->string('phone')->nullable(); + $table->text('address')->nullable(); + $table->enum('type', ['customer', 'vendor', 'both'])->default('customer'); + $table->text('notes')->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('contacts'); + } +}; diff --git a/erp/database/migrations/2026_05_26_000005_create_invoices_table.php b/erp/database/migrations/2026_05_26_000005_create_invoices_table.php new file mode 100644 index 00000000000..0465169303d --- /dev/null +++ b/erp/database/migrations/2026_05_26_000005_create_invoices_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->foreignId('contact_id')->nullable()->constrained()->nullOnDelete(); + $table->string('number', 50)->nullable(); + $table->date('issue_date'); + $table->date('due_date')->nullable(); + $table->enum('status', ['draft', 'sent', 'paid', 'cancelled'])->default('draft'); + $table->text('notes')->nullable(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->softDeletes(); + + $table->unique(['tenant_id', 'number']); + }); + } + + public function down(): void + { + Schema::dropIfExists('invoices'); + } +}; diff --git a/erp/database/migrations/2026_05_26_000006_create_invoice_items_table.php b/erp/database/migrations/2026_05_26_000006_create_invoice_items_table.php new file mode 100644 index 00000000000..6bcd340fdf3 --- /dev/null +++ b/erp/database/migrations/2026_05_26_000006_create_invoice_items_table.php @@ -0,0 +1,26 @@ +id(); + $table->foreignId('invoice_id')->constrained()->cascadeOnDelete(); + $table->string('description'); + $table->decimal('quantity', 12, 2)->default(1); + $table->decimal('unit_price', 15, 2); + $table->decimal('tax_rate', 5, 2)->default(0); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('invoice_items'); + } +}; diff --git a/erp/database/migrations/2026_05_26_000007_create_payments_table.php b/erp/database/migrations/2026_05_26_000007_create_payments_table.php new file mode 100644 index 00000000000..947d16d10d5 --- /dev/null +++ b/erp/database/migrations/2026_05_26_000007_create_payments_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->foreignId('invoice_id')->constrained()->cascadeOnDelete(); + $table->decimal('amount', 15, 2); + $table->date('payment_date'); + $table->enum('method', ['cash', 'bank_transfer', 'cheque', 'card', 'other'])->default('bank_transfer'); + $table->string('reference')->nullable(); + $table->text('notes')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('payments'); + } +}; diff --git a/erp/database/migrations/2026_05_27_000001_create_departments_table.php b/erp/database/migrations/2026_05_27_000001_create_departments_table.php new file mode 100644 index 00000000000..4fc69100c3b --- /dev/null +++ b/erp/database/migrations/2026_05_27_000001_create_departments_table.php @@ -0,0 +1,26 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->text('description')->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('departments'); + } +}; diff --git a/erp/database/migrations/2026_05_27_000002_create_employees_table.php b/erp/database/migrations/2026_05_27_000002_create_employees_table.php new file mode 100644 index 00000000000..13712c52173 --- /dev/null +++ b/erp/database/migrations/2026_05_27_000002_create_employees_table.php @@ -0,0 +1,39 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('department_id')->nullable()->constrained()->nullOnDelete(); + $table->string('employee_number', 30)->nullable(); + $table->string('first_name'); + $table->string('last_name'); + $table->string('email')->nullable(); + $table->string('phone')->nullable(); + $table->string('position')->nullable(); + $table->enum('employment_type', ['full_time', 'part_time', 'contract'])->default('full_time'); + $table->enum('status', ['active', 'on_leave', 'terminated'])->default('active'); + $table->date('start_date'); + $table->date('end_date')->nullable(); + $table->enum('salary_type', ['hourly', 'monthly'])->default('monthly'); + $table->decimal('salary_amount', 12, 2)->default(0); + $table->timestamps(); + $table->softDeletes(); + + $table->unique(['tenant_id', 'employee_number']); + }); + } + + public function down(): void + { + Schema::dropIfExists('employees'); + } +}; diff --git a/erp/database/migrations/2026_05_27_000003_create_leave_types_table.php b/erp/database/migrations/2026_05_27_000003_create_leave_types_table.php new file mode 100644 index 00000000000..f566df5486c --- /dev/null +++ b/erp/database/migrations/2026_05_27_000003_create_leave_types_table.php @@ -0,0 +1,26 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->integer('days_per_year')->default(0); + $table->boolean('is_paid')->default(true); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('leave_types'); + } +}; diff --git a/erp/database/migrations/2026_05_27_000004_create_leave_requests_table.php b/erp/database/migrations/2026_05_27_000004_create_leave_requests_table.php new file mode 100644 index 00000000000..32b0b29e3b3 --- /dev/null +++ b/erp/database/migrations/2026_05_27_000004_create_leave_requests_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->foreignId('employee_id')->constrained()->cascadeOnDelete(); + $table->foreignId('leave_type_id')->constrained()->restrictOnDelete(); + $table->date('start_date'); + $table->date('end_date'); + $table->unsignedSmallInteger('days'); + $table->enum('status', ['pending', 'approved', 'rejected'])->default('pending'); + $table->text('notes')->nullable(); + $table->foreignId('reviewed_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('reviewed_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('leave_requests'); + } +}; diff --git a/erp/database/migrations/2026_05_27_000005_create_payroll_runs_table.php b/erp/database/migrations/2026_05_27_000005_create_payroll_runs_table.php new file mode 100644 index 00000000000..38ac601204c --- /dev/null +++ b/erp/database/migrations/2026_05_27_000005_create_payroll_runs_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->date('period_start'); + $table->date('period_end'); + $table->enum('status', ['draft', 'processed'])->default('draft'); + $table->text('notes')->nullable(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('payroll_runs'); + } +}; diff --git a/erp/database/migrations/2026_05_27_000006_create_payroll_items_table.php b/erp/database/migrations/2026_05_27_000006_create_payroll_items_table.php new file mode 100644 index 00000000000..bf821c6050d --- /dev/null +++ b/erp/database/migrations/2026_05_27_000006_create_payroll_items_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignId('payroll_run_id')->constrained()->cascadeOnDelete(); + $table->foreignId('employee_id')->constrained()->restrictOnDelete(); + $table->decimal('gross_salary', 12, 2); + $table->decimal('deductions', 12, 2)->default(0); + $table->decimal('net_salary', 12, 2); + $table->text('notes')->nullable(); + $table->timestamps(); + + $table->unique(['payroll_run_id', 'employee_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('payroll_items'); + } +}; diff --git a/erp/database/migrations/2026_05_29_000001_create_notifications_table.php b/erp/database/migrations/2026_05_29_000001_create_notifications_table.php new file mode 100644 index 00000000000..52e3b00595f --- /dev/null +++ b/erp/database/migrations/2026_05_29_000001_create_notifications_table.php @@ -0,0 +1,25 @@ +uuid('id')->primary(); + $table->string('type'); + $table->morphs('notifiable'); + $table->text('data'); + $table->timestamp('read_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('notifications'); + } +}; diff --git a/erp/database/migrations/2026_05_29_000002_create_tenant_settings_table.php b/erp/database/migrations/2026_05_29_000002_create_tenant_settings_table.php new file mode 100644 index 00000000000..4d57c8e007f --- /dev/null +++ b/erp/database/migrations/2026_05_29_000002_create_tenant_settings_table.php @@ -0,0 +1,26 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->string('key', 64); + $table->text('value')->nullable(); + $table->timestamps(); + + $table->unique(['tenant_id', 'key']); + }); + } + + public function down(): void + { + Schema::dropIfExists('tenant_settings'); + } +}; diff --git a/erp/database/migrations/2026_05_29_000003_create_bills_table.php b/erp/database/migrations/2026_05_29_000003_create_bills_table.php new file mode 100644 index 00000000000..8c80f68f55f --- /dev/null +++ b/erp/database/migrations/2026_05_29_000003_create_bills_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->foreignId('contact_id')->nullable()->constrained()->nullOnDelete(); + $table->string('number', 50)->nullable(); + $table->date('issue_date'); + $table->date('due_date')->nullable(); + $table->enum('status', ['draft', 'received', 'paid', 'cancelled'])->default('draft'); + $table->text('notes')->nullable(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->softDeletes(); + $table->unique(['tenant_id', 'number']); + }); + } + + public function down(): void + { + Schema::dropIfExists('bills'); + } +}; diff --git a/erp/database/migrations/2026_05_29_000004_create_bill_items_table.php b/erp/database/migrations/2026_05_29_000004_create_bill_items_table.php new file mode 100644 index 00000000000..5133d63131c --- /dev/null +++ b/erp/database/migrations/2026_05_29_000004_create_bill_items_table.php @@ -0,0 +1,26 @@ +id(); + $table->foreignId('bill_id')->constrained()->cascadeOnDelete(); + $table->string('description'); + $table->decimal('quantity', 12, 2); + $table->decimal('unit_price', 15, 2); + $table->decimal('tax_rate', 5, 2)->default(0); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('bill_items'); + } +}; diff --git a/erp/database/migrations/2026_05_29_000005_create_bill_payments_table.php b/erp/database/migrations/2026_05_29_000005_create_bill_payments_table.php new file mode 100644 index 00000000000..63766de6568 --- /dev/null +++ b/erp/database/migrations/2026_05_29_000005_create_bill_payments_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->foreignId('bill_id')->constrained()->cascadeOnDelete(); + $table->decimal('amount', 15, 2); + $table->date('payment_date'); + $table->enum('method', ['cash', 'bank_transfer', 'cheque', 'card', 'other']); + $table->string('reference', 100)->nullable(); + $table->text('notes')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('bill_payments'); + } +}; diff --git a/erp/database/migrations/2026_05_31_000006_create_quotes_table.php b/erp/database/migrations/2026_05_31_000006_create_quotes_table.php new file mode 100644 index 00000000000..91731cad9b5 --- /dev/null +++ b/erp/database/migrations/2026_05_31_000006_create_quotes_table.php @@ -0,0 +1,33 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->unsignedBigInteger('contact_id')->nullable(); + $table->string('number')->nullable(); + $table->date('issue_date'); + $table->date('expiry_date')->nullable(); + $table->enum('status', ['draft', 'sent', 'accepted', 'declined', 'cancelled'])->default('draft'); + $table->text('notes')->nullable(); + $table->unsignedBigInteger('created_by')->nullable(); + $table->timestamps(); + $table->softDeletes(); + $table->foreign('tenant_id')->references('id')->on('tenants')->cascadeOnDelete(); + $table->foreign('contact_id')->references('id')->on('contacts')->nullOnDelete(); + $table->unique(['tenant_id', 'number']); + }); + } + + public function down(): void + { + Schema::dropIfExists('quotes'); + } +}; diff --git a/erp/database/migrations/2026_05_31_000007_create_quote_items_table.php b/erp/database/migrations/2026_05_31_000007_create_quote_items_table.php new file mode 100644 index 00000000000..cefda213a7c --- /dev/null +++ b/erp/database/migrations/2026_05_31_000007_create_quote_items_table.php @@ -0,0 +1,27 @@ +id(); + $table->unsignedBigInteger('quote_id'); + $table->text('description'); + $table->decimal('quantity', 12, 2)->default(1); + $table->decimal('unit_price', 15, 2); + $table->decimal('tax_rate', 5, 2)->default(0); + $table->timestamps(); + $table->foreign('quote_id')->references('id')->on('quotes')->cascadeOnDelete(); + }); + } + + public function down(): void + { + Schema::dropIfExists('quote_items'); + } +}; diff --git a/erp/database/migrations/2026_05_31_000008_create_credit_notes_table.php b/erp/database/migrations/2026_05_31_000008_create_credit_notes_table.php new file mode 100644 index 00000000000..445ca97f3c4 --- /dev/null +++ b/erp/database/migrations/2026_05_31_000008_create_credit_notes_table.php @@ -0,0 +1,42 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->string('reference'); + $table->unsignedBigInteger('contact_id')->nullable(); + $table->unsignedBigInteger('original_invoice_id')->nullable(); + $table->unsignedBigInteger('original_bill_id')->nullable(); + $table->enum('type', ['sale', 'purchase'])->default('sale'); + $table->enum('status', ['draft', 'issued', 'applied', 'void'])->default('draft'); + $table->date('issue_date'); + $table->string('currency_code', 3)->default('USD'); + $table->decimal('exchange_rate', 15, 6)->default(1); + $table->decimal('subtotal', 15, 2)->default(0); + $table->decimal('tax_total', 15, 2)->default(0); + $table->decimal('total', 15, 2)->default(0); + $table->decimal('amount_applied', 15, 2)->default(0); + $table->text('notes')->nullable(); + $table->timestamps(); + $table->softDeletes(); + $table->foreign('tenant_id')->references('id')->on('tenants')->cascadeOnDelete(); + $table->foreign('contact_id')->references('id')->on('contacts')->nullOnDelete(); + $table->foreign('original_invoice_id')->references('id')->on('invoices')->nullOnDelete(); + $table->foreign('original_bill_id')->references('id')->on('bills')->nullOnDelete(); + $table->unique('reference'); + }); + } + + public function down(): void + { + Schema::dropIfExists('credit_notes'); + } +}; diff --git a/erp/database/migrations/2026_05_31_000009_create_credit_note_items_table.php b/erp/database/migrations/2026_05_31_000009_create_credit_note_items_table.php new file mode 100644 index 00000000000..9c4681102f2 --- /dev/null +++ b/erp/database/migrations/2026_05_31_000009_create_credit_note_items_table.php @@ -0,0 +1,28 @@ +id(); + $table->unsignedBigInteger('credit_note_id'); + $table->string('description'); + $table->decimal('quantity', 10, 2)->default(1); + $table->decimal('unit_price', 15, 2)->default(0); + $table->decimal('tax_rate', 5, 2)->default(0); + $table->decimal('line_total', 15, 2)->default(0); + $table->timestamps(); + $table->foreign('credit_note_id')->references('id')->on('credit_notes')->cascadeOnDelete(); + }); + } + + public function down(): void + { + Schema::dropIfExists('credit_note_items'); + } +}; diff --git a/erp/database/migrations/2026_05_31_000010_create_recurring_invoices_table.php b/erp/database/migrations/2026_05_31_000010_create_recurring_invoices_table.php new file mode 100644 index 00000000000..fe301beab6a --- /dev/null +++ b/erp/database/migrations/2026_05_31_000010_create_recurring_invoices_table.php @@ -0,0 +1,37 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->unsignedBigInteger('contact_id')->nullable(); + $table->enum('frequency', ['weekly', 'monthly', 'quarterly', 'yearly'])->default('monthly'); + $table->date('start_date'); + $table->date('next_run_date'); + $table->date('end_date')->nullable(); + $table->unsignedInteger('due_days')->default(30); + $table->enum('status', ['active', 'paused', 'ended'])->default('active'); + $table->boolean('auto_send')->default(false); + $table->text('notes')->nullable(); + $table->timestamp('last_generated_at')->nullable(); + $table->unsignedInteger('generated_count')->default(0); + $table->unsignedBigInteger('created_by')->nullable(); + $table->timestamps(); + $table->softDeletes(); + $table->foreign('tenant_id')->references('id')->on('tenants')->cascadeOnDelete(); + $table->foreign('contact_id')->references('id')->on('contacts')->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::dropIfExists('recurring_invoices'); + } +}; diff --git a/erp/database/migrations/2026_05_31_000011_create_recurring_invoice_items_table.php b/erp/database/migrations/2026_05_31_000011_create_recurring_invoice_items_table.php new file mode 100644 index 00000000000..b2e76c62ec7 --- /dev/null +++ b/erp/database/migrations/2026_05_31_000011_create_recurring_invoice_items_table.php @@ -0,0 +1,27 @@ +id(); + $table->unsignedBigInteger('recurring_invoice_id'); + $table->text('description'); + $table->decimal('quantity', 12, 2)->default(1); + $table->decimal('unit_price', 15, 2); + $table->decimal('tax_rate', 5, 2)->default(0); + $table->timestamps(); + $table->foreign('recurring_invoice_id')->references('id')->on('recurring_invoices')->cascadeOnDelete(); + }); + } + + public function down(): void + { + Schema::dropIfExists('recurring_invoice_items'); + } +}; diff --git a/erp/database/migrations/2026_05_31_000012_create_sales_orders_table.php b/erp/database/migrations/2026_05_31_000012_create_sales_orders_table.php new file mode 100644 index 00000000000..e7a3e27f29b --- /dev/null +++ b/erp/database/migrations/2026_05_31_000012_create_sales_orders_table.php @@ -0,0 +1,37 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->unsignedBigInteger('contact_id')->nullable(); + $table->unsignedBigInteger('warehouse_id')->nullable(); + $table->unsignedBigInteger('invoice_id')->nullable(); + $table->string('number')->nullable(); + $table->date('order_date'); + $table->date('expected_date')->nullable(); + $table->enum('status', ['draft', 'confirmed', 'fulfilled', 'cancelled'])->default('draft'); + $table->text('notes')->nullable(); + $table->unsignedBigInteger('created_by')->nullable(); + $table->timestamps(); + $table->softDeletes(); + $table->foreign('tenant_id')->references('id')->on('tenants')->cascadeOnDelete(); + $table->foreign('contact_id')->references('id')->on('contacts')->nullOnDelete(); + $table->foreign('warehouse_id')->references('id')->on('warehouses')->nullOnDelete(); + $table->foreign('invoice_id')->references('id')->on('invoices')->nullOnDelete(); + $table->unique(['tenant_id', 'number']); + }); + } + + public function down(): void + { + Schema::dropIfExists('sales_orders'); + } +}; diff --git a/erp/database/migrations/2026_05_31_000013_create_sales_order_items_table.php b/erp/database/migrations/2026_05_31_000013_create_sales_order_items_table.php new file mode 100644 index 00000000000..f17f53aaf00 --- /dev/null +++ b/erp/database/migrations/2026_05_31_000013_create_sales_order_items_table.php @@ -0,0 +1,30 @@ +id(); + $table->unsignedBigInteger('sales_order_id'); + $table->unsignedBigInteger('product_id')->nullable(); + $table->text('description'); + $table->decimal('quantity', 12, 2)->default(1); + $table->decimal('unit_price', 15, 2); + $table->decimal('tax_rate', 5, 2)->default(0); + $table->decimal('quantity_fulfilled', 12, 2)->default(0); + $table->timestamps(); + $table->foreign('sales_order_id')->references('id')->on('sales_orders')->cascadeOnDelete(); + $table->foreign('product_id')->references('id')->on('products')->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::dropIfExists('sales_order_items'); + } +}; diff --git a/erp/database/migrations/2026_05_31_000014_update_payroll_runs_add_computed_columns.php b/erp/database/migrations/2026_05_31_000014_update_payroll_runs_add_computed_columns.php new file mode 100644 index 00000000000..48977437920 --- /dev/null +++ b/erp/database/migrations/2026_05_31_000014_update_payroll_runs_add_computed_columns.php @@ -0,0 +1,30 @@ +string('period_label')->nullable()->after('tenant_id'); + $table->decimal('total_gross', 14, 2)->default(0)->after('status'); + $table->decimal('total_deductions', 14, 2)->default(0)->after('total_gross'); + $table->decimal('total_net', 14, 2)->default(0)->after('total_deductions'); + $table->integer('employee_count')->default(0)->after('total_net'); + $table->timestamp('processed_at')->nullable()->after('employee_count'); + }); + } + + public function down(): void + { + Schema::table('payroll_runs', function (Blueprint $table) { + $table->dropColumn([ + 'period_label', 'total_gross', 'total_deductions', + 'total_net', 'employee_count', 'processed_at', + ]); + }); + } +}; diff --git a/erp/database/migrations/2026_05_31_000015_create_exchange_rates_table.php b/erp/database/migrations/2026_05_31_000015_create_exchange_rates_table.php new file mode 100644 index 00000000000..3057d119669 --- /dev/null +++ b/erp/database/migrations/2026_05_31_000015_create_exchange_rates_table.php @@ -0,0 +1,26 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->string('currency_code', 3); + $table->decimal('rate', 14, 6); + $table->date('date'); + $table->timestamps(); + $table->unique(['tenant_id', 'currency_code', 'date']); + }); + } + + public function down(): void + { + Schema::dropIfExists('exchange_rates'); + } +}; diff --git a/erp/database/migrations/2026_05_31_000016_add_currency_to_invoices_table.php b/erp/database/migrations/2026_05_31_000016_add_currency_to_invoices_table.php new file mode 100644 index 00000000000..b11f40a71d0 --- /dev/null +++ b/erp/database/migrations/2026_05_31_000016_add_currency_to_invoices_table.php @@ -0,0 +1,23 @@ +string('currency_code', 3)->default('USD')->after('status'); + $table->decimal('exchange_rate', 14, 6)->default(1.000000)->after('currency_code'); + }); + } + + public function down(): void + { + Schema::table('invoices', function (Blueprint $table) { + $table->dropColumn(['currency_code', 'exchange_rate']); + }); + } +}; diff --git a/erp/database/migrations/2026_05_31_000017_add_currency_to_bills_table.php b/erp/database/migrations/2026_05_31_000017_add_currency_to_bills_table.php new file mode 100644 index 00000000000..f61ee040131 --- /dev/null +++ b/erp/database/migrations/2026_05_31_000017_add_currency_to_bills_table.php @@ -0,0 +1,23 @@ +string('currency_code', 3)->default('USD')->after('status'); + $table->decimal('exchange_rate', 14, 6)->default(1.000000)->after('currency_code'); + }); + } + + public function down(): void + { + Schema::table('bills', function (Blueprint $table) { + $table->dropColumn(['currency_code', 'exchange_rate']); + }); + } +}; diff --git a/erp/database/migrations/2026_05_31_000018_add_currency_to_quotes_table.php b/erp/database/migrations/2026_05_31_000018_add_currency_to_quotes_table.php new file mode 100644 index 00000000000..039f48011d3 --- /dev/null +++ b/erp/database/migrations/2026_05_31_000018_add_currency_to_quotes_table.php @@ -0,0 +1,23 @@ +string('currency_code', 3)->default('USD')->after('status'); + $table->decimal('exchange_rate', 14, 6)->default(1.000000)->after('currency_code'); + }); + } + + public function down(): void + { + Schema::table('quotes', function (Blueprint $table) { + $table->dropColumn(['currency_code', 'exchange_rate']); + }); + } +}; diff --git a/erp/database/migrations/2026_05_31_000019_create_bank_accounts_table.php b/erp/database/migrations/2026_05_31_000019_create_bank_accounts_table.php new file mode 100644 index 00000000000..ce7fdaa60ae --- /dev/null +++ b/erp/database/migrations/2026_05_31_000019_create_bank_accounts_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('account_number')->nullable(); + $table->string('bank_name')->nullable(); + $table->string('currency_code', 3)->default('USD'); + $table->decimal('opening_balance', 14, 2)->default(0); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('bank_accounts'); + } +}; diff --git a/erp/database/migrations/2026_05_31_000020_create_bank_transactions_table.php b/erp/database/migrations/2026_05_31_000020_create_bank_transactions_table.php new file mode 100644 index 00000000000..1ef7b72721c --- /dev/null +++ b/erp/database/migrations/2026_05_31_000020_create_bank_transactions_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->foreignId('bank_account_id')->constrained()->cascadeOnDelete(); + $table->date('transaction_date'); + $table->string('description')->nullable(); + $table->decimal('amount', 14, 2); + $table->string('reference')->nullable(); + $table->boolean('reconciled')->default(false); + $table->foreignId('payment_id')->nullable()->constrained('payments')->nullOnDelete(); + $table->foreignId('journal_entry_id')->nullable()->constrained('journal_entries')->nullOnDelete(); + $table->timestamp('imported_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('bank_transactions'); + } +}; diff --git a/erp/database/migrations/2026_06_01_064257_add_is_active_to_users_table.php b/erp/database/migrations/2026_06_01_064257_add_is_active_to_users_table.php new file mode 100644 index 00000000000..81f9a24b3d5 --- /dev/null +++ b/erp/database/migrations/2026_06_01_064257_add_is_active_to_users_table.php @@ -0,0 +1,28 @@ +boolean('is_active')->default(true)->after('tenant_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('is_active'); + }); + } +}; diff --git a/erp/database/migrations/2026_06_01_065114_add_settings_columns_to_tenants_table.php b/erp/database/migrations/2026_06_01_065114_add_settings_columns_to_tenants_table.php new file mode 100644 index 00000000000..f684b07d1a9 --- /dev/null +++ b/erp/database/migrations/2026_06_01_065114_add_settings_columns_to_tenants_table.php @@ -0,0 +1,39 @@ +string('email')->nullable()->after('slug'); + $table->string('phone')->nullable()->after('email'); + $table->text('address')->nullable()->after('phone'); + $table->string('city')->nullable()->after('address'); + $table->string('country')->nullable()->after('city'); + $table->string('currency_code', 3)->default('USD')->after('country'); + $table->string('timezone')->default('UTC')->after('currency_code'); + $table->string('date_format')->default('Y-m-d')->after('timezone'); + $table->string('logo_path')->nullable()->after('date_format'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('tenants', function (Blueprint $table) { + $table->dropColumn([ + 'email', 'phone', 'address', 'city', 'country', + 'currency_code', 'timezone', 'date_format', 'logo_path', + ]); + }); + } +}; diff --git a/erp/database/migrations/2026_06_01_070706_create_warehouse_transfers_table.php b/erp/database/migrations/2026_06_01_070706_create_warehouse_transfers_table.php new file mode 100644 index 00000000000..60028d3a48a --- /dev/null +++ b/erp/database/migrations/2026_06_01_070706_create_warehouse_transfers_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->foreignId('product_id')->constrained()->cascadeOnDelete(); + $table->foreignId('from_warehouse_id')->constrained('warehouses')->cascadeOnDelete(); + $table->foreignId('to_warehouse_id')->constrained('warehouses')->cascadeOnDelete(); + $table->decimal('quantity', 12, 4); + $table->string('reference')->nullable(); + $table->text('notes')->nullable(); + $table->enum('status', ['pending', 'completed', 'cancelled'])->default('completed'); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('warehouse_transfers'); + } +}; diff --git a/erp/database/migrations/2026_06_01_074127_create_budget_lines_table.php b/erp/database/migrations/2026_06_01_074127_create_budget_lines_table.php new file mode 100644 index 00000000000..2650a0dc7bc --- /dev/null +++ b/erp/database/migrations/2026_06_01_074127_create_budget_lines_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignId('budget_id')->constrained()->cascadeOnDelete(); + $table->foreignId('account_id')->constrained()->cascadeOnDelete(); + $table->integer('period')->default(0); // 0=annual, 1-12=month, 1-4=quarter + $table->decimal('amount', 14, 2)->default(0); + $table->text('notes')->nullable(); + $table->timestamps(); + $table->unique(['budget_id', 'account_id', 'period']); + }); + } + + public function down(): void + { + Schema::dropIfExists('budget_lines'); + } +}; diff --git a/erp/database/migrations/2026_06_01_074127_create_budgets_table.php b/erp/database/migrations/2026_06_01_074127_create_budgets_table.php new file mode 100644 index 00000000000..30c8dea25a3 --- /dev/null +++ b/erp/database/migrations/2026_06_01_074127_create_budgets_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->integer('year'); + $table->enum('period_type', ['annual', 'monthly', 'quarterly'])->default('annual'); + $table->text('notes')->nullable(); + $table->enum('status', ['draft', 'active', 'archived'])->default('draft'); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('budgets'); + } +}; diff --git a/erp/database/migrations/2026_06_01_075041_create_depreciation_entries_table.php b/erp/database/migrations/2026_06_01_075041_create_depreciation_entries_table.php new file mode 100644 index 00000000000..f295405c557 --- /dev/null +++ b/erp/database/migrations/2026_06_01_075041_create_depreciation_entries_table.php @@ -0,0 +1,26 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->foreignId('fixed_asset_id')->constrained()->cascadeOnDelete(); + $table->foreignId('journal_entry_id')->nullable()->constrained()->nullOnDelete(); + $table->date('period_date'); + $table->decimal('amount', 14, 2); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('depreciation_entries'); + } +}; diff --git a/erp/database/migrations/2026_06_01_075041_create_fixed_assets_table.php b/erp/database/migrations/2026_06_01_075041_create_fixed_assets_table.php new file mode 100644 index 00000000000..c576bc37c3b --- /dev/null +++ b/erp/database/migrations/2026_06_01_075041_create_fixed_assets_table.php @@ -0,0 +1,38 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('code')->nullable(); + $table->string('category')->default('equipment'); + $table->text('description')->nullable(); + $table->date('purchase_date'); + $table->decimal('purchase_cost', 14, 2); + $table->decimal('salvage_value', 14, 2)->default(0); + $table->integer('useful_life_years')->default(5); + $table->decimal('accumulated_depreciation', 14, 2)->default(0); + $table->enum('status', ['active', 'disposed', 'fully_depreciated'])->default('active'); + $table->date('disposal_date')->nullable(); + $table->decimal('disposal_proceeds', 14, 2)->nullable(); + $table->foreignId('asset_account_id')->nullable()->constrained('accounts')->nullOnDelete(); + $table->foreignId('depreciation_account_id')->nullable()->constrained('accounts')->nullOnDelete(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('fixed_assets'); + } +}; diff --git a/erp/database/migrations/2026_06_01_080000_create_expense_claims_table.php b/erp/database/migrations/2026_06_01_080000_create_expense_claims_table.php new file mode 100644 index 00000000000..80d9e32d377 --- /dev/null +++ b/erp/database/migrations/2026_06_01_080000_create_expense_claims_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->foreignId('employee_id')->constrained()->cascadeOnDelete(); + $table->foreignId('submitted_by')->nullable()->constrained('users')->nullOnDelete(); + $table->string('title'); + $table->text('description')->nullable(); + $table->date('expense_date'); + $table->decimal('amount', 12, 2); + $table->string('currency_code', 3)->default('USD'); + $table->string('category')->default('other'); + $table->string('receipt_path')->nullable(); + $table->enum('status', ['draft', 'submitted', 'approved', 'rejected', 'reimbursed'])->default('draft'); + $table->foreignId('reviewed_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('reviewed_at')->nullable(); + $table->text('review_notes')->nullable(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('expense_claims'); + } +}; diff --git a/erp/database/migrations/2026_06_01_090001_create_product_categories_table.php b/erp/database/migrations/2026_06_01_090001_create_product_categories_table.php new file mode 100644 index 00000000000..48e25e85514 --- /dev/null +++ b/erp/database/migrations/2026_06_01_090001_create_product_categories_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('slug')->nullable(); + $table->text('description')->nullable(); + $table->string('colour', 7)->default('#6366f1'); + $table->timestamps(); + $table->softDeletes(); + $table->unique(['tenant_id', 'slug']); + }); + } + + public function down(): void + { + Schema::dropIfExists('product_categories'); + } +}; diff --git a/erp/database/migrations/2026_06_01_090002_add_product_category_id_to_products.php b/erp/database/migrations/2026_06_01_090002_add_product_category_id_to_products.php new file mode 100644 index 00000000000..1550f8822b5 --- /dev/null +++ b/erp/database/migrations/2026_06_01_090002_add_product_category_id_to_products.php @@ -0,0 +1,35 @@ +dropForeign(['category_id']); + $table->dropColumn('category_id'); + }); + + Schema::table('products', function (Blueprint $table) { + $table->foreignId('category_id')->nullable()->after('tenant_id') + ->constrained('product_categories')->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('products', function (Blueprint $table) { + $table->dropForeign(['category_id']); + $table->dropColumn('category_id'); + }); + + Schema::table('products', function (Blueprint $table) { + $table->foreignId('category_id')->nullable()->after('tenant_id') + ->constrained('categories')->nullOnDelete(); + }); + } +}; diff --git a/erp/database/migrations/2026_06_01_224027_add_reorder_fields_to_products.php b/erp/database/migrations/2026_06_01_224027_add_reorder_fields_to_products.php new file mode 100644 index 00000000000..66dfe0220f5 --- /dev/null +++ b/erp/database/migrations/2026_06_01_224027_add_reorder_fields_to_products.php @@ -0,0 +1,25 @@ +decimal('reorder_quantity', 12, 4)->default(0)->after('reorder_point'); + $table->foreignId('preferred_supplier_id')->nullable()->after('reorder_quantity') + ->constrained('suppliers')->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('products', function (Blueprint $table) { + $table->dropForeign(['preferred_supplier_id']); + $table->dropColumn(['reorder_quantity', 'preferred_supplier_id']); + }); + } +}; diff --git a/erp/database/migrations/2026_06_01_225416_create_price_lists_table.php b/erp/database/migrations/2026_06_01_225416_create_price_lists_table.php new file mode 100644 index 00000000000..0665af995a4 --- /dev/null +++ b/erp/database/migrations/2026_06_01_225416_create_price_lists_table.php @@ -0,0 +1,47 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->text('description')->nullable(); + $table->string('currency_code', 3)->default('USD'); + $table->decimal('discount_percent', 5, 2)->default(0); + $table->boolean('is_active')->default(true); + $table->timestamps(); + $table->softDeletes(); + }); + + Schema::create('price_list_items', function (Blueprint $table) { + $table->id(); + $table->foreignId('price_list_id')->constrained()->cascadeOnDelete(); + $table->foreignId('product_id')->constrained()->cascadeOnDelete(); + $table->decimal('unit_price', 14, 4); + $table->timestamps(); + $table->unique(['price_list_id', 'product_id']); + }); + + Schema::table('contacts', function (Blueprint $table) { + $table->foreignId('price_list_id')->nullable()->after('type') + ->constrained('price_lists')->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('contacts', function (Blueprint $table) { + $table->dropForeign(['price_list_id']); + $table->dropColumn('price_list_id'); + }); + Schema::dropIfExists('price_list_items'); + Schema::dropIfExists('price_lists'); + } +}; diff --git a/erp/database/migrations/2026_06_01_230001_create_projects_table.php b/erp/database/migrations/2026_06_01_230001_create_projects_table.php new file mode 100644 index 00000000000..e5b199dd2f6 --- /dev/null +++ b/erp/database/migrations/2026_06_01_230001_create_projects_table.php @@ -0,0 +1,33 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->string('name'); + $table->text('description')->nullable(); + $table->foreignId('contact_id')->nullable()->nullOnDelete()->constrained('contacts'); + $table->string('status', 20)->default('planning'); + $table->date('start_date')->nullable(); + $table->date('end_date')->nullable(); + $table->decimal('budget', 14, 2)->nullable(); + $table->string('billing_type', 20)->default('non_billable'); + $table->decimal('hourly_rate', 10, 2)->nullable(); + $table->softDeletes(); + $table->timestamps(); + $table->index(['tenant_id', 'status']); + }); + } + + public function down(): void + { + Schema::dropIfExists('projects'); + } +}; diff --git a/erp/database/migrations/2026_06_01_230001b_create_project_tasks_table.php b/erp/database/migrations/2026_06_01_230001b_create_project_tasks_table.php new file mode 100644 index 00000000000..74f9befec9c --- /dev/null +++ b/erp/database/migrations/2026_06_01_230001b_create_project_tasks_table.php @@ -0,0 +1,32 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->foreignId('project_id')->constrained()->cascadeOnDelete(); + $table->string('title'); + $table->text('description')->nullable(); + $table->unsignedBigInteger('assigned_to')->nullable(); + $table->string('status', 20)->default('todo'); + $table->string('priority', 10)->default('medium'); + $table->date('due_date')->nullable(); + $table->decimal('estimated_hours', 8, 2)->nullable(); + $table->decimal('actual_hours', 8, 2)->nullable(); + $table->timestamps(); + $table->index(['tenant_id', 'project_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('project_tasks'); + } +}; diff --git a/erp/database/migrations/2026_06_01_230002_create_project_time_entries_table.php b/erp/database/migrations/2026_06_01_230002_create_project_time_entries_table.php new file mode 100644 index 00000000000..80899b170e2 --- /dev/null +++ b/erp/database/migrations/2026_06_01_230002_create_project_time_entries_table.php @@ -0,0 +1,30 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->foreignId('project_id')->constrained()->cascadeOnDelete(); + $table->foreignId('task_id')->nullable()->nullOnDelete()->constrained('project_tasks'); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->text('description')->nullable(); + $table->decimal('hours', 8, 2)->default(0); + $table->date('entry_date'); + $table->boolean('is_billable')->default(true); + $table->timestamps(); + $table->index(['tenant_id', 'project_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('project_time_entries'); + } +}; diff --git a/erp/database/migrations/2026_06_01_240001_create_attachments_table.php b/erp/database/migrations/2026_06_01_240001_create_attachments_table.php new file mode 100644 index 00000000000..36675aaa54c --- /dev/null +++ b/erp/database/migrations/2026_06_01_240001_create_attachments_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete(); + $table->string('attachable_type'); + $table->unsignedBigInteger('attachable_id'); + $table->string('filename'); + $table->string('disk')->default('local'); + $table->string('path'); + $table->string('mime_type')->nullable(); + $table->unsignedBigInteger('size')->nullable(); + $table->foreignId('uploaded_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + + $table->index(['attachable_type', 'attachable_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('attachments'); + } +}; diff --git a/erp/database/migrations/2026_06_02_100001_add_phase34_fields_to_recurring_invoices.php b/erp/database/migrations/2026_06_02_100001_add_phase34_fields_to_recurring_invoices.php new file mode 100644 index 00000000000..d6cb48803c6 --- /dev/null +++ b/erp/database/migrations/2026_06_02_100001_add_phase34_fields_to_recurring_invoices.php @@ -0,0 +1,25 @@ +string('reference_prefix', 50)->default('REC-INV')->after('contact_id'); + $table->tinyInteger('interval')->unsigned()->default(1)->after('frequency'); + $table->string('currency_code', 3)->default('USD')->after('auto_send'); + $table->decimal('exchange_rate', 15, 6)->default(1)->after('currency_code'); + }); + } + + public function down(): void + { + Schema::table('recurring_invoices', function (Blueprint $table) { + $table->dropColumn(['reference_prefix', 'interval', 'currency_code', 'exchange_rate']); + }); + } +}; diff --git a/erp/database/migrations/2026_06_02_100002_add_recurring_invoice_id_to_invoices.php b/erp/database/migrations/2026_06_02_100002_add_recurring_invoice_id_to_invoices.php new file mode 100644 index 00000000000..411013c4dee --- /dev/null +++ b/erp/database/migrations/2026_06_02_100002_add_recurring_invoice_id_to_invoices.php @@ -0,0 +1,27 @@ +foreignId('recurring_invoice_id') + ->nullable() + ->after('tenant_id') + ->constrained('recurring_invoices') + ->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('invoices', function (Blueprint $table) { + $table->dropForeign(['recurring_invoice_id']); + $table->dropColumn('recurring_invoice_id'); + }); + } +}; diff --git a/erp/database/migrations/2026_06_02_200001_create_stock_adjustments_table.php b/erp/database/migrations/2026_06_02_200001_create_stock_adjustments_table.php new file mode 100644 index 00000000000..a6523851e44 --- /dev/null +++ b/erp/database/migrations/2026_06_02_200001_create_stock_adjustments_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete(); + $table->foreignId('warehouse_id')->constrained('warehouses')->cascadeOnDelete(); + $table->string('reference')->unique(); + $table->enum('reason', ['count', 'damage', 'theft', 'expiry', 'correction', 'other'])->default('count'); + $table->enum('status', ['draft', 'confirmed', 'cancelled'])->default('draft'); + $table->foreignId('adjusted_by')->nullable()->nullOnDelete()->constrained('users'); + $table->timestamp('confirmed_at')->nullable(); + $table->text('notes')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('stock_adjustments'); + } +}; diff --git a/erp/database/migrations/2026_06_02_200002_create_stock_adjustment_items_table.php b/erp/database/migrations/2026_06_02_200002_create_stock_adjustment_items_table.php new file mode 100644 index 00000000000..8ac0192bde4 --- /dev/null +++ b/erp/database/migrations/2026_06_02_200002_create_stock_adjustment_items_table.php @@ -0,0 +1,26 @@ +id(); + $table->foreignId('stock_adjustment_id')->constrained('stock_adjustments')->cascadeOnDelete(); + $table->foreignId('product_id')->constrained('products')->cascadeOnDelete(); + $table->decimal('expected_quantity', 10, 2)->default(0); + $table->decimal('actual_quantity', 10, 2)->default(0); + $table->decimal('difference', 10, 2)->default(0); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('stock_adjustment_items'); + } +}; diff --git a/erp/database/migrations/2026_06_03_100001_create_purchase_requisitions_table.php b/erp/database/migrations/2026_06_03_100001_create_purchase_requisitions_table.php new file mode 100644 index 00000000000..ae13425ea39 --- /dev/null +++ b/erp/database/migrations/2026_06_03_100001_create_purchase_requisitions_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete(); + $table->string('reference')->unique(); + $table->foreignId('requested_by')->nullable()->nullOnDelete()->constrained('users'); + $table->foreignId('approved_by')->nullable()->nullOnDelete()->constrained('users'); + $table->enum('status', ['draft', 'submitted', 'approved', 'rejected'])->default('draft'); + $table->date('needed_by')->nullable(); + $table->text('notes')->nullable(); + $table->text('rejection_reason')->nullable(); + $table->timestamp('approved_at')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('purchase_requisitions'); + } +}; diff --git a/erp/database/migrations/2026_06_03_100002_create_purchase_requisition_items_table.php b/erp/database/migrations/2026_06_03_100002_create_purchase_requisition_items_table.php new file mode 100644 index 00000000000..b553d163f74 --- /dev/null +++ b/erp/database/migrations/2026_06_03_100002_create_purchase_requisition_items_table.php @@ -0,0 +1,26 @@ +id(); + $table->foreignId('purchase_requisition_id')->constrained('purchase_requisitions')->cascadeOnDelete(); + $table->foreignId('product_id')->nullable()->nullOnDelete()->constrained('products'); + $table->string('description'); + $table->decimal('quantity', 10, 2)->default(1); + $table->decimal('estimated_unit_cost', 15, 2)->default(0); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('purchase_requisition_items'); + } +}; diff --git a/erp/database/migrations/2026_06_03_200001_create_batch_payments_table.php b/erp/database/migrations/2026_06_03_200001_create_batch_payments_table.php new file mode 100644 index 00000000000..2340abf16fc --- /dev/null +++ b/erp/database/migrations/2026_06_03_200001_create_batch_payments_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->string('reference')->unique(); + $table->date('payment_date'); + $table->enum('payment_method', ['bank_transfer', 'cheque', 'cash', 'card', 'other'])->default('bank_transfer'); + $table->enum('type', ['received', 'made'])->default('received'); + $table->decimal('total_amount', 15, 2)->default(0); + $table->text('notes')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('batch_payments'); + } +}; diff --git a/erp/database/migrations/2026_06_03_200002_add_batch_payment_id_to_payments.php b/erp/database/migrations/2026_06_03_200002_add_batch_payment_id_to_payments.php new file mode 100644 index 00000000000..81248453df1 --- /dev/null +++ b/erp/database/migrations/2026_06_03_200002_add_batch_payment_id_to_payments.php @@ -0,0 +1,27 @@ +foreignId('batch_payment_id') + ->nullable() + ->after('notes') + ->constrained('batch_payments') + ->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('payments', function (Blueprint $table) { + $table->dropForeignIdFor(\App\Modules\Finance\Models\BatchPayment::class, 'batch_payment_id'); + $table->dropColumn('batch_payment_id'); + }); + } +}; diff --git a/erp/database/migrations/2026_06_03_200003_add_partial_status_to_invoices_and_bills.php b/erp/database/migrations/2026_06_03_200003_add_partial_status_to_invoices_and_bills.php new file mode 100644 index 00000000000..2566db9bed4 --- /dev/null +++ b/erp/database/migrations/2026_06_03_200003_add_partial_status_to_invoices_and_bills.php @@ -0,0 +1,38 @@ +enum('status', ['draft', 'sent', 'partial', 'paid', 'cancelled']) + ->default('draft') + ->change(); + }); + + Schema::table('bills', function (Blueprint $table) { + $table->enum('status', ['draft', 'received', 'partial', 'paid', 'cancelled']) + ->default('draft') + ->change(); + }); + } + + public function down(): void + { + Schema::table('invoices', function (Blueprint $table) { + $table->enum('status', ['draft', 'sent', 'paid', 'cancelled']) + ->default('draft') + ->change(); + }); + + Schema::table('bills', function (Blueprint $table) { + $table->enum('status', ['draft', 'received', 'paid', 'cancelled']) + ->default('draft') + ->change(); + }); + } +}; diff --git a/erp/database/migrations/2026_06_04_100001_add_phase40_fields_to_sales_orders.php b/erp/database/migrations/2026_06_04_100001_add_phase40_fields_to_sales_orders.php new file mode 100644 index 00000000000..524cce95707 --- /dev/null +++ b/erp/database/migrations/2026_06_04_100001_add_phase40_fields_to_sales_orders.php @@ -0,0 +1,33 @@ +string('reference', 100)->nullable()->unique()->after('contact_id'); + $table->string('currency_code', 3)->default('USD')->after('notes'); + $table->decimal('exchange_rate', 15, 6)->default(1)->after('currency_code'); + }); + + Schema::table('sales_order_items', function (Blueprint $table) { + $table->decimal('line_total', 15, 2)->default(0)->after('tax_rate'); + }); + } + + public function down(): void + { + Schema::table('sales_orders', function (Blueprint $table) { + $table->dropUnique(['reference']); + $table->dropColumn(['reference', 'currency_code', 'exchange_rate']); + }); + + Schema::table('sales_order_items', function (Blueprint $table) { + $table->dropColumn('line_total'); + }); + } +}; diff --git a/erp/database/migrations/2026_06_04_100002_add_sales_order_id_to_invoices.php b/erp/database/migrations/2026_06_04_100002_add_sales_order_id_to_invoices.php new file mode 100644 index 00000000000..91935105272 --- /dev/null +++ b/erp/database/migrations/2026_06_04_100002_add_sales_order_id_to_invoices.php @@ -0,0 +1,24 @@ +unsignedBigInteger('sales_order_id')->nullable()->after('contact_id'); + $table->foreign('sales_order_id')->references('id')->on('sales_orders')->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('invoices', function (Blueprint $table) { + $table->dropForeign(['sales_order_id']); + $table->dropColumn('sales_order_id'); + }); + } +}; diff --git a/erp/database/migrations/2026_06_04_100003_add_invoiced_status_to_sales_orders.php b/erp/database/migrations/2026_06_04_100003_add_invoiced_status_to_sales_orders.php new file mode 100644 index 00000000000..fdf375a0ef6 --- /dev/null +++ b/erp/database/migrations/2026_06_04_100003_add_invoiced_status_to_sales_orders.php @@ -0,0 +1,24 @@ +enum('status', ['draft', 'confirmed', 'fulfilled', 'invoiced', 'cancelled']) + ->default('draft')->change(); + }); + } + + public function down(): void + { + Schema::table('sales_orders', function (Blueprint $table) { + $table->enum('status', ['draft', 'confirmed', 'fulfilled', 'cancelled']) + ->default('draft')->change(); + }); + } +}; diff --git a/erp/database/migrations/2026_06_04_200001_create_delivery_notes_table.php b/erp/database/migrations/2026_06_04_200001_create_delivery_notes_table.php new file mode 100644 index 00000000000..375441df0f9 --- /dev/null +++ b/erp/database/migrations/2026_06_04_200001_create_delivery_notes_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete(); + $table->foreignId('sales_order_id')->nullable()->constrained('sales_orders')->nullOnDelete(); + $table->foreignId('invoice_id')->nullable()->constrained('invoices')->nullOnDelete(); + $table->foreignId('contact_id')->nullable()->constrained('contacts')->nullOnDelete(); + $table->string('reference')->unique(); + $table->enum('status', ['draft', 'dispatched', 'delivered'])->default('draft'); + $table->date('dispatch_date')->nullable(); + $table->date('delivery_date')->nullable(); + $table->string('carrier')->nullable(); + $table->string('tracking_number')->nullable(); + $table->text('notes')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('delivery_notes'); + } +}; diff --git a/erp/database/migrations/2026_06_04_200002_create_delivery_note_items_table.php b/erp/database/migrations/2026_06_04_200002_create_delivery_note_items_table.php new file mode 100644 index 00000000000..221899183be --- /dev/null +++ b/erp/database/migrations/2026_06_04_200002_create_delivery_note_items_table.php @@ -0,0 +1,25 @@ +id(); + $table->foreignId('delivery_note_id')->constrained('delivery_notes')->cascadeOnDelete(); + $table->foreignId('product_id')->nullable()->constrained('products')->nullOnDelete(); + $table->string('description'); + $table->decimal('quantity', 10, 2)->default(1); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('delivery_note_items'); + } +}; diff --git a/erp/database/migrations/2026_06_05_100001_create_onboarding_templates_table.php b/erp/database/migrations/2026_06_05_100001_create_onboarding_templates_table.php new file mode 100644 index 00000000000..848b18ae90d --- /dev/null +++ b/erp/database/migrations/2026_06_05_100001_create_onboarding_templates_table.php @@ -0,0 +1,26 @@ +id(); + $table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete(); + $table->string('name'); + $table->text('description')->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('onboarding_templates'); + } +}; diff --git a/erp/database/migrations/2026_06_05_100002_create_onboarding_template_tasks_table.php b/erp/database/migrations/2026_06_05_100002_create_onboarding_template_tasks_table.php new file mode 100644 index 00000000000..f4313d242cb --- /dev/null +++ b/erp/database/migrations/2026_06_05_100002_create_onboarding_template_tasks_table.php @@ -0,0 +1,26 @@ +id(); + $table->foreignId('onboarding_template_id')->constrained('onboarding_templates')->cascadeOnDelete(); + $table->string('title'); + $table->text('description')->nullable(); + $table->tinyInteger('due_days')->unsigned()->default(0); + $table->tinyInteger('sort_order')->unsigned()->default(0); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('onboarding_template_tasks'); + } +}; diff --git a/erp/database/migrations/2026_06_05_100003_create_employee_onboardings_table.php b/erp/database/migrations/2026_06_05_100003_create_employee_onboardings_table.php new file mode 100644 index 00000000000..443c0b53530 --- /dev/null +++ b/erp/database/migrations/2026_06_05_100003_create_employee_onboardings_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete(); + $table->foreignId('employee_id')->constrained('employees')->cascadeOnDelete(); + $table->foreignId('template_id')->nullable()->constrained('onboarding_templates')->nullOnDelete(); + $table->string('title'); + $table->enum('status', ['in_progress', 'completed', 'cancelled'])->default('in_progress'); + $table->date('started_at'); + $table->date('completed_at')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('employee_onboardings'); + } +}; diff --git a/erp/database/migrations/2026_06_05_100004_create_employee_onboarding_tasks_table.php b/erp/database/migrations/2026_06_05_100004_create_employee_onboarding_tasks_table.php new file mode 100644 index 00000000000..22a1b694add --- /dev/null +++ b/erp/database/migrations/2026_06_05_100004_create_employee_onboarding_tasks_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('employee_onboarding_id')->constrained('employee_onboardings')->cascadeOnDelete(); + $table->string('title'); + $table->text('description')->nullable(); + $table->date('due_date')->nullable(); + $table->timestamp('completed_at')->nullable(); + $table->foreignId('completed_by')->nullable()->constrained('users')->nullOnDelete(); + $table->tinyInteger('sort_order')->unsigned()->default(0); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('employee_onboarding_tasks'); + } +}; diff --git a/erp/database/migrations/2026_06_06_100001_create_performance_reviews_table.php b/erp/database/migrations/2026_06_06_100001_create_performance_reviews_table.php new file mode 100644 index 00000000000..16dc918225f --- /dev/null +++ b/erp/database/migrations/2026_06_06_100001_create_performance_reviews_table.php @@ -0,0 +1,34 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->foreignId('employee_id')->constrained()->cascadeOnDelete(); + $table->unsignedBigInteger('reviewer_id')->nullable(); + $table->string('review_period'); + $table->date('review_date'); + $table->string('status', 20)->default('draft'); + $table->decimal('overall_rating', 3, 1)->nullable(); + $table->text('strengths')->nullable(); + $table->text('improvements')->nullable(); + $table->text('goals')->nullable(); + $table->text('reviewer_notes')->nullable(); + $table->softDeletes(); + $table->timestamps(); + $table->index(['tenant_id', 'employee_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('performance_reviews'); + } +}; diff --git a/erp/database/migrations/2026_06_06_100002_create_performance_review_goals_table.php b/erp/database/migrations/2026_06_06_100002_create_performance_review_goals_table.php new file mode 100644 index 00000000000..b4ac4e35c60 --- /dev/null +++ b/erp/database/migrations/2026_06_06_100002_create_performance_review_goals_table.php @@ -0,0 +1,30 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->foreignId('performance_review_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->text('description')->nullable(); + $table->decimal('target_score', 8, 2)->default(100); + $table->decimal('actual_score', 8, 2)->default(0); + $table->decimal('weight', 5, 2)->default(1); + $table->text('notes')->nullable(); + $table->timestamps(); + $table->index('tenant_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('performance_kpis'); + } +}; diff --git a/erp/database/migrations/2026_06_06_100003_create_performance_review_competencies_table.php b/erp/database/migrations/2026_06_06_100003_create_performance_review_competencies_table.php new file mode 100644 index 00000000000..bd1591859ab --- /dev/null +++ b/erp/database/migrations/2026_06_06_100003_create_performance_review_competencies_table.php @@ -0,0 +1,21 @@ +id(); + $table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete(); + $table->string('title'); + $table->string('provider')->nullable(); + $table->enum('type', ['internal', 'external', 'online', 'certification'])->default('internal'); + $table->decimal('duration_hours', 5, 1)->nullable(); + $table->text('description')->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('training_courses'); + } +}; diff --git a/erp/database/migrations/2026_06_07_100002_create_employee_training_records_table.php b/erp/database/migrations/2026_06_07_100002_create_employee_training_records_table.php new file mode 100644 index 00000000000..fede497773c --- /dev/null +++ b/erp/database/migrations/2026_06_07_100002_create_employee_training_records_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete(); + $table->foreignId('employee_id')->constrained('employees')->cascadeOnDelete(); + $table->foreignId('training_course_id')->nullable()->constrained('training_courses')->nullOnDelete(); + $table->string('course_title'); + $table->date('completed_date'); + $table->date('expiry_date')->nullable(); + $table->decimal('score', 5, 2)->nullable(); + $table->boolean('passed')->default(true); + $table->string('certificate_number')->nullable(); + $table->text('notes')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('employee_training_records'); + } +}; diff --git a/erp/database/migrations/2026_06_08_100001_create_job_positions_table.php b/erp/database/migrations/2026_06_08_100001_create_job_positions_table.php new file mode 100644 index 00000000000..67453ce7960 --- /dev/null +++ b/erp/database/migrations/2026_06_08_100001_create_job_positions_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete(); + $table->string('title'); + $table->foreignId('department_id')->nullable()->constrained('departments')->nullOnDelete(); + $table->string('location')->nullable(); + $table->enum('employment_type', ['full_time', 'part_time', 'contract', 'internship'])->default('full_time'); + $table->text('description')->nullable(); + $table->text('requirements')->nullable(); + $table->unsignedInteger('openings')->default(1); + $table->enum('status', ['draft', 'open', 'closed', 'on_hold'])->default('draft'); + $table->timestamp('posted_at')->nullable(); + $table->timestamp('closed_at')->nullable(); + $table->softDeletes(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('job_positions'); + } +}; diff --git a/erp/database/migrations/2026_06_08_100002_create_job_applications_table.php b/erp/database/migrations/2026_06_08_100002_create_job_applications_table.php new file mode 100644 index 00000000000..811591f15a2 --- /dev/null +++ b/erp/database/migrations/2026_06_08_100002_create_job_applications_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete(); + $table->foreignId('job_position_id')->constrained('job_positions')->cascadeOnDelete(); + $table->string('applicant_name'); + $table->string('applicant_email'); + $table->string('applicant_phone')->nullable(); + $table->string('resume_path')->nullable(); + $table->text('cover_letter')->nullable(); + $table->string('source')->nullable(); + $table->enum('stage', ['applied', 'screening', 'interview', 'offer', 'hired', 'rejected'])->default('applied'); + $table->text('notes')->nullable(); + $table->unsignedTinyInteger('rating')->nullable(); + $table->timestamp('rejected_at')->nullable(); + $table->timestamp('hired_at')->nullable(); + $table->softDeletes(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('job_applications'); + } +}; diff --git a/erp/database/migrations/2026_06_08_114618_create_personal_access_tokens_table.php b/erp/database/migrations/2026_06_08_114618_create_personal_access_tokens_table.php new file mode 100644 index 00000000000..40ff706ee7c --- /dev/null +++ b/erp/database/migrations/2026_06_08_114618_create_personal_access_tokens_table.php @@ -0,0 +1,33 @@ +id(); + $table->morphs('tokenable'); + $table->text('name'); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable()->index(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('personal_access_tokens'); + } +}; diff --git a/erp/database/migrations/2026_06_09_100001_create_attendance_records_table.php b/erp/database/migrations/2026_06_09_100001_create_attendance_records_table.php new file mode 100644 index 00000000000..d3b0ebbdd68 --- /dev/null +++ b/erp/database/migrations/2026_06_09_100001_create_attendance_records_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete(); + $table->foreignId('employee_id')->constrained('employees')->cascadeOnDelete(); + $table->date('work_date'); + $table->time('clock_in')->nullable(); + $table->time('clock_out')->nullable(); + $table->unsignedInteger('break_minutes')->default(0); + $table->enum('status', ['present', 'absent', 'half_day', 'holiday', 'leave'])->default('present'); + $table->text('notes')->nullable(); + $table->softDeletes(); + $table->timestamps(); + + $table->unique(['tenant_id', 'employee_id', 'work_date']); + }); + } + + public function down(): void + { + Schema::dropIfExists('attendance_records'); + } +}; diff --git a/erp/database/migrations/2026_06_09_100002_create_work_schedules_table.php b/erp/database/migrations/2026_06_09_100002_create_work_schedules_table.php new file mode 100644 index 00000000000..1edaf154296 --- /dev/null +++ b/erp/database/migrations/2026_06_09_100002_create_work_schedules_table.php @@ -0,0 +1,39 @@ +id(); + $table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete(); + $table->string('name'); + $table->time('monday_start')->nullable(); + $table->time('monday_end')->nullable(); + $table->time('tuesday_start')->nullable(); + $table->time('tuesday_end')->nullable(); + $table->time('wednesday_start')->nullable(); + $table->time('wednesday_end')->nullable(); + $table->time('thursday_start')->nullable(); + $table->time('thursday_end')->nullable(); + $table->time('friday_start')->nullable(); + $table->time('friday_end')->nullable(); + $table->time('saturday_start')->nullable(); + $table->time('saturday_end')->nullable(); + $table->time('sunday_start')->nullable(); + $table->time('sunday_end')->nullable(); + $table->boolean('is_default')->default(false); + $table->softDeletes(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('work_schedules'); + } +}; diff --git a/erp/database/migrations/2026_06_10_100001_create_assets_table.php b/erp/database/migrations/2026_06_10_100001_create_assets_table.php new file mode 100644 index 00000000000..8a02368a43b --- /dev/null +++ b/erp/database/migrations/2026_06_10_100001_create_assets_table.php @@ -0,0 +1,38 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->string('name'); + $table->string('asset_code', 100)->nullable(); + $table->string('category', 100)->nullable(); + $table->string('location')->nullable(); + $table->unsignedBigInteger('assigned_to_employee_id')->nullable(); + $table->date('purchase_date')->nullable(); + $table->decimal('purchase_cost', 12, 2)->nullable(); + $table->decimal('current_value', 12, 2)->nullable(); + $table->enum('status', ['active', 'inactive', 'disposed', 'under_maintenance'])->default('active'); + $table->string('serial_number', 100)->nullable(); + $table->text('notes')->nullable(); + $table->timestamp('disposed_at')->nullable(); + $table->softDeletes(); + $table->timestamps(); + + $table->foreign('tenant_id')->references('id')->on('tenants')->cascadeOnDelete(); + $table->foreign('assigned_to_employee_id')->references('id')->on('employees')->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::dropIfExists('assets'); + } +}; diff --git a/erp/database/migrations/2026_06_10_100002_create_asset_maintenances_table.php b/erp/database/migrations/2026_06_10_100002_create_asset_maintenances_table.php new file mode 100644 index 00000000000..1e07e557efc --- /dev/null +++ b/erp/database/migrations/2026_06_10_100002_create_asset_maintenances_table.php @@ -0,0 +1,34 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->unsignedBigInteger('asset_id'); + $table->date('scheduled_date'); + $table->date('completed_date')->nullable(); + $table->enum('type', ['routine', 'repair', 'inspection', 'calibration'])->default('routine'); + $table->text('description')->nullable(); + $table->decimal('cost', 10, 2)->nullable(); + $table->string('performed_by')->nullable(); + $table->enum('status', ['scheduled', 'completed', 'cancelled'])->default('scheduled'); + $table->softDeletes(); + $table->timestamps(); + + $table->foreign('tenant_id')->references('id')->on('tenants')->cascadeOnDelete(); + $table->foreign('asset_id')->references('id')->on('assets')->cascadeOnDelete(); + }); + } + + public function down(): void + { + Schema::dropIfExists('asset_maintenances'); + } +}; diff --git a/erp/database/migrations/2026_06_11_100001_create_vendor_profiles_table.php b/erp/database/migrations/2026_06_11_100001_create_vendor_profiles_table.php new file mode 100644 index 00000000000..ee2cc56be78 --- /dev/null +++ b/erp/database/migrations/2026_06_11_100001_create_vendor_profiles_table.php @@ -0,0 +1,30 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->foreignId('contact_id')->constrained('contacts')->cascadeOnDelete()->unique(); + $table->decimal('credit_limit', 12, 2)->nullable(); + $table->unsignedInteger('payment_terms_days')->default(30); + $table->string('preferred_currency', 3)->nullable(); + $table->string('bank_name')->nullable(); + $table->string('bank_account_number', 100)->nullable(); + $table->string('bank_routing_number', 100)->nullable(); + $table->text('notes')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('vendor_profiles'); + } +}; diff --git a/erp/database/migrations/2026_06_11_100002_create_vendor_evaluations_table.php b/erp/database/migrations/2026_06_11_100002_create_vendor_evaluations_table.php new file mode 100644 index 00000000000..19f5433f441 --- /dev/null +++ b/erp/database/migrations/2026_06_11_100002_create_vendor_evaluations_table.php @@ -0,0 +1,32 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->foreignId('contact_id')->constrained('contacts')->cascadeOnDelete(); + $table->foreignId('evaluated_by')->constrained('users'); + $table->date('evaluation_date'); + $table->unsignedTinyInteger('quality_rating'); + $table->unsignedTinyInteger('delivery_rating'); + $table->unsignedTinyInteger('price_rating'); + $table->unsignedTinyInteger('communication_rating'); + $table->decimal('overall_rating', 3, 2); + $table->text('comments')->nullable(); + $table->softDeletes(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('vendor_evaluations'); + } +}; diff --git a/erp/database/migrations/2026_06_12_100001_create_customer_portal_tokens_table.php b/erp/database/migrations/2026_06_12_100001_create_customer_portal_tokens_table.php new file mode 100644 index 00000000000..04d4eda7df1 --- /dev/null +++ b/erp/database/migrations/2026_06_12_100001_create_customer_portal_tokens_table.php @@ -0,0 +1,29 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->foreignId('contact_id')->constrained('contacts')->cascadeOnDelete(); + $table->string('token', 64)->unique(); + $table->string('email'); + $table->timestamp('expires_at')->nullable(); + $table->timestamp('last_accessed_at')->nullable(); + $table->timestamps(); + + $table->index('tenant_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('customer_portal_tokens'); + } +}; diff --git a/erp/database/migrations/2026_06_13_100001_update_budgets_table_add_fiscal_year.php b/erp/database/migrations/2026_06_13_100001_update_budgets_table_add_fiscal_year.php new file mode 100644 index 00000000000..8507b9ea3d5 --- /dev/null +++ b/erp/database/migrations/2026_06_13_100001_update_budgets_table_add_fiscal_year.php @@ -0,0 +1,89 @@ +unsignedSmallInteger('fiscal_year')->default(2025)->after('name'); + }); + + // Populate fiscal_year from year + DB::table('budgets')->update(['fiscal_year' => DB::raw('"year"')]); + + Schema::table('budgets', function (Blueprint $table) { + $table->unique(['tenant_id', 'name', 'fiscal_year'], 'budgets_tenant_name_fiscal_year_unique'); + }); + + // Update status column to support 'closed' in addition to 'archived' + // SQLite: drop and recreate the status column with updated check constraint + // Use raw SQL to modify the check constraint + DB::statement(" + CREATE TABLE budgets_new AS SELECT + id, tenant_id, name, fiscal_year, year, period_type, notes, + CASE WHEN status = 'archived' THEN 'closed' ELSE status END as status, + created_by, created_at, updated_at, deleted_at + FROM budgets + "); + DB::statement("DROP TABLE budgets"); + DB::statement(" + CREATE TABLE budgets ( + id integer NOT NULL PRIMARY KEY AUTOINCREMENT, + tenant_id integer NOT NULL, + name varchar(255) NOT NULL, + fiscal_year integer unsigned NOT NULL DEFAULT 2025, + year integer NOT NULL, + period_type varchar(255) CHECK(period_type IN ('annual','monthly','quarterly')) NOT NULL DEFAULT 'annual', + notes text, + status varchar(255) CHECK(status IN ('draft','active','closed')) NOT NULL DEFAULT 'draft', + created_by integer, + created_at datetime, + updated_at datetime, + deleted_at datetime, + FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE, + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL + ) + "); + DB::statement("INSERT INTO budgets SELECT id, tenant_id, name, fiscal_year, year, period_type, notes, status, created_by, created_at, updated_at, deleted_at FROM budgets_new"); + DB::statement("DROP TABLE budgets_new"); + + // Re-create indexes + DB::statement("CREATE UNIQUE INDEX budgets_tenant_name_fiscal_year_unique ON budgets (tenant_id, name, fiscal_year)"); + } + + public function down(): void + { + DB::statement(" + CREATE TABLE budgets_restore AS SELECT + id, tenant_id, name, year, period_type, notes, + CASE WHEN status = 'closed' THEN 'archived' ELSE status END as status, + created_by, created_at, updated_at, deleted_at + FROM budgets + "); + DB::statement("DROP TABLE budgets"); + DB::statement(" + CREATE TABLE budgets ( + id integer NOT NULL PRIMARY KEY AUTOINCREMENT, + tenant_id integer NOT NULL, + name varchar(255) NOT NULL, + year integer NOT NULL, + period_type varchar(255) CHECK(period_type IN ('annual','monthly','quarterly')) NOT NULL DEFAULT 'annual', + notes text, + status varchar(255) CHECK(status IN ('draft','active','archived')) NOT NULL DEFAULT 'draft', + created_by integer, + created_at datetime, + updated_at datetime, + deleted_at datetime, + FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE, + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL + ) + "); + DB::statement("INSERT INTO budgets SELECT id, tenant_id, name, year, period_type, notes, status, created_by, created_at, updated_at, deleted_at FROM budgets_restore"); + DB::statement("DROP TABLE budgets_restore"); + } +}; diff --git a/erp/database/migrations/2026_06_13_100002_update_budget_lines_table_add_tenant.php b/erp/database/migrations/2026_06_13_100002_update_budget_lines_table_add_tenant.php new file mode 100644 index 00000000000..788a2528897 --- /dev/null +++ b/erp/database/migrations/2026_06_13_100002_update_budget_lines_table_add_tenant.php @@ -0,0 +1,42 @@ +unsignedBigInteger('tenant_id')->nullable()->after('id'); + } + if (!Schema::hasColumn('budget_lines', 'deleted_at')) { + $table->softDeletes(); + } + }); + + // Populate tenant_id from related budget (SQLite compatible) + $lines = DB::table('budget_lines')->whereNull('tenant_id')->get(['id', 'budget_id']); + foreach ($lines as $line) { + $budget = DB::table('budgets')->where('id', $line->budget_id)->first(['tenant_id']); + if ($budget) { + DB::table('budget_lines')->where('id', $line->id)->update(['tenant_id' => $budget->tenant_id]); + } + } + } + + public function down(): void + { + Schema::table('budget_lines', function (Blueprint $table) { + if (Schema::hasColumn('budget_lines', 'deleted_at')) { + $table->dropSoftDeletes(); + } + if (Schema::hasColumn('budget_lines', 'tenant_id')) { + $table->dropColumn('tenant_id'); + } + }); + } +}; diff --git a/erp/database/migrations/2026_06_14_100001_create_exchange_rates_table.php b/erp/database/migrations/2026_06_14_100001_create_exchange_rates_table.php new file mode 100644 index 00000000000..fac8b4aa021 --- /dev/null +++ b/erp/database/migrations/2026_06_14_100001_create_exchange_rates_table.php @@ -0,0 +1,41 @@ +dropUnique(['tenant_id', 'currency_code', 'date']); + $table->dropColumn(['currency_code', 'date']); + }); + + Schema::table('exchange_rates', function (Blueprint $table) { + $table->string('base_currency', 3)->after('tenant_id'); + $table->string('quote_currency', 3)->after('base_currency'); + $table->date('effective_date')->after('rate'); + $table->string('source', 100)->nullable()->after('effective_date'); + $table->unique( + ['tenant_id', 'base_currency', 'quote_currency', 'effective_date'], + 'exchange_rates_unique_pair_date' + ); + }); + } + + public function down(): void + { + Schema::table('exchange_rates', function (Blueprint $table) { + $table->dropUnique('exchange_rates_unique_pair_date'); + $table->dropColumn(['base_currency', 'quote_currency', 'effective_date', 'source']); + }); + + Schema::table('exchange_rates', function (Blueprint $table) { + $table->string('currency_code', 3)->after('tenant_id'); + $table->date('date')->after('rate'); + $table->unique(['tenant_id', 'currency_code', 'date']); + }); + } +}; diff --git a/erp/database/migrations/2026_06_15_100001_make_audit_log_auditable_nullable.php b/erp/database/migrations/2026_06_15_100001_make_audit_log_auditable_nullable.php new file mode 100644 index 00000000000..b44eb956e91 --- /dev/null +++ b/erp/database/migrations/2026_06_15_100001_make_audit_log_auditable_nullable.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->unsignedBigInteger('tenant_id')->nullable(); + $table->string('event', 64); + $table->string('auditable_type')->nullable(); + $table->unsignedBigInteger('auditable_id')->nullable(); + $table->json('old_values')->nullable(); + $table->json('new_values')->nullable(); + $table->ipAddress('ip_address')->nullable(); + $table->string('user_agent')->nullable(); + $table->timestamp('created_at')->useCurrent(); + + $table->index(['auditable_type', 'auditable_id']); + $table->index(['user_id', 'created_at']); + $table->index('tenant_id'); + }); + } + + public function down(): void + { + // No rollback needed for test env + } +}; diff --git a/erp/database/migrations/2026_06_16_100001_create_notification_rules_table.php b/erp/database/migrations/2026_06_16_100001_create_notification_rules_table.php new file mode 100644 index 00000000000..35c95326781 --- /dev/null +++ b/erp/database/migrations/2026_06_16_100001_create_notification_rules_table.php @@ -0,0 +1,27 @@ +id(); + $table->unsignedBigInteger('tenant_id')->index(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('event_type', 100); + $table->json('conditions')->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('notification_rules'); + } +}; diff --git a/erp/database/migrations/2026_06_16_100002_create_notification_inbox_table.php b/erp/database/migrations/2026_06_16_100002_create_notification_inbox_table.php new file mode 100644 index 00000000000..d3a8856d681 --- /dev/null +++ b/erp/database/migrations/2026_06_16_100002_create_notification_inbox_table.php @@ -0,0 +1,29 @@ +id(); + $table->unsignedBigInteger('tenant_id')->index(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('title'); + $table->text('body')->nullable(); + $table->string('type'); + $table->string('link')->nullable(); + $table->boolean('is_read')->default(false); + $table->timestamp('read_at')->nullable(); + $table->timestamp('created_at'); + }); + } + + public function down(): void + { + Schema::dropIfExists('notification_inbox'); + } +}; diff --git a/erp/database/migrations/2026_06_17_100001_create_document_templates_table.php b/erp/database/migrations/2026_06_17_100001_create_document_templates_table.php new file mode 100644 index 00000000000..bfd12a33562 --- /dev/null +++ b/erp/database/migrations/2026_06_17_100001_create_document_templates_table.php @@ -0,0 +1,30 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->string('name'); + $table->enum('type', ['invoice', 'quote', 'letter', 'receipt', 'purchase_order'])->default('invoice'); + $table->string('subject')->nullable(); + $table->longText('body'); + $table->json('variables')->nullable(); + $table->boolean('is_default')->default(false); + $table->boolean('is_active')->default(true); + $table->softDeletes(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('document_templates'); + } +}; diff --git a/erp/database/migrations/2026_06_18_100001_create_product_bundle_items_table.php b/erp/database/migrations/2026_06_18_100001_create_product_bundle_items_table.php new file mode 100644 index 00000000000..9786b85779d --- /dev/null +++ b/erp/database/migrations/2026_06_18_100001_create_product_bundle_items_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->foreignId('bundle_product_id')->constrained('products')->cascadeOnDelete(); + $table->foreignId('component_product_id')->constrained('products')->cascadeOnDelete(); + $table->decimal('quantity', 10, 4); + $table->timestamps(); + + $table->unique(['bundle_product_id', 'component_product_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('product_bundle_items'); + } +}; diff --git a/erp/database/migrations/2026_06_18_100002_add_is_bundle_to_products.php b/erp/database/migrations/2026_06_18_100002_add_is_bundle_to_products.php new file mode 100644 index 00000000000..e541c4bf107 --- /dev/null +++ b/erp/database/migrations/2026_06_18_100002_add_is_bundle_to_products.php @@ -0,0 +1,23 @@ +boolean('is_bundle')->default(false)->after('is_active'); + $table->decimal('stock_quantity', 12, 4)->default(0)->after('is_bundle'); + }); + } + + public function down(): void + { + Schema::table('products', function (Blueprint $table) { + $table->dropColumn(['is_bundle', 'stock_quantity']); + }); + } +}; diff --git a/erp/database/migrations/2026_06_18_100003_make_product_sku_nullable.php b/erp/database/migrations/2026_06_18_100003_make_product_sku_nullable.php new file mode 100644 index 00000000000..cff037a8482 --- /dev/null +++ b/erp/database/migrations/2026_06_18_100003_make_product_sku_nullable.php @@ -0,0 +1,22 @@ +string('sku')->nullable()->change(); + }); + } + + public function down(): void + { + Schema::table('products', function (Blueprint $table) { + $table->string('sku')->nullable(false)->change(); + }); + } +}; diff --git a/erp/database/migrations/2026_06_19_000001_create_audit_logs_table.php b/erp/database/migrations/2026_06_19_000001_create_audit_logs_table.php new file mode 100644 index 00000000000..da4de8169ab --- /dev/null +++ b/erp/database/migrations/2026_06_19_000001_create_audit_logs_table.php @@ -0,0 +1,48 @@ +string('action')->nullable()->after('tenant_id'); + } + if (!Schema::hasColumn('audit_logs', 'auditable_label')) { + $table->string('auditable_label')->nullable()->after('auditable_id'); + } + if (!Schema::hasColumn('audit_logs', 'url')) { + $table->string('url')->nullable()->after('user_agent'); + } + if (!Schema::hasColumn('audit_logs', 'module')) { + $table->string('module')->nullable()->after('url'); + } + }); + } + + public function down(): void + { + if (!Schema::hasTable('audit_logs')) { + return; + } + + Schema::table('audit_logs', function (Blueprint $table) { + foreach (['action', 'auditable_label', 'url', 'module'] as $col) { + if (Schema::hasColumn('audit_logs', $col)) { + $table->dropColumn($col); + } + } + }); + } +}; diff --git a/erp/database/migrations/2026_06_19_000002_create_erp_notifications_table.php b/erp/database/migrations/2026_06_19_000002_create_erp_notifications_table.php new file mode 100644 index 00000000000..ed6fdb4e7c2 --- /dev/null +++ b/erp/database/migrations/2026_06_19_000002_create_erp_notifications_table.php @@ -0,0 +1,27 @@ +id(); + $table->unsignedBigInteger('tenant_id')->index(); + $table->unsignedBigInteger('user_id')->index(); + $table->string('type'); // invoice_created, low_stock, payroll_approved, etc. + $table->string('title'); + $table->text('message'); + $table->json('data')->nullable(); + $table->timestamp('read_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('erp_notifications'); + } +}; diff --git a/erp/database/migrations/2026_06_19_100001_create_warehouse_stock_table.php b/erp/database/migrations/2026_06_19_100001_create_warehouse_stock_table.php new file mode 100644 index 00000000000..31738b079f9 --- /dev/null +++ b/erp/database/migrations/2026_06_19_100001_create_warehouse_stock_table.php @@ -0,0 +1,28 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->foreignId('warehouse_id')->constrained('warehouses')->cascadeOnDelete(); + $table->foreignId('product_id')->constrained('products')->cascadeOnDelete(); + $table->decimal('quantity', 12, 4)->default(0); + $table->decimal('reorder_point', 12, 4)->nullable(); + $table->timestamps(); + + $table->unique(['warehouse_id', 'product_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('warehouse_stock'); + } +}; diff --git a/erp/database/migrations/2026_06_19_100002_create_stock_transfers_table.php b/erp/database/migrations/2026_06_19_100002_create_stock_transfers_table.php new file mode 100644 index 00000000000..92feab3fe93 --- /dev/null +++ b/erp/database/migrations/2026_06_19_100002_create_stock_transfers_table.php @@ -0,0 +1,29 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->string('reference')->nullable(); + $table->foreignId('from_warehouse_id')->constrained('warehouses'); + $table->foreignId('to_warehouse_id')->constrained('warehouses'); + $table->enum('status', ['draft', 'in_transit', 'completed', 'cancelled'])->default('draft'); + $table->text('notes')->nullable(); + $table->timestamp('transferred_at')->nullable(); + $table->softDeletes(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('stock_transfers'); + } +}; diff --git a/erp/database/migrations/2026_06_19_100003_create_stock_transfer_items_table.php b/erp/database/migrations/2026_06_19_100003_create_stock_transfer_items_table.php new file mode 100644 index 00000000000..1bab9cb76c4 --- /dev/null +++ b/erp/database/migrations/2026_06_19_100003_create_stock_transfer_items_table.php @@ -0,0 +1,25 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->foreignId('stock_transfer_id')->constrained('stock_transfers')->cascadeOnDelete(); + $table->foreignId('product_id')->constrained('products'); + $table->decimal('quantity', 12, 4); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('stock_transfer_items'); + } +}; diff --git a/erp/database/migrations/2026_06_20_100001_create_subscription_plans_table.php b/erp/database/migrations/2026_06_20_100001_create_subscription_plans_table.php new file mode 100644 index 00000000000..e5d84d95c81 --- /dev/null +++ b/erp/database/migrations/2026_06_20_100001_create_subscription_plans_table.php @@ -0,0 +1,30 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->string('name'); + $table->text('description')->nullable(); + $table->enum('billing_cycle', ['monthly', 'quarterly', 'annually'])->default('monthly'); + $table->decimal('price', 10, 2); + $table->string('currency_code', 3)->default('USD'); + $table->unsignedInteger('trial_days')->default(0); + $table->boolean('is_active')->default(true); + $table->softDeletes(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('subscription_plans'); + } +}; diff --git a/erp/database/migrations/2026_06_20_100002_create_subscriptions_table.php b/erp/database/migrations/2026_06_20_100002_create_subscriptions_table.php new file mode 100644 index 00000000000..00f5bb145d4 --- /dev/null +++ b/erp/database/migrations/2026_06_20_100002_create_subscriptions_table.php @@ -0,0 +1,33 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->foreignId('contact_id')->constrained('contacts')->cascadeOnDelete(); + $table->foreignId('subscription_plan_id')->constrained('subscription_plans')->cascadeOnDelete(); + $table->enum('status', ['trial', 'active', 'paused', 'cancelled', 'expired'])->default('trial'); + $table->date('started_at'); + $table->date('trial_ends_at')->nullable(); + $table->date('current_period_start')->nullable(); + $table->date('current_period_end')->nullable(); + $table->timestamp('cancelled_at')->nullable(); + $table->date('next_invoice_date')->nullable(); + $table->text('notes')->nullable(); + $table->softDeletes(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('subscriptions'); + } +}; diff --git a/erp/database/migrations/2026_06_21_100001_create_commission_rules_table.php b/erp/database/migrations/2026_06_21_100001_create_commission_rules_table.php new file mode 100644 index 00000000000..fb7e4a2aec3 --- /dev/null +++ b/erp/database/migrations/2026_06_21_100001_create_commission_rules_table.php @@ -0,0 +1,28 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->string('name'); + $table->decimal('rate', 5, 4)->default(0); + $table->enum('type', ['percentage', 'fixed'])->default('percentage'); + $table->decimal('fixed_amount', 10, 2)->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('commission_rules'); + } +}; diff --git a/erp/database/migrations/2026_06_21_100002_create_commissions_table.php b/erp/database/migrations/2026_06_21_100002_create_commissions_table.php new file mode 100644 index 00000000000..68188f88439 --- /dev/null +++ b/erp/database/migrations/2026_06_21_100002_create_commissions_table.php @@ -0,0 +1,32 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->foreignId('commission_rule_id')->constrained('commission_rules')->cascadeOnDelete(); + $table->foreignId('user_id')->constrained('users'); + $table->foreignId('invoice_id')->constrained('invoices')->cascadeOnDelete(); + $table->decimal('invoice_amount', 12, 2); + $table->decimal('commission_amount', 10, 2); + $table->enum('status', ['pending', 'approved', 'paid'])->default('pending'); + $table->timestamp('approved_at')->nullable(); + $table->timestamp('paid_at')->nullable(); + $table->text('notes')->nullable(); + $table->softDeletes(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('commissions'); + } +}; diff --git a/erp/database/migrations/2026_06_21_100003_add_assigned_to_user_id_to_invoices.php b/erp/database/migrations/2026_06_21_100003_add_assigned_to_user_id_to_invoices.php new file mode 100644 index 00000000000..55e04bdfa75 --- /dev/null +++ b/erp/database/migrations/2026_06_21_100003_add_assigned_to_user_id_to_invoices.php @@ -0,0 +1,23 @@ +foreignId('assigned_to_user_id')->nullable()->constrained('users')->nullOnDelete()->after('contact_id'); + }); + } + + public function down(): void + { + Schema::table('invoices', function (Blueprint $table) { + $table->dropForeignIdFor(\App\Models\User::class, 'assigned_to_user_id'); + $table->dropColumn('assigned_to_user_id'); + }); + } +}; diff --git a/erp/database/migrations/2026_06_22_100001_create_contracts_table.php b/erp/database/migrations/2026_06_22_100001_create_contracts_table.php new file mode 100644 index 00000000000..e579f3c3eba --- /dev/null +++ b/erp/database/migrations/2026_06_22_100001_create_contracts_table.php @@ -0,0 +1,37 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->foreignId('contact_id')->nullable()->constrained('contacts')->nullOnDelete(); + $table->string('title'); + $table->string('reference')->nullable(); + $table->enum('type', ['client', 'vendor', 'employment', 'nda', 'other'])->default('client'); + $table->enum('status', ['draft', 'active', 'expired', 'terminated'])->default('draft'); + $table->decimal('value', 14, 2)->nullable(); + $table->string('currency_code', 3)->nullable(); + $table->date('start_date')->nullable(); + $table->date('end_date')->nullable(); + $table->boolean('auto_renew')->default(false); + $table->unsignedInteger('renewal_notice_days')->default(30); + $table->text('description')->nullable(); + $table->text('terms')->nullable(); + $table->date('signed_at')->nullable(); + $table->softDeletes(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('contracts'); + } +}; diff --git a/erp/database/migrations/2026_06_23_100001_create_employee_loans_table.php b/erp/database/migrations/2026_06_23_100001_create_employee_loans_table.php new file mode 100644 index 00000000000..94112e8885a --- /dev/null +++ b/erp/database/migrations/2026_06_23_100001_create_employee_loans_table.php @@ -0,0 +1,35 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->foreignId('employee_id')->constrained('employees')->cascadeOnDelete(); + $table->enum('type', ['loan', 'advance'])->default('loan'); + $table->decimal('amount', 10, 2); + $table->decimal('outstanding_balance', 10, 2); + $table->decimal('interest_rate', 5, 2)->default(0); + $table->enum('status', ['pending', 'active', 'completed', 'cancelled'])->default('pending'); + $table->foreignId('approved_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('approved_at')->nullable(); + $table->timestamp('disbursed_at')->nullable(); + $table->string('purpose')->nullable(); + $table->text('notes')->nullable(); + $table->date('repayment_start_date')->nullable(); + $table->softDeletes(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('employee_loans'); + } +}; diff --git a/erp/database/migrations/2026_06_23_100002_create_loan_repayments_table.php b/erp/database/migrations/2026_06_23_100002_create_loan_repayments_table.php new file mode 100644 index 00000000000..687b5074ccd --- /dev/null +++ b/erp/database/migrations/2026_06_23_100002_create_loan_repayments_table.php @@ -0,0 +1,26 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->foreignId('employee_loan_id')->constrained('employee_loans')->cascadeOnDelete(); + $table->decimal('amount', 10, 2); + $table->date('payment_date'); + $table->text('notes')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('loan_repayments'); + } +}; diff --git a/erp/database/migrations/2026_06_24_100001_create_qc_checklists_table.php b/erp/database/migrations/2026_06_24_100001_create_qc_checklists_table.php new file mode 100644 index 00000000000..087947310e6 --- /dev/null +++ b/erp/database/migrations/2026_06_24_100001_create_qc_checklists_table.php @@ -0,0 +1,27 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->string('name'); + $table->foreignId('product_id')->nullable()->nullOnDelete()->constrained('products'); + $table->text('description')->nullable(); + $table->boolean('is_active')->default(true); + $table->softDeletes(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('qc_checklists'); + } +}; diff --git a/erp/database/migrations/2026_06_24_100002_create_qc_checklist_items_table.php b/erp/database/migrations/2026_06_24_100002_create_qc_checklist_items_table.php new file mode 100644 index 00000000000..11f2c6adbe4 --- /dev/null +++ b/erp/database/migrations/2026_06_24_100002_create_qc_checklist_items_table.php @@ -0,0 +1,27 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->foreignId('qc_checklist_id')->constrained('qc_checklists')->cascadeOnDelete(); + $table->string('name'); + $table->text('description')->nullable(); + $table->boolean('is_required')->default(true); + $table->unsignedInteger('sort_order')->default(0); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('qc_checklist_items'); + } +}; diff --git a/erp/database/migrations/2026_06_24_100003_create_qc_inspections_table.php b/erp/database/migrations/2026_06_24_100003_create_qc_inspections_table.php new file mode 100644 index 00000000000..3ac2870065b --- /dev/null +++ b/erp/database/migrations/2026_06_24_100003_create_qc_inspections_table.php @@ -0,0 +1,31 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->foreignId('qc_checklist_id')->constrained('qc_checklists')->cascadeOnDelete(); + $table->foreignId('product_id')->nullable()->nullOnDelete()->constrained('products'); + $table->foreignId('inspector_id')->nullable()->nullOnDelete()->constrained('users'); + $table->string('batch_reference')->nullable(); + $table->enum('status', ['pending', 'in_progress', 'passed', 'failed'])->default('pending'); + $table->enum('overall_result', ['pass', 'fail', 'conditional'])->nullable(); + $table->text('notes')->nullable(); + $table->timestamp('inspected_at')->nullable(); + $table->softDeletes(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('qc_inspections'); + } +}; diff --git a/erp/database/migrations/2026_06_24_100004_create_qc_inspection_results_table.php b/erp/database/migrations/2026_06_24_100004_create_qc_inspection_results_table.php new file mode 100644 index 00000000000..aad25144297 --- /dev/null +++ b/erp/database/migrations/2026_06_24_100004_create_qc_inspection_results_table.php @@ -0,0 +1,26 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->foreignId('qc_inspection_id')->constrained('qc_inspections')->cascadeOnDelete(); + $table->foreignId('qc_checklist_item_id')->constrained('qc_checklist_items')->cascadeOnDelete(); + $table->enum('result', ['pass', 'fail', 'na'])->default('na'); + $table->text('notes')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('qc_inspection_results'); + } +}; diff --git a/erp/database/migrations/2026_06_25_100001_create_return_requests_table.php b/erp/database/migrations/2026_06_25_100001_create_return_requests_table.php new file mode 100644 index 00000000000..66fe6a52ea5 --- /dev/null +++ b/erp/database/migrations/2026_06_25_100001_create_return_requests_table.php @@ -0,0 +1,33 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->foreignId('invoice_id')->nullable()->nullOnDelete()->constrained('invoices'); + $table->foreignId('contact_id')->nullable()->nullOnDelete()->constrained('contacts'); + $table->text('reason'); + $table->string('status', 20)->default('pending'); + $table->decimal('refund_amount', 10, 2)->default(0); + $table->text('notes')->nullable(); + $table->unsignedBigInteger('approved_by')->nullable(); + $table->timestamp('approved_at')->nullable(); + $table->timestamp('refunded_at')->nullable(); + $table->softDeletes(); + $table->timestamps(); + $table->index('tenant_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('return_requests'); + } +}; diff --git a/erp/database/migrations/2026_06_25_100002_create_return_request_items_table.php b/erp/database/migrations/2026_06_25_100002_create_return_request_items_table.php new file mode 100644 index 00000000000..eca00daf789 --- /dev/null +++ b/erp/database/migrations/2026_06_25_100002_create_return_request_items_table.php @@ -0,0 +1,29 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->foreignId('return_request_id')->constrained()->cascadeOnDelete(); + $table->foreignId('invoice_item_id')->nullable()->nullOnDelete()->constrained('invoice_items'); + $table->string('product_name'); + $table->unsignedInteger('quantity')->default(1); + $table->decimal('unit_price', 10, 2)->default(0); + $table->text('reason')->nullable(); + $table->timestamps(); + $table->index('tenant_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('return_request_items'); + } +}; diff --git a/erp/database/migrations/2026_06_26_100001_create_costing_layers_table.php b/erp/database/migrations/2026_06_26_100001_create_costing_layers_table.php new file mode 100644 index 00000000000..e091ba86006 --- /dev/null +++ b/erp/database/migrations/2026_06_26_100001_create_costing_layers_table.php @@ -0,0 +1,33 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->foreignId('product_id')->constrained()->cascadeOnDelete(); + $table->foreignId('warehouse_id')->nullable()->nullOnDelete()->constrained(); + $table->string('costing_method', 10)->default('fifo'); // fifo or avco + $table->decimal('quantity_received', 14, 4)->default(0); + $table->decimal('quantity_remaining', 14, 4)->default(0); + $table->decimal('unit_cost', 14, 4)->default(0); + $table->timestamp('received_at')->useCurrent(); + $table->string('reference_type', 50)->nullable(); + $table->unsignedBigInteger('reference_id')->nullable(); + $table->timestamps(); + $table->index(['tenant_id', 'product_id']); + $table->index('received_at'); + }); + } + + public function down(): void + { + Schema::dropIfExists('costing_layers'); + } +}; diff --git a/erp/database/migrations/2026_06_26_100002_create_product_cost_snapshots_table.php b/erp/database/migrations/2026_06_26_100002_create_product_cost_snapshots_table.php new file mode 100644 index 00000000000..c9463745c05 --- /dev/null +++ b/erp/database/migrations/2026_06_26_100002_create_product_cost_snapshots_table.php @@ -0,0 +1,30 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->foreignId('product_id')->constrained()->cascadeOnDelete(); + $table->string('costing_method', 10)->default('fifo'); + $table->decimal('average_cost', 14, 4)->default(0); + $table->decimal('fifo_cost', 14, 4)->default(0); + $table->date('snapshot_date'); + $table->decimal('total_quantity', 14, 4)->default(0); + $table->decimal('total_value', 14, 4)->default(0); + $table->timestamps(); + $table->index(['tenant_id', 'product_id', 'snapshot_date']); + }); + } + + public function down(): void + { + Schema::dropIfExists('product_cost_snapshots'); + } +}; diff --git a/erp/database/migrations/2026_06_27_100001_create_shift_templates_table.php b/erp/database/migrations/2026_06_27_100001_create_shift_templates_table.php new file mode 100644 index 00000000000..51b0c53040b --- /dev/null +++ b/erp/database/migrations/2026_06_27_100001_create_shift_templates_table.php @@ -0,0 +1,31 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->string('name'); + $table->time('start_time'); + $table->time('end_time'); + $table->unsignedInteger('break_minutes')->default(0); + $table->json('days_of_week')->nullable(); + $table->string('color', 20)->default('#6366f1'); + $table->boolean('is_active')->default(true); + $table->softDeletes(); + $table->timestamps(); + $table->index('tenant_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('shift_templates'); + } +}; diff --git a/erp/database/migrations/2026_06_27_100002_create_shift_assignments_table.php b/erp/database/migrations/2026_06_27_100002_create_shift_assignments_table.php new file mode 100644 index 00000000000..7ef52bbd31f --- /dev/null +++ b/erp/database/migrations/2026_06_27_100002_create_shift_assignments_table.php @@ -0,0 +1,29 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->foreignId('shift_template_id')->constrained()->cascadeOnDelete(); + $table->foreignId('employee_id')->constrained()->cascadeOnDelete(); + $table->date('assigned_date'); + $table->text('notes')->nullable(); + $table->string('status', 20)->default('scheduled'); + $table->timestamps(); + $table->index(['tenant_id', 'assigned_date']); + $table->index(['employee_id', 'assigned_date']); + }); + } + + public function down(): void + { + Schema::dropIfExists('shift_assignments'); + } +}; diff --git a/erp/database/migrations/2026_06_28_100001_create_expense_claims_table.php b/erp/database/migrations/2026_06_28_100001_create_expense_claims_table.php new file mode 100644 index 00000000000..365d09e1214 --- /dev/null +++ b/erp/database/migrations/2026_06_28_100001_create_expense_claims_table.php @@ -0,0 +1,39 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->foreignId('employee_id')->constrained()->cascadeOnDelete(); + $table->string('title'); + $table->text('description')->nullable(); + $table->string('status', 20)->default('draft'); + $table->decimal('total_amount', 10, 2)->default(0); + $table->timestamp('submitted_at')->nullable(); + $table->unsignedBigInteger('approved_by')->nullable(); + $table->timestamp('approved_at')->nullable(); + $table->timestamp('paid_at')->nullable(); + $table->text('rejection_reason')->nullable(); + $table->text('notes')->nullable(); + $table->softDeletes(); + $table->timestamps(); + $table->index(['tenant_id', 'employee_id']); + $table->index('status'); + }); + } + + public function down(): void + { + Schema::dropIfExists('expense_claims'); + } +}; diff --git a/erp/database/migrations/2026_06_28_100002_create_expense_claim_items_table.php b/erp/database/migrations/2026_06_28_100002_create_expense_claim_items_table.php new file mode 100644 index 00000000000..a95c0b1b389 --- /dev/null +++ b/erp/database/migrations/2026_06_28_100002_create_expense_claim_items_table.php @@ -0,0 +1,29 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->foreignId('expense_claim_id')->constrained()->cascadeOnDelete(); + $table->string('category', 50)->default('other'); + $table->string('description'); + $table->decimal('amount', 10, 2)->default(0); + $table->date('expense_date'); + $table->string('receipt_reference')->nullable(); + $table->timestamps(); + $table->index('tenant_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('expense_claim_items'); + } +}; diff --git a/erp/database/migrations/2026_06_29_100001_add_tiered_pricing_columns_to_price_lists.php b/erp/database/migrations/2026_06_29_100001_add_tiered_pricing_columns_to_price_lists.php new file mode 100644 index 00000000000..73cfa5e669b --- /dev/null +++ b/erp/database/migrations/2026_06_29_100001_add_tiered_pricing_columns_to_price_lists.php @@ -0,0 +1,43 @@ +boolean('is_default')->default(false)->after('is_active'); + $table->date('valid_from')->nullable()->after('is_default'); + $table->date('valid_to')->nullable()->after('valid_from'); + }); + + Schema::table('price_list_items', function (Blueprint $table) { + $table->unsignedBigInteger('tenant_id')->default(0)->after('id'); + $table->unsignedInteger('min_quantity')->default(1)->after('unit_price'); + }); + + // Drop the old unique constraint and add new one including min_quantity + Schema::table('price_list_items', function (Blueprint $table) { + $table->dropUnique(['price_list_id', 'product_id']); + $table->unique(['price_list_id', 'product_id', 'min_quantity']); + $table->index('tenant_id'); + }); + } + + public function down(): void + { + Schema::table('price_list_items', function (Blueprint $table) { + $table->dropUnique(['price_list_id', 'product_id', 'min_quantity']); + $table->dropIndex(['tenant_id']); + $table->unique(['price_list_id', 'product_id']); + $table->dropColumn(['tenant_id', 'min_quantity']); + }); + + Schema::table('price_lists', function (Blueprint $table) { + $table->dropColumn(['is_default', 'valid_from', 'valid_to']); + }); + } +}; diff --git a/erp/database/migrations/2026_06_30_100001_create_tax_rates_table.php b/erp/database/migrations/2026_06_30_100001_create_tax_rates_table.php new file mode 100644 index 00000000000..868540ee285 --- /dev/null +++ b/erp/database/migrations/2026_06_30_100001_create_tax_rates_table.php @@ -0,0 +1,30 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->string('name'); + $table->decimal('rate', 8, 4)->default(0); + $table->string('tax_type', 10)->default('both'); // sales/purchase/both + $table->boolean('is_compound')->default(false); + $table->boolean('is_active')->default(true); + $table->foreignId('account_id')->nullable()->nullOnDelete()->constrained('accounts'); + $table->softDeletes(); + $table->timestamps(); + $table->index('tenant_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('tax_rates'); + } +}; diff --git a/erp/database/migrations/2026_06_30_100002_create_tax_groups_table.php b/erp/database/migrations/2026_06_30_100002_create_tax_groups_table.php new file mode 100644 index 00000000000..2eebeaab89f --- /dev/null +++ b/erp/database/migrations/2026_06_30_100002_create_tax_groups_table.php @@ -0,0 +1,27 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->string('name'); + $table->text('description')->nullable(); + $table->boolean('is_active')->default(true); + $table->softDeletes(); + $table->timestamps(); + $table->index('tenant_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('tax_groups'); + } +}; diff --git a/erp/database/migrations/2026_06_30_100003_create_tax_group_items_table.php b/erp/database/migrations/2026_06_30_100003_create_tax_group_items_table.php new file mode 100644 index 00000000000..2db2c0d26cb --- /dev/null +++ b/erp/database/migrations/2026_06_30_100003_create_tax_group_items_table.php @@ -0,0 +1,26 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->foreignId('tax_group_id')->constrained()->cascadeOnDelete(); + $table->foreignId('tax_rate_id')->constrained()->cascadeOnDelete(); + $table->timestamps(); + $table->unique(['tax_group_id', 'tax_rate_id']); + $table->index('tenant_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('tax_group_items'); + } +}; diff --git a/erp/database/migrations/2026_07_03_100000_update_payroll_runs_add_new_fields.php b/erp/database/migrations/2026_07_03_100000_update_payroll_runs_add_new_fields.php new file mode 100644 index 00000000000..9946e61162a --- /dev/null +++ b/erp/database/migrations/2026_07_03_100000_update_payroll_runs_add_new_fields.php @@ -0,0 +1,30 @@ +date('run_date')->nullable()->after('period_end'); + } + if (!Schema::hasColumn('payroll_runs', 'approved_by')) { + $table->unsignedBigInteger('approved_by')->nullable()->after('notes'); + } + if (!Schema::hasColumn('payroll_runs', 'approved_at')) { + $table->timestamp('approved_at')->nullable()->after('approved_by'); + } + }); + } + + public function down(): void + { + Schema::table('payroll_runs', function (Blueprint $table) { + $table->dropColumn(['run_date', 'approved_by', 'approved_at']); + }); + } +}; diff --git a/erp/database/migrations/2026_07_03_100001_create_payroll_runs_table.php b/erp/database/migrations/2026_07_03_100001_create_payroll_runs_table.php new file mode 100644 index 00000000000..f11ed4cc3e7 --- /dev/null +++ b/erp/database/migrations/2026_07_03_100001_create_payroll_runs_table.php @@ -0,0 +1,34 @@ +date('run_date')->nullable()->after('period_end'); + }); + } + if (!Schema::hasColumn('payroll_runs', 'approved_by')) { + Schema::table('payroll_runs', function (Blueprint $table) { + $table->unsignedBigInteger('approved_by')->nullable(); + }); + } + if (!Schema::hasColumn('payroll_runs', 'approved_at')) { + Schema::table('payroll_runs', function (Blueprint $table) { + $table->timestamp('approved_at')->nullable(); + }); + } + } + + public function down(): void + { + // + } +}; diff --git a/erp/database/migrations/2026_07_03_100002_create_payslips_table.php b/erp/database/migrations/2026_07_03_100002_create_payslips_table.php new file mode 100644 index 00000000000..a9cd828a8e2 --- /dev/null +++ b/erp/database/migrations/2026_07_03_100002_create_payslips_table.php @@ -0,0 +1,31 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->foreignId('payroll_run_id')->constrained()->cascadeOnDelete(); + $table->foreignId('employee_id')->constrained()->cascadeOnDelete(); + $table->decimal('gross_amount', 14, 2)->default(0); + $table->decimal('total_deductions', 14, 2)->default(0); + $table->decimal('net_amount', 14, 2)->default(0); + $table->decimal('tax_amount', 14, 2)->default(0); + $table->text('notes')->nullable(); + $table->timestamps(); + $table->unique(['payroll_run_id', 'employee_id']); + $table->index('tenant_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('payslips'); + } +}; diff --git a/erp/database/migrations/2026_07_03_100003_update_payroll_runs_extend_status.php b/erp/database/migrations/2026_07_03_100003_update_payroll_runs_extend_status.php new file mode 100644 index 00000000000..227a7bf7968 --- /dev/null +++ b/erp/database/migrations/2026_07_03_100003_update_payroll_runs_extend_status.php @@ -0,0 +1,56 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->string('period_label')->nullable(); + $table->date('period_start'); + $table->date('period_end'); + $table->date('run_date')->nullable(); + $table->string('status', 20)->default('draft'); + $table->decimal('total_gross', 14, 2)->default(0); + $table->decimal('total_deductions', 14, 2)->default(0); + $table->decimal('total_net', 14, 2)->default(0); + $table->integer('employee_count')->default(0); + $table->timestamp('processed_at')->nullable(); + $table->text('notes')->nullable(); + $table->unsignedBigInteger('created_by')->nullable(); + $table->unsignedBigInteger('approved_by')->nullable(); + $table->timestamp('approved_at')->nullable(); + $table->softDeletes(); + $table->timestamps(); + $table->index(['tenant_id', 'status']); + }); + + DB::statement('INSERT INTO payroll_runs SELECT id, tenant_id, period_label, period_start, period_end, run_date, status, total_gross, total_deductions, total_net, employee_count, processed_at, notes, created_by, approved_by, approved_at, deleted_at, created_at, updated_at FROM payroll_runs_backup'); + DB::statement('DROP TABLE payroll_runs_backup'); + + DB::statement('PRAGMA foreign_keys = ON'); + } + // For non-SQLite databases, no action needed (VARCHAR doesn't have CHECK constraint) + } + + public function down(): void + { + // Not reversible without data loss risk + } +}; diff --git a/erp/database/migrations/2026_07_04_100001_create_service_agreements_table.php b/erp/database/migrations/2026_07_04_100001_create_service_agreements_table.php new file mode 100644 index 00000000000..2c6ee7290a1 --- /dev/null +++ b/erp/database/migrations/2026_07_04_100001_create_service_agreements_table.php @@ -0,0 +1,36 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->foreignId('contact_id')->nullable()->nullOnDelete()->constrained('contacts'); + $table->string('title'); + $table->text('description')->nullable(); + $table->string('agreement_type', 20)->default('maintenance'); + $table->string('status', 20)->default('draft'); + $table->date('start_date')->nullable(); + $table->date('end_date')->nullable(); + $table->decimal('value', 14, 2)->nullable(); + $table->string('billing_cycle', 20)->default('monthly'); + $table->boolean('auto_renew')->default(false); + $table->text('terms')->nullable(); + $table->date('signed_at')->nullable(); + $table->softDeletes(); + $table->timestamps(); + $table->index(['tenant_id', 'status']); + }); + } + + public function down(): void + { + Schema::dropIfExists('service_agreements'); + } +}; diff --git a/erp/database/migrations/2026_07_04_100002_create_service_agreement_items_table.php b/erp/database/migrations/2026_07_04_100002_create_service_agreement_items_table.php new file mode 100644 index 00000000000..a4faa058d94 --- /dev/null +++ b/erp/database/migrations/2026_07_04_100002_create_service_agreement_items_table.php @@ -0,0 +1,28 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->foreignId('service_agreement_id')->constrained()->cascadeOnDelete(); + $table->string('description'); + $table->unsignedInteger('quantity')->default(1); + $table->decimal('unit_price', 10, 2)->default(0); + $table->decimal('total_price', 10, 2)->default(0); + $table->timestamps(); + $table->index('tenant_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('service_agreement_items'); + } +}; diff --git a/erp/database/migrations/2026_07_04_100003_create_maintenance_logs_table.php b/erp/database/migrations/2026_07_04_100003_create_maintenance_logs_table.php new file mode 100644 index 00000000000..ca356aa320c --- /dev/null +++ b/erp/database/migrations/2026_07_04_100003_create_maintenance_logs_table.php @@ -0,0 +1,31 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->foreignId('service_agreement_id')->constrained()->cascadeOnDelete(); + $table->unsignedBigInteger('technician_id')->nullable(); + $table->date('log_date'); + $table->text('description'); + $table->string('status', 20)->default('scheduled'); + $table->text('resolution')->nullable(); + $table->decimal('hours_spent', 8, 2)->nullable(); + $table->date('next_service_date')->nullable(); + $table->timestamps(); + $table->index(['tenant_id', 'service_agreement_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('maintenance_logs'); + } +}; diff --git a/erp/database/migrations/2026_07_05_100001_create_demand_forecasts_table.php b/erp/database/migrations/2026_07_05_100001_create_demand_forecasts_table.php new file mode 100644 index 00000000000..d63cb3f1ce3 --- /dev/null +++ b/erp/database/migrations/2026_07_05_100001_create_demand_forecasts_table.php @@ -0,0 +1,31 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->foreignId('product_id')->constrained()->cascadeOnDelete(); + $table->foreignId('warehouse_id')->nullable()->nullOnDelete()->constrained(); + $table->date('forecast_date'); + $table->decimal('forecasted_quantity', 14, 2)->default(0); + $table->decimal('actual_quantity', 14, 2)->nullable(); + $table->string('method', 20)->default('manual'); + $table->decimal('confidence_score', 5, 2)->nullable(); + $table->text('notes')->nullable(); + $table->timestamps(); + $table->index(['tenant_id', 'product_id', 'forecast_date']); + }); + } + + public function down(): void + { + Schema::dropIfExists('demand_forecasts'); + } +}; diff --git a/erp/database/migrations/2026_07_05_100002_create_forecast_alerts_table.php b/erp/database/migrations/2026_07_05_100002_create_forecast_alerts_table.php new file mode 100644 index 00000000000..9d78ad17091 --- /dev/null +++ b/erp/database/migrations/2026_07_05_100002_create_forecast_alerts_table.php @@ -0,0 +1,29 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->foreignId('product_id')->constrained()->cascadeOnDelete(); + $table->string('alert_type', 30); + $table->string('severity', 10)->default('medium'); + $table->text('message'); + $table->boolean('is_resolved')->default(false); + $table->timestamp('resolved_at')->nullable(); + $table->timestamps(); + $table->index(['tenant_id', 'is_resolved']); + }); + } + + public function down(): void + { + Schema::dropIfExists('forecast_alerts'); + } +}; diff --git a/erp/database/migrations/2026_07_06_100001_create_warehouse_zones_table.php b/erp/database/migrations/2026_07_06_100001_create_warehouse_zones_table.php new file mode 100644 index 00000000000..ea10c0c5c77 --- /dev/null +++ b/erp/database/migrations/2026_07_06_100001_create_warehouse_zones_table.php @@ -0,0 +1,29 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->foreignId('warehouse_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('code', 20); + $table->text('description')->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + $table->unique(['warehouse_id', 'code']); + $table->index('tenant_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('warehouse_zones'); + } +}; diff --git a/erp/database/migrations/2026_07_06_100002_create_warehouse_bins_table.php b/erp/database/migrations/2026_07_06_100002_create_warehouse_bins_table.php new file mode 100644 index 00000000000..860d906d7db --- /dev/null +++ b/erp/database/migrations/2026_07_06_100002_create_warehouse_bins_table.php @@ -0,0 +1,31 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->foreignId('warehouse_id')->constrained()->cascadeOnDelete(); + $table->foreignId('zone_id')->nullable()->nullOnDelete()->constrained('warehouse_zones'); + $table->string('code', 30); + $table->string('name')->nullable(); + $table->string('bin_type', 20)->default('standard'); + $table->decimal('capacity', 14, 2)->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + $table->unique(['warehouse_id', 'code']); + $table->index('tenant_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('warehouse_bins'); + } +}; diff --git a/erp/database/migrations/2026_07_06_100003_create_bin_stock_locations_table.php b/erp/database/migrations/2026_07_06_100003_create_bin_stock_locations_table.php new file mode 100644 index 00000000000..04d73679360 --- /dev/null +++ b/erp/database/migrations/2026_07_06_100003_create_bin_stock_locations_table.php @@ -0,0 +1,29 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->foreignId('bin_id')->constrained('warehouse_bins')->cascadeOnDelete(); + $table->foreignId('product_id')->constrained()->cascadeOnDelete(); + $table->decimal('quantity', 14, 4)->default(0); + $table->string('lot_number', 50)->nullable(); + $table->date('expiry_date')->nullable(); + $table->timestamps(); + $table->unique(['bin_id', 'product_id', 'lot_number']); + $table->index(['tenant_id', 'product_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('bin_stock_locations'); + } +}; diff --git a/erp/database/migrations/2026_07_07_100001_create_loyalty_programs_table.php b/erp/database/migrations/2026_07_07_100001_create_loyalty_programs_table.php new file mode 100644 index 00000000000..491576cdfed --- /dev/null +++ b/erp/database/migrations/2026_07_07_100001_create_loyalty_programs_table.php @@ -0,0 +1,31 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->string('name'); + $table->text('description')->nullable(); + $table->decimal('points_per_currency_unit', 10, 4)->default(1); + $table->decimal('points_to_currency_rate', 10, 6)->default(0.01); + $table->unsignedInteger('minimum_redemption_points')->default(100); + $table->boolean('is_active')->default(true); + $table->json('tier_config')->nullable(); + $table->softDeletes(); + $table->timestamps(); + $table->index('tenant_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('loyalty_programs'); + } +}; diff --git a/erp/database/migrations/2026_07_07_100002_create_loyalty_enrollments_table.php b/erp/database/migrations/2026_07_07_100002_create_loyalty_enrollments_table.php new file mode 100644 index 00000000000..60d38f189c9 --- /dev/null +++ b/erp/database/migrations/2026_07_07_100002_create_loyalty_enrollments_table.php @@ -0,0 +1,31 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->foreignId('loyalty_program_id')->constrained()->cascadeOnDelete(); + $table->foreignId('contact_id')->constrained()->cascadeOnDelete(); + $table->unsignedInteger('points_balance')->default(0); + $table->unsignedInteger('total_points_earned')->default(0); + $table->unsignedInteger('total_points_redeemed')->default(0); + $table->timestamp('enrolled_at')->useCurrent(); + $table->string('tier_name')->nullable(); + $table->timestamps(); + $table->unique(['loyalty_program_id', 'contact_id']); + $table->index('tenant_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('loyalty_enrollments'); + } +}; diff --git a/erp/database/migrations/2026_07_07_100003_create_loyalty_transactions_table.php b/erp/database/migrations/2026_07_07_100003_create_loyalty_transactions_table.php new file mode 100644 index 00000000000..158312c0a6a --- /dev/null +++ b/erp/database/migrations/2026_07_07_100003_create_loyalty_transactions_table.php @@ -0,0 +1,29 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->foreignId('loyalty_enrollment_id')->constrained()->cascadeOnDelete(); + $table->string('type', 20); // earn/redeem/adjustment/expire + $table->integer('points'); + $table->text('description')->nullable(); + $table->unsignedBigInteger('reference_id')->nullable(); + $table->unsignedInteger('balance_after')->default(0); + $table->timestamps(); + $table->index(['tenant_id', 'loyalty_enrollment_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('loyalty_transactions'); + } +}; diff --git a/erp/database/migrations/2026_07_08_100001_create_leads_table.php b/erp/database/migrations/2026_07_08_100001_create_leads_table.php new file mode 100644 index 00000000000..693380b2829 --- /dev/null +++ b/erp/database/migrations/2026_07_08_100001_create_leads_table.php @@ -0,0 +1,38 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->string('name'); + $table->string('email')->nullable(); + $table->string('phone', 30)->nullable(); + $table->string('company')->nullable(); + $table->string('source', 30)->default('other'); + $table->string('stage', 20)->default('new'); + $table->unsignedBigInteger('assigned_to')->nullable(); + $table->decimal('estimated_value', 14, 2)->nullable(); + $table->unsignedTinyInteger('probability')->default(0); + $table->text('notes')->nullable(); + $table->text('lost_reason')->nullable(); + $table->date('won_at')->nullable(); + $table->date('lost_at')->nullable(); + $table->date('expected_close_date')->nullable(); + $table->softDeletes(); + $table->timestamps(); + $table->index(['tenant_id', 'stage']); + }); + } + + public function down(): void + { + Schema::dropIfExists('leads'); + } +}; diff --git a/erp/database/migrations/2026_07_08_100002_create_lead_activities_table.php b/erp/database/migrations/2026_07_08_100002_create_lead_activities_table.php new file mode 100644 index 00000000000..fbff9a3a2ce --- /dev/null +++ b/erp/database/migrations/2026_07_08_100002_create_lead_activities_table.php @@ -0,0 +1,30 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->foreignId('lead_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('type', 20)->default('note'); + $table->text('description'); + $table->date('activity_date'); + $table->text('outcome')->nullable(); + $table->unsignedInteger('duration_minutes')->nullable(); + $table->timestamps(); + $table->index(['tenant_id', 'lead_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('lead_activities'); + } +}; diff --git a/erp/database/migrations/2026_07_09_100001_create_product_attributes_table.php b/erp/database/migrations/2026_07_09_100001_create_product_attributes_table.php new file mode 100644 index 00000000000..0e660a08fd5 --- /dev/null +++ b/erp/database/migrations/2026_07_09_100001_create_product_attributes_table.php @@ -0,0 +1,25 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->string('name'); + $table->string('type')->default('text'); + $table->json('options')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('product_attributes'); + } +}; diff --git a/erp/database/migrations/2026_07_09_100002_create_product_variants_table.php b/erp/database/migrations/2026_07_09_100002_create_product_variants_table.php new file mode 100644 index 00000000000..fc80f9750c4 --- /dev/null +++ b/erp/database/migrations/2026_07_09_100002_create_product_variants_table.php @@ -0,0 +1,28 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->foreignId('product_id')->constrained()->cascadeOnDelete(); + $table->string('sku')->unique(); + $table->string('name'); + $table->decimal('price_adjustment', 10, 2)->default(0); + $table->integer('stock_quantity')->default(0); + $table->boolean('is_active')->default(true); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('product_variants'); + } +}; diff --git a/erp/database/migrations/2026_07_09_100003_create_product_variant_values_table.php b/erp/database/migrations/2026_07_09_100003_create_product_variant_values_table.php new file mode 100644 index 00000000000..b89b9314fae --- /dev/null +++ b/erp/database/migrations/2026_07_09_100003_create_product_variant_values_table.php @@ -0,0 +1,24 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->foreignId('variant_id')->constrained('product_variants')->cascadeOnDelete(); + $table->foreignId('attribute_id')->constrained('product_attributes')->cascadeOnDelete(); + $table->string('value'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('product_variant_values'); + } +}; diff --git a/erp/database/migrations/2026_07_10_100001_update_bank_accounts_for_reconciliation.php b/erp/database/migrations/2026_07_10_100001_update_bank_accounts_for_reconciliation.php new file mode 100644 index 00000000000..7b4a38a7b00 --- /dev/null +++ b/erp/database/migrations/2026_07_10_100001_update_bank_accounts_for_reconciliation.php @@ -0,0 +1,24 @@ +string('currency')->default('USD')->after('bank_name'); + $table->decimal('current_balance', 15, 2)->default(0)->after('opening_balance'); + $table->boolean('is_active')->default(true)->after('current_balance'); + }); + } + + public function down(): void + { + Schema::table('bank_accounts', function (Blueprint $table) { + $table->dropColumn(['currency', 'current_balance', 'is_active']); + }); + } +}; diff --git a/erp/database/migrations/2026_07_10_100002_update_bank_transactions_for_reconciliation.php b/erp/database/migrations/2026_07_10_100002_update_bank_transactions_for_reconciliation.php new file mode 100644 index 00000000000..27f7df82336 --- /dev/null +++ b/erp/database/migrations/2026_07_10_100002_update_bank_transactions_for_reconciliation.php @@ -0,0 +1,24 @@ +string('type')->default('credit')->after('reference'); + $table->boolean('is_reconciled')->default(false)->after('type'); + $table->unsignedBigInteger('reconciliation_id')->nullable()->after('is_reconciled'); + }); + } + + public function down(): void + { + Schema::table('bank_transactions', function (Blueprint $table) { + $table->dropColumn(['type', 'is_reconciled', 'reconciliation_id']); + }); + } +}; diff --git a/erp/database/migrations/2026_07_10_100003_create_bank_reconciliations_table.php b/erp/database/migrations/2026_07_10_100003_create_bank_reconciliations_table.php new file mode 100644 index 00000000000..3c349620c75 --- /dev/null +++ b/erp/database/migrations/2026_07_10_100003_create_bank_reconciliations_table.php @@ -0,0 +1,30 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->foreignId('bank_account_id')->constrained()->cascadeOnDelete(); + $table->date('statement_date'); + $table->decimal('statement_balance', 15, 2); + $table->decimal('reconciled_balance', 15, 2)->default(0); + $table->string('status')->default('draft'); // draft|completed + $table->text('notes')->nullable(); + $table->unsignedBigInteger('completed_by')->nullable(); + $table->timestamp('completed_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('bank_reconciliations'); + } +}; diff --git a/erp/database/migrations/2026_07_11_100001_create_onboarding_checklists_table.php b/erp/database/migrations/2026_07_11_100001_create_onboarding_checklists_table.php new file mode 100644 index 00000000000..2669b94e908 --- /dev/null +++ b/erp/database/migrations/2026_07_11_100001_create_onboarding_checklists_table.php @@ -0,0 +1,27 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->string('name'); + $table->string('department')->nullable(); + $table->text('description')->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('onboarding_checklists'); + } +}; diff --git a/erp/database/migrations/2026_07_11_100002_create_onboarding_tasks_table.php b/erp/database/migrations/2026_07_11_100002_create_onboarding_tasks_table.php new file mode 100644 index 00000000000..6131b4e7e8a --- /dev/null +++ b/erp/database/migrations/2026_07_11_100002_create_onboarding_tasks_table.php @@ -0,0 +1,29 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->foreignId('onboarding_checklist_id')->constrained()->cascadeOnDelete(); + $table->string('title'); + $table->text('description')->nullable(); + $table->string('category')->nullable(); + $table->integer('due_day_offset')->default(0); + $table->boolean('is_required')->default(true); + $table->integer('sort_order')->default(0); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('onboarding_tasks'); + } +}; diff --git a/erp/database/migrations/2026_07_11_100003_create_employee_onboardings_table.php b/erp/database/migrations/2026_07_11_100003_create_employee_onboardings_table.php new file mode 100644 index 00000000000..259ad915bf1 --- /dev/null +++ b/erp/database/migrations/2026_07_11_100003_create_employee_onboardings_table.php @@ -0,0 +1,30 @@ +unsignedBigInteger('onboarding_checklist_id')->nullable()->after('employee_id'); + $table->date('start_date')->nullable()->after('onboarding_checklist_id'); + $table->unsignedBigInteger('assigned_by')->nullable()->after('start_date'); + // Make title nullable for checklist-based onboardings + $table->string('title')->nullable()->change(); + // Make started_at nullable for checklist-based onboardings + $table->date('started_at')->nullable()->change(); + }); + } + + public function down(): void + { + Schema::table('employee_onboardings', function (Blueprint $table) { + $table->dropColumn(['onboarding_checklist_id', 'start_date', 'assigned_by']); + $table->string('title')->nullable(false)->change(); + $table->date('started_at')->nullable(false)->change(); + }); + } +}; diff --git a/erp/database/migrations/2026_07_11_100004_create_onboarding_progress_table.php b/erp/database/migrations/2026_07_11_100004_create_onboarding_progress_table.php new file mode 100644 index 00000000000..c2285d3d34b --- /dev/null +++ b/erp/database/migrations/2026_07_11_100004_create_onboarding_progress_table.php @@ -0,0 +1,28 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->foreignId('employee_onboarding_id')->constrained()->cascadeOnDelete(); + $table->foreignId('onboarding_task_id')->constrained()->cascadeOnDelete(); + $table->string('status')->default('pending'); + $table->text('notes')->nullable(); + $table->unsignedBigInteger('completed_by')->nullable(); + $table->timestamp('completed_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('onboarding_progress'); + } +}; diff --git a/erp/database/migrations/2026_07_12_100001_create_budgets_table.php b/erp/database/migrations/2026_07_12_100001_create_budgets_table.php new file mode 100644 index 00000000000..9bad557a012 --- /dev/null +++ b/erp/database/migrations/2026_07_12_100001_create_budgets_table.php @@ -0,0 +1,22 @@ +string('category')->nullable()->after('tenant_id'); + } + if (!Schema::hasColumn('budget_lines', 'line_type')) { + $table->string('line_type')->nullable()->after('category'); + } + if (!Schema::hasColumn('budget_lines', 'period_number')) { + $table->integer('period_number')->default(1)->after('line_type'); + } + if (!Schema::hasColumn('budget_lines', 'budgeted_amount')) { + $table->decimal('budgeted_amount', 15, 2)->default(0)->after('period_number'); + } + if (!Schema::hasColumn('budget_lines', 'actual_amount')) { + $table->decimal('actual_amount', 15, 2)->default(0)->after('budgeted_amount'); + } + }); + + // Make account_id nullable so Phase 82 lines can be created without one + // SQLite doesn't support ALTER COLUMN, so we recreate the table. + if (DB::getDriverName() === 'sqlite') { + DB::statement(" + CREATE TABLE budget_lines_new AS SELECT + id, budget_id, budget_id as budget_id_tmp, account_id, period, amount, notes, + created_at, updated_at, tenant_id, deleted_at, + category, line_type, period_number, budgeted_amount, actual_amount + FROM budget_lines + "); + DB::statement("DROP TABLE budget_lines"); + DB::statement(" + CREATE TABLE budget_lines ( + id integer NOT NULL PRIMARY KEY AUTOINCREMENT, + budget_id integer NOT NULL, + account_id integer NULL, + period integer NOT NULL DEFAULT 0, + amount decimal(14,2) NOT NULL DEFAULT 0, + notes text, + created_at datetime, + updated_at datetime, + tenant_id integer, + deleted_at datetime, + category varchar(255), + line_type varchar(255), + period_number integer NOT NULL DEFAULT 1, + budgeted_amount decimal(15,2) NOT NULL DEFAULT 0, + actual_amount decimal(15,2) NOT NULL DEFAULT 0, + FOREIGN KEY (budget_id) REFERENCES budgets(id) ON DELETE CASCADE + ) + "); + DB::statement(" + INSERT INTO budget_lines + (id, budget_id, account_id, period, amount, notes, created_at, updated_at, + tenant_id, deleted_at, category, line_type, period_number, budgeted_amount, actual_amount) + SELECT id, budget_id, account_id, period, amount, notes, created_at, updated_at, + tenant_id, deleted_at, category, line_type, period_number, budgeted_amount, actual_amount + FROM budget_lines_new + "); + DB::statement("DROP TABLE budget_lines_new"); + } + } + + public function down(): void + { + Schema::table('budget_lines', function (Blueprint $table) { + foreach (['category', 'line_type', 'period_number', 'budgeted_amount', 'actual_amount'] as $col) { + if (Schema::hasColumn('budget_lines', $col)) { + $table->dropColumn($col); + } + } + }); + } +}; diff --git a/erp/database/migrations/2026_07_13_100001_alter_leave_types_add_columns.php b/erp/database/migrations/2026_07_13_100001_alter_leave_types_add_columns.php new file mode 100644 index 00000000000..93fe54f0ff4 --- /dev/null +++ b/erp/database/migrations/2026_07_13_100001_alter_leave_types_add_columns.php @@ -0,0 +1,40 @@ +string('code')->nullable()->after('name'); + } + if (! Schema::hasColumn('leave_types', 'default_days')) { + $table->integer('default_days')->default(0)->after('code'); + } + if (! Schema::hasColumn('leave_types', 'requires_approval')) { + $table->boolean('requires_approval')->default(true)->after('is_active'); + } + if (! Schema::hasColumn('leave_types', 'description')) { + $table->text('description')->nullable()->after('requires_approval'); + } + if (! Schema::hasColumn('leave_types', 'deleted_at')) { + $table->softDeletes(); + } + }); + } + + public function down(): void + { + Schema::table('leave_types', function (Blueprint $table) { + $cols = ['code', 'default_days', 'requires_approval', 'description', 'deleted_at']; + $toDrop = array_filter($cols, fn ($c) => Schema::hasColumn('leave_types', $c)); + if ($toDrop) { + $table->dropColumn(array_values($toDrop)); + } + }); + } +}; diff --git a/erp/database/migrations/2026_07_13_100002_alter_leave_requests_add_columns.php b/erp/database/migrations/2026_07_13_100002_alter_leave_requests_add_columns.php new file mode 100644 index 00000000000..5f9094b59bd --- /dev/null +++ b/erp/database/migrations/2026_07_13_100002_alter_leave_requests_add_columns.php @@ -0,0 +1,49 @@ +decimal('days_requested', 5, 1)->default(0)->after('end_date'); + } + if (! Schema::hasColumn('leave_requests', 'reason')) { + $table->text('reason')->nullable()->after('days_requested'); + } + if (! Schema::hasColumn('leave_requests', 'rejection_reason')) { + $table->text('rejection_reason')->nullable()->after('reason'); + } + if (! Schema::hasColumn('leave_requests', 'approved_by')) { + $table->unsignedBigInteger('approved_by')->nullable()->after('rejection_reason'); + } + if (! Schema::hasColumn('leave_requests', 'approved_at')) { + $table->timestamp('approved_at')->nullable()->after('approved_by'); + } + }); + + // Allow 'cancelled' in status enum + if (Schema::getConnection()->getDriverName() === 'sqlite') { + // SQLite does not support modifying enums; we rely on the model for validation + } else { + Schema::table('leave_requests', function (Blueprint $table) { + $table->string('status')->default('pending')->change(); + }); + } + } + + public function down(): void + { + Schema::table('leave_requests', function (Blueprint $table) { + $cols = ['days_requested', 'reason', 'rejection_reason', 'approved_by', 'approved_at']; + $toDrop = array_filter($cols, fn ($c) => Schema::hasColumn('leave_requests', $c)); + if ($toDrop) { + $table->dropColumn(array_values($toDrop)); + } + }); + } +}; diff --git a/erp/database/migrations/2026_07_13_100003_create_leave_balances_table.php b/erp/database/migrations/2026_07_13_100003_create_leave_balances_table.php new file mode 100644 index 00000000000..e1ea2959c34 --- /dev/null +++ b/erp/database/migrations/2026_07_13_100003_create_leave_balances_table.php @@ -0,0 +1,33 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->foreignId('employee_id')->constrained('employees')->cascadeOnDelete(); + $table->unsignedBigInteger('leave_type_id'); + $table->integer('year'); + $table->decimal('allocated_days', 5, 1)->default(0); + $table->decimal('used_days', 5, 1)->default(0); + $table->decimal('pending_days', 5, 1)->default(0); + $table->timestamps(); + $table->unique(['employee_id', 'leave_type_id', 'year']); + }); + } + + public function down(): void + { + Schema::dropIfExists('leave_balances'); + } +}; diff --git a/erp/database/migrations/2026_07_14_100001_alter_training_courses_add_columns.php b/erp/database/migrations/2026_07_14_100001_alter_training_courses_add_columns.php new file mode 100644 index 00000000000..8c166ef5d1b --- /dev/null +++ b/erp/database/migrations/2026_07_14_100001_alter_training_courses_add_columns.php @@ -0,0 +1,24 @@ +string('category')->nullable()->after('title'); + $table->decimal('cost', 10, 2)->nullable()->after('duration_hours'); + $table->boolean('is_mandatory')->default(false)->after('cost'); + }); + } + + public function down(): void + { + Schema::table('training_courses', function (Blueprint $table) { + $table->dropColumn(['category', 'cost', 'is_mandatory']); + }); + } +}; diff --git a/erp/database/migrations/2026_07_14_100002_create_training_enrollments_table.php b/erp/database/migrations/2026_07_14_100002_create_training_enrollments_table.php new file mode 100644 index 00000000000..8ddc9db14f4 --- /dev/null +++ b/erp/database/migrations/2026_07_14_100002_create_training_enrollments_table.php @@ -0,0 +1,31 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->foreignId('employee_id')->constrained('employees')->cascadeOnDelete(); + $table->foreignId('training_course_id')->constrained()->cascadeOnDelete(); + $table->date('enrolled_date'); + $table->date('scheduled_date')->nullable(); + $table->date('completed_date')->nullable(); + $table->string('status')->default('enrolled'); + $table->decimal('score', 5, 2)->nullable(); + $table->text('notes')->nullable(); + $table->unsignedBigInteger('enrolled_by')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('training_enrollments'); + } +}; diff --git a/erp/database/migrations/2026_07_14_100003_create_employee_certifications_table.php b/erp/database/migrations/2026_07_14_100003_create_employee_certifications_table.php new file mode 100644 index 00000000000..f978539d119 --- /dev/null +++ b/erp/database/migrations/2026_07_14_100003_create_employee_certifications_table.php @@ -0,0 +1,30 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->foreignId('employee_id')->constrained('employees')->cascadeOnDelete(); + $table->string('name'); + $table->string('issuing_body')->nullable(); + $table->string('certificate_number')->nullable(); + $table->date('issued_date'); + $table->date('expiry_date')->nullable(); + $table->boolean('is_verified')->default(false); + $table->unsignedBigInteger('training_enrollment_id')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('employee_certifications'); + } +}; diff --git a/erp/database/migrations/2026_07_15_100001_create_currencies_table.php b/erp/database/migrations/2026_07_15_100001_create_currencies_table.php new file mode 100644 index 00000000000..43f334e984d --- /dev/null +++ b/erp/database/migrations/2026_07_15_100001_create_currencies_table.php @@ -0,0 +1,29 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->string('code', 3); + $table->string('name'); + $table->string('symbol', 10); + $table->integer('decimal_places')->default(2); + $table->boolean('is_base')->default(false); + $table->boolean('is_active')->default(true); + $table->timestamps(); + $table->unique(['tenant_id', 'code']); + }); + } + + public function down(): void + { + Schema::dropIfExists('currencies'); + } +}; diff --git a/erp/database/migrations/2026_07_15_100002_add_from_to_currency_to_exchange_rates_table.php b/erp/database/migrations/2026_07_15_100002_add_from_to_currency_to_exchange_rates_table.php new file mode 100644 index 00000000000..e84290180c0 --- /dev/null +++ b/erp/database/migrations/2026_07_15_100002_add_from_to_currency_to_exchange_rates_table.php @@ -0,0 +1,24 @@ +string('from_currency', 3)->nullable()->after('tenant_id'); + $table->string('to_currency', 3)->nullable()->after('from_currency'); + $table->boolean('is_active')->default(true)->after('effective_date'); + }); + } + + public function down(): void + { + Schema::table('exchange_rates', function (Blueprint $table) { + $table->dropColumn(['from_currency', 'to_currency', 'is_active']); + }); + } +}; diff --git a/erp/database/migrations/2026_07_16_100001_create_vehicles_table.php b/erp/database/migrations/2026_07_16_100001_create_vehicles_table.php new file mode 100644 index 00000000000..5ea7785f853 --- /dev/null +++ b/erp/database/migrations/2026_07_16_100001_create_vehicles_table.php @@ -0,0 +1,36 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->string('registration'); + $table->string('make'); + $table->string('model'); + $table->integer('year')->nullable(); + $table->string('vin')->nullable(); + $table->string('colour')->nullable(); + $table->string('fuel_type')->default('petrol'); + $table->decimal('odometer_km', 10, 1)->default(0); + $table->string('status')->default('available'); + $table->unsignedBigInteger('assigned_to_employee_id')->nullable(); + $table->date('insurance_expiry')->nullable(); + $table->date('registration_expiry')->nullable(); + $table->text('notes')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('vehicles'); + } +}; diff --git a/erp/database/migrations/2026_07_16_100002_create_vehicle_logs_table.php b/erp/database/migrations/2026_07_16_100002_create_vehicle_logs_table.php new file mode 100644 index 00000000000..f063130666a --- /dev/null +++ b/erp/database/migrations/2026_07_16_100002_create_vehicle_logs_table.php @@ -0,0 +1,34 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->foreignId('vehicle_id')->constrained()->cascadeOnDelete(); + $table->string('log_type'); + $table->date('log_date'); + $table->decimal('odometer_start', 10, 1)->nullable(); + $table->decimal('odometer_end', 10, 1)->nullable(); + $table->decimal('distance_km', 10, 1)->nullable(); + $table->decimal('fuel_litres', 8, 2)->nullable(); + $table->decimal('cost', 10, 2)->nullable(); + $table->string('driver_name')->nullable(); + $table->string('destination')->nullable(); + $table->string('purpose')->nullable(); + $table->text('notes')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('vehicle_logs'); + } +}; diff --git a/erp/database/migrations/2026_07_17_100001_create_support_tickets_table.php b/erp/database/migrations/2026_07_17_100001_create_support_tickets_table.php new file mode 100644 index 00000000000..cae2705ac44 --- /dev/null +++ b/erp/database/migrations/2026_07_17_100001_create_support_tickets_table.php @@ -0,0 +1,34 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->string('reference')->unique(); + $table->string('subject'); + $table->text('description'); + $table->string('status')->default('open'); + $table->string('priority')->default('normal'); + $table->string('category')->nullable(); + $table->unsignedBigInteger('contact_id')->nullable(); + $table->unsignedBigInteger('assigned_to')->nullable(); + $table->unsignedBigInteger('created_by'); + $table->timestamp('resolved_at')->nullable(); + $table->timestamp('closed_at')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('support_tickets'); + } +}; diff --git a/erp/database/migrations/2026_07_17_100002_create_ticket_comments_table.php b/erp/database/migrations/2026_07_17_100002_create_ticket_comments_table.php new file mode 100644 index 00000000000..cee8cac4ca7 --- /dev/null +++ b/erp/database/migrations/2026_07_17_100002_create_ticket_comments_table.php @@ -0,0 +1,26 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->foreignId('support_ticket_id')->constrained()->cascadeOnDelete(); + $table->unsignedBigInteger('created_by'); + $table->text('body'); + $table->boolean('is_internal')->default(false); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('ticket_comments'); + } +}; diff --git a/erp/database/migrations/2026_07_18_100001_create_supplier_reviews_table.php b/erp/database/migrations/2026_07_18_100001_create_supplier_reviews_table.php new file mode 100644 index 00000000000..94c2598fcd5 --- /dev/null +++ b/erp/database/migrations/2026_07_18_100001_create_supplier_reviews_table.php @@ -0,0 +1,31 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->foreignId('supplier_id')->constrained()->cascadeOnDelete(); + $table->unsignedBigInteger('purchase_order_id')->nullable(); + $table->date('review_date'); + $table->integer('quality_score'); + $table->integer('delivery_score'); + $table->integer('communication_score'); + $table->integer('price_score'); + $table->text('notes')->nullable(); + $table->unsignedBigInteger('reviewed_by'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('supplier_reviews'); + } +}; diff --git a/erp/database/migrations/2026_07_18_100002_create_supplier_contracts_table.php b/erp/database/migrations/2026_07_18_100002_create_supplier_contracts_table.php new file mode 100644 index 00000000000..852e5b5670b --- /dev/null +++ b/erp/database/migrations/2026_07_18_100002_create_supplier_contracts_table.php @@ -0,0 +1,32 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->foreignId('supplier_id')->constrained()->cascadeOnDelete(); + $table->string('contract_number')->nullable(); + $table->string('title'); + $table->date('start_date'); + $table->date('end_date')->nullable(); + $table->decimal('value', 15, 2)->nullable(); + $table->string('status')->default('active'); + $table->string('payment_terms')->nullable(); + $table->text('terms')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('supplier_contracts'); + } +}; diff --git a/erp/database/migrations/2026_07_19_100001_create_disciplinary_cases_table.php b/erp/database/migrations/2026_07_19_100001_create_disciplinary_cases_table.php new file mode 100644 index 00000000000..84a3df1a84d --- /dev/null +++ b/erp/database/migrations/2026_07_19_100001_create_disciplinary_cases_table.php @@ -0,0 +1,35 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->foreignId('employee_id')->constrained('employees')->cascadeOnDelete(); + $table->string('reference')->nullable(); + $table->string('incident_type'); + $table->date('incident_date'); + $table->text('description'); + $table->string('severity')->default('minor'); + $table->string('status')->default('open'); + $table->string('outcome')->nullable(); + $table->text('outcome_notes')->nullable(); + $table->unsignedBigInteger('handled_by')->nullable(); + $table->date('hearing_date')->nullable(); + $table->date('resolved_date')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('disciplinary_cases'); + } +}; diff --git a/erp/database/migrations/2026_07_19_100002_create_grievances_table.php b/erp/database/migrations/2026_07_19_100002_create_grievances_table.php new file mode 100644 index 00000000000..99d2c3b0b11 --- /dev/null +++ b/erp/database/migrations/2026_07_19_100002_create_grievances_table.php @@ -0,0 +1,33 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->foreignId('employee_id')->constrained('employees')->cascadeOnDelete(); + $table->string('reference')->nullable(); + $table->string('category'); + $table->text('description'); + $table->string('status')->default('submitted'); + $table->text('resolution')->nullable(); + $table->boolean('is_anonymous')->default(false); + $table->unsignedBigInteger('assigned_to')->nullable(); + $table->date('submitted_date'); + $table->date('resolved_date')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('grievances'); + } +}; diff --git a/erp/database/migrations/2026_07_20_100001_create_lot_numbers_table.php b/erp/database/migrations/2026_07_20_100001_create_lot_numbers_table.php new file mode 100644 index 00000000000..440c4cf777d --- /dev/null +++ b/erp/database/migrations/2026_07_20_100001_create_lot_numbers_table.php @@ -0,0 +1,32 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->foreignId('product_id')->constrained()->cascadeOnDelete(); + $table->foreignId('warehouse_id')->constrained()->cascadeOnDelete(); + $table->string('lot_number'); + $table->date('manufacture_date')->nullable(); + $table->date('expiry_date')->nullable(); + $table->integer('quantity_received')->default(0); + $table->integer('quantity_remaining')->default(0); + $table->string('status')->default('active'); + $table->text('notes')->nullable(); + $table->timestamps(); + $table->unique(['tenant_id', 'product_id', 'lot_number']); + }); + } + + public function down(): void + { + Schema::dropIfExists('lot_numbers'); + } +}; diff --git a/erp/database/migrations/2026_07_20_100002_create_serial_numbers_table.php b/erp/database/migrations/2026_07_20_100002_create_serial_numbers_table.php new file mode 100644 index 00000000000..f776856cc41 --- /dev/null +++ b/erp/database/migrations/2026_07_20_100002_create_serial_numbers_table.php @@ -0,0 +1,31 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->foreignId('product_id')->constrained()->cascadeOnDelete(); + $table->foreignId('warehouse_id')->constrained()->cascadeOnDelete(); + $table->string('serial_number'); + $table->string('status')->default('in_stock'); + $table->date('received_date')->nullable(); + $table->date('sold_date')->nullable(); + $table->unsignedBigInteger('lot_number_id')->nullable(); + $table->text('notes')->nullable(); + $table->timestamps(); + $table->unique(['tenant_id', 'serial_number']); + }); + } + + public function down(): void + { + Schema::dropIfExists('serial_numbers'); + } +}; diff --git a/erp/database/migrations/2026_07_21_100001_create_timesheets_table.php b/erp/database/migrations/2026_07_21_100001_create_timesheets_table.php new file mode 100644 index 00000000000..139c64db125 --- /dev/null +++ b/erp/database/migrations/2026_07_21_100001_create_timesheets_table.php @@ -0,0 +1,31 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->unsignedBigInteger('employee_id'); + $table->date('week_start'); + $table->date('week_end'); + $table->enum('status', ['draft', 'submitted', 'approved', 'rejected'])->default('draft'); + $table->decimal('total_hours', 6, 2)->default(0); + $table->unsignedBigInteger('approved_by')->nullable(); + $table->timestamp('approved_at')->nullable(); + $table->text('notes')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('timesheets'); + } +}; diff --git a/erp/database/migrations/2026_07_21_100002_create_timesheet_entries_table.php b/erp/database/migrations/2026_07_21_100002_create_timesheet_entries_table.php new file mode 100644 index 00000000000..6861417a235 --- /dev/null +++ b/erp/database/migrations/2026_07_21_100002_create_timesheet_entries_table.php @@ -0,0 +1,27 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->unsignedBigInteger('timesheet_id'); + $table->date('work_date'); + $table->decimal('hours', 5, 2); + $table->string('project')->nullable(); + $table->text('description')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('timesheet_entries'); + } +}; diff --git a/erp/database/migrations/2026_07_22_100001_add_party_fields_to_contracts_table.php b/erp/database/migrations/2026_07_22_100001_add_party_fields_to_contracts_table.php new file mode 100644 index 00000000000..64345fde480 --- /dev/null +++ b/erp/database/migrations/2026_07_22_100001_add_party_fields_to_contracts_table.php @@ -0,0 +1,39 @@ +string('contract_number')->nullable()->unique()->after('tenant_id'); + } + if (!Schema::hasColumn('contracts', 'party_name')) { + $table->string('party_name')->nullable()->after('title'); + } + if (!Schema::hasColumn('contracts', 'party_email')) { + $table->string('party_email')->nullable()->after('party_name'); + } + if (!Schema::hasColumn('contracts', 'created_by')) { + $table->unsignedBigInteger('created_by')->nullable()->after('terms'); + } + if (!Schema::hasColumn('contracts', 'terminated_at')) { + $table->timestamp('terminated_at')->nullable()->after('signed_at'); + } + if (!Schema::hasColumn('contracts', 'notes')) { + $table->text('notes')->nullable()->after('description'); + } + }); + } + + public function down(): void + { + Schema::table('contracts', function (Blueprint $table) { + $table->dropColumn(array_filter(['contract_number', 'party_name', 'party_email', 'created_by', 'terminated_at', 'notes'], fn($col) => Schema::hasColumn('contracts', $col))); + }); + } +}; diff --git a/erp/database/migrations/2026_07_22_100002_create_contract_renewals_table.php b/erp/database/migrations/2026_07_22_100002_create_contract_renewals_table.php new file mode 100644 index 00000000000..303163ce2b6 --- /dev/null +++ b/erp/database/migrations/2026_07_22_100002_create_contract_renewals_table.php @@ -0,0 +1,27 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->unsignedBigInteger('contract_id'); + $table->date('new_end_date'); + $table->decimal('new_value', 15, 2)->nullable(); + $table->text('notes')->nullable(); + $table->unsignedBigInteger('renewed_by'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('contract_renewals'); + } +}; diff --git a/erp/database/migrations/2026_07_23_100001_create_audit_logs_table.php b/erp/database/migrations/2026_07_23_100001_create_audit_logs_table.php new file mode 100644 index 00000000000..3ffc7fafe24 --- /dev/null +++ b/erp/database/migrations/2026_07_23_100001_create_audit_logs_table.php @@ -0,0 +1,20 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->string('name'); + $table->string('type'); // health, dental, vision, life, retirement, other + $table->text('description')->nullable(); + $table->decimal('employee_cost', 10, 2)->default(0); // monthly employee contribution + $table->decimal('employer_cost', 10, 2)->default(0); // monthly employer contribution + $table->boolean('is_active')->default(true); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('benefit_plans'); + } +}; diff --git a/erp/database/migrations/2026_07_24_100002_create_employee_benefits_table.php b/erp/database/migrations/2026_07_24_100002_create_employee_benefits_table.php new file mode 100644 index 00000000000..f639691f9ed --- /dev/null +++ b/erp/database/migrations/2026_07_24_100002_create_employee_benefits_table.php @@ -0,0 +1,28 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->unsignedBigInteger('employee_id'); + $table->unsignedBigInteger('benefit_plan_id'); + $table->date('enrolled_at'); + $table->date('ended_at')->nullable(); + $table->enum('status', ['active', 'waived', 'ended'])->default('active'); + $table->text('notes')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('employee_benefits'); + } +}; diff --git a/erp/database/migrations/2026_07_25_100001_create_purchase_orders_table.php b/erp/database/migrations/2026_07_25_100001_create_purchase_orders_table.php new file mode 100644 index 00000000000..e6caabf77d3 --- /dev/null +++ b/erp/database/migrations/2026_07_25_100001_create_purchase_orders_table.php @@ -0,0 +1,59 @@ +unsignedBigInteger('supplier_id')->nullable()->change(); + $table->unsignedBigInteger('warehouse_id')->nullable()->change(); + }); + + Schema::table('purchase_orders', function (Blueprint $table) { + if (!Schema::hasColumn('purchase_orders', 'po_number')) { + $table->string('po_number')->nullable(); + } + if (!Schema::hasColumn('purchase_orders', 'requisition_id')) { + $table->unsignedBigInteger('requisition_id')->nullable(); + } + if (!Schema::hasColumn('purchase_orders', 'order_date')) { + $table->date('order_date')->nullable(); + } + if (!Schema::hasColumn('purchase_orders', 'subtotal')) { + $table->decimal('subtotal', 15, 2)->default(0); + } + if (!Schema::hasColumn('purchase_orders', 'tax')) { + $table->decimal('tax', 15, 2)->default(0); + } + if (!Schema::hasColumn('purchase_orders', 'total')) { + $table->decimal('total', 15, 2)->default(0); + } + if (!Schema::hasColumn('purchase_orders', 'currency')) { + $table->string('currency', 3)->default('USD'); + } + if (!Schema::hasColumn('purchase_orders', 'sent_at')) { + $table->timestamp('sent_at')->nullable(); + } + if (!Schema::hasColumn('purchase_orders', 'received_at')) { + $table->timestamp('received_at')->nullable(); + } + }); + + // Drop index that references status, then replace enum with plain string + Schema::table('purchase_orders', function (Blueprint $table) { + $table->dropIndex('purchase_orders_tenant_id_status_index'); + }); + Schema::table('purchase_orders', function (Blueprint $table) { + $table->dropColumn('status'); + }); + Schema::table('purchase_orders', function (Blueprint $table) { + $table->string('status')->default('draft'); + }); + } + + public function down(): void {} +}; diff --git a/erp/database/migrations/2026_07_25_100002_create_purchase_order_items_table.php b/erp/database/migrations/2026_07_25_100002_create_purchase_order_items_table.php new file mode 100644 index 00000000000..1e983e49473 --- /dev/null +++ b/erp/database/migrations/2026_07_25_100002_create_purchase_order_items_table.php @@ -0,0 +1,43 @@ +unsignedBigInteger('product_id')->nullable()->change(); + }); + + Schema::table('purchase_order_items', function (Blueprint $table) { + if (!Schema::hasColumn('purchase_order_items', 'tenant_id')) { + $table->unsignedBigInteger('tenant_id')->nullable(); + } + if (!Schema::hasColumn('purchase_order_items', 'description')) { + $table->string('description')->nullable(); + } + // Rename unit_cost -> unit_price if needed + if (Schema::hasColumn('purchase_order_items', 'unit_cost') && !Schema::hasColumn('purchase_order_items', 'unit_price')) { + $table->renameColumn('unit_cost', 'unit_price'); + } elseif (!Schema::hasColumn('purchase_order_items', 'unit_price')) { + $table->decimal('unit_price', 15, 2)->nullable()->default(0); + } + // Rename received_quantity -> received_qty if needed + if (Schema::hasColumn('purchase_order_items', 'received_quantity') && !Schema::hasColumn('purchase_order_items', 'received_qty')) { + $table->renameColumn('received_quantity', 'received_qty'); + } elseif (!Schema::hasColumn('purchase_order_items', 'received_qty')) { + $table->decimal('received_qty', 10, 2)->default(0); + } + }); + + // Make unit_price nullable to avoid breaking existing tests that don't provide it + Schema::table('purchase_order_items', function (Blueprint $table) { + $table->decimal('unit_price', 15, 2)->nullable()->default(0)->change(); + }); + } + + public function down(): void {} +}; diff --git a/erp/database/migrations/2026_07_26_100001_add_phase96_columns_to_performance_reviews_table.php b/erp/database/migrations/2026_07_26_100001_add_phase96_columns_to_performance_reviews_table.php new file mode 100644 index 00000000000..d8b25e3dd3a --- /dev/null +++ b/erp/database/migrations/2026_07_26_100001_add_phase96_columns_to_performance_reviews_table.php @@ -0,0 +1,25 @@ +string('period')->nullable()->after('reviewer_id'); + $table->text('employee_comments')->nullable()->after('goals'); + $table->timestamp('submitted_at')->nullable()->after('employee_comments'); + $table->timestamp('acknowledged_at')->nullable()->after('submitted_at'); + }); + } + + public function down(): void + { + Schema::table('performance_reviews', function (Blueprint $table) { + $table->dropColumn(['period', 'employee_comments', 'submitted_at', 'acknowledged_at']); + }); + } +}; diff --git a/erp/database/migrations/2026_07_26_100002_create_review_ratings_table.php b/erp/database/migrations/2026_07_26_100002_create_review_ratings_table.php new file mode 100644 index 00000000000..ac0de781d93 --- /dev/null +++ b/erp/database/migrations/2026_07_26_100002_create_review_ratings_table.php @@ -0,0 +1,28 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->unsignedBigInteger('performance_review_id'); + $table->string('competency'); + $table->tinyInteger('rating'); + $table->text('notes')->nullable(); + $table->timestamps(); + + $table->index(['tenant_id', 'performance_review_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('review_ratings'); + } +}; diff --git a/erp/database/migrations/2026_07_26_100003_make_review_period_nullable_in_performance_reviews.php b/erp/database/migrations/2026_07_26_100003_make_review_period_nullable_in_performance_reviews.php new file mode 100644 index 00000000000..ddba4956916 --- /dev/null +++ b/erp/database/migrations/2026_07_26_100003_make_review_period_nullable_in_performance_reviews.php @@ -0,0 +1,22 @@ +string('review_period')->nullable()->change(); + }); + } + + public function down(): void + { + Schema::table('performance_reviews', function (Blueprint $table) { + $table->string('review_period')->nullable(false)->change(); + }); + } +}; diff --git a/erp/database/migrations/2026_07_27_100001_create_expense_claims_table.php b/erp/database/migrations/2026_07_27_100001_create_expense_claims_table.php new file mode 100644 index 00000000000..ded8278985d --- /dev/null +++ b/erp/database/migrations/2026_07_27_100001_create_expense_claims_table.php @@ -0,0 +1,39 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->string('reference')->unique(); + $table->unsignedBigInteger('submitted_by'); + $table->unsignedBigInteger('approved_by')->nullable(); + $table->string('status')->default('draft'); + $table->date('claim_date'); + $table->string('currency', 3)->default('USD'); + $table->decimal('total_amount', 15, 2)->default(0); + $table->text('notes')->nullable(); + $table->timestamp('submitted_at')->nullable(); + $table->timestamp('approved_at')->nullable(); + $table->timestamp('paid_at')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('finance_expense_claims'); + } +}; diff --git a/erp/database/migrations/2026_07_27_100002_create_expense_items_table.php b/erp/database/migrations/2026_07_27_100002_create_expense_items_table.php new file mode 100644 index 00000000000..ece73897aeb --- /dev/null +++ b/erp/database/migrations/2026_07_27_100002_create_expense_items_table.php @@ -0,0 +1,28 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->unsignedBigInteger('expense_claim_id'); + $table->string('category'); + $table->date('expense_date'); + $table->string('description'); + $table->decimal('amount', 15, 2); + $table->string('receipt_url')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('finance_expense_items'); + } +}; diff --git a/erp/database/migrations/2026_07_28_100001_create_sales_orders_table.php b/erp/database/migrations/2026_07_28_100001_create_sales_orders_table.php new file mode 100644 index 00000000000..e00ae45476b --- /dev/null +++ b/erp/database/migrations/2026_07_28_100001_create_sales_orders_table.php @@ -0,0 +1,48 @@ +string('so_number')->nullable()->unique(); + } + if (!Schema::hasColumn('sales_orders', 'customer_id')) { + $table->unsignedBigInteger('customer_id')->nullable(); + } + if (!Schema::hasColumn('sales_orders', 'subtotal')) { + $table->decimal('subtotal', 15, 2)->default(0); + } + if (!Schema::hasColumn('sales_orders', 'tax')) { + $table->decimal('tax', 15, 2)->default(0); + } + if (!Schema::hasColumn('sales_orders', 'total')) { + $table->decimal('total', 15, 2)->default(0); + } + if (!Schema::hasColumn('sales_orders', 'currency')) { + $table->string('currency', 3)->default('USD'); + } + if (!Schema::hasColumn('sales_orders', 'confirmed_at')) { + $table->timestamp('confirmed_at')->nullable(); + } + if (!Schema::hasColumn('sales_orders', 'shipped_at')) { + $table->timestamp('shipped_at')->nullable(); + } + if (!Schema::hasColumn('sales_orders', 'delivered_at')) { + $table->timestamp('delivered_at')->nullable(); + } + }); + + // Replace enum status with a plain string to support shipped/delivered + Schema::table('sales_orders', function (Blueprint $table) { + $table->string('status')->default('draft')->change(); + }); + } + + public function down(): void {} +}; diff --git a/erp/database/migrations/2026_07_28_100002_create_sales_order_items_table.php b/erp/database/migrations/2026_07_28_100002_create_sales_order_items_table.php new file mode 100644 index 00000000000..93ab2f6c077 --- /dev/null +++ b/erp/database/migrations/2026_07_28_100002_create_sales_order_items_table.php @@ -0,0 +1,22 @@ +unsignedBigInteger('tenant_id')->nullable(); + } + if (!Schema::hasColumn('sales_order_items', 'shipped_qty')) { + $table->decimal('shipped_qty', 10, 2)->default(0); + } + }); + } + + public function down(): void {} +}; diff --git a/erp/database/migrations/2026_07_29_100001_create_job_positions_table.php b/erp/database/migrations/2026_07_29_100001_create_job_positions_table.php new file mode 100644 index 00000000000..4e09ae58b91 --- /dev/null +++ b/erp/database/migrations/2026_07_29_100001_create_job_positions_table.php @@ -0,0 +1,41 @@ +string('department')->nullable()->after('title'); + } + if (!Schema::hasColumn('job_positions', 'salary_min')) { + $table->decimal('salary_min', 12, 2)->nullable()->after('requirements'); + } + if (!Schema::hasColumn('job_positions', 'salary_max')) { + $table->decimal('salary_max', 12, 2)->nullable()->after('salary_min'); + } + if (!Schema::hasColumn('job_positions', 'is_active')) { + $table->boolean('is_active')->default(true)->after('salary_max'); + } + if (!Schema::hasColumn('job_positions', 'closes_at')) { + $table->date('closes_at')->nullable()->after('posted_at'); + } + }); + } + + public function down(): void + { + Schema::table('job_positions', function (Blueprint $table) { + $columns = ['department', 'salary_min', 'salary_max', 'is_active', 'closes_at']; + foreach ($columns as $col) { + if (Schema::hasColumn('job_positions', $col)) { + $table->dropColumn($col); + } + } + }); + } +}; diff --git a/erp/database/migrations/2026_07_29_100002_create_job_applications_table.php b/erp/database/migrations/2026_07_29_100002_create_job_applications_table.php new file mode 100644 index 00000000000..54170d34113 --- /dev/null +++ b/erp/database/migrations/2026_07_29_100002_create_job_applications_table.php @@ -0,0 +1,38 @@ +string('status')->default('new')->after('source'); + } + if (!Schema::hasColumn('job_applications', 'resume_url')) { + $table->string('resume_url')->nullable()->after('cover_letter'); + } + if (!Schema::hasColumn('job_applications', 'reviewed_by')) { + $table->unsignedBigInteger('reviewed_by')->nullable()->after('notes'); + } + if (!Schema::hasColumn('job_applications', 'reviewed_at')) { + $table->timestamp('reviewed_at')->nullable()->after('reviewed_by'); + } + }); + } + + public function down(): void + { + Schema::table('job_applications', function (Blueprint $table) { + $columns = ['status', 'resume_url', 'reviewed_by', 'reviewed_at']; + foreach ($columns as $col) { + if (Schema::hasColumn('job_applications', $col)) { + $table->dropColumn($col); + } + } + }); + } +}; diff --git a/erp/database/migrations/2026_07_30_100001_create_work_schedules_table.php b/erp/database/migrations/2026_07_30_100001_create_work_schedules_table.php new file mode 100644 index 00000000000..46364f3f5af --- /dev/null +++ b/erp/database/migrations/2026_07_30_100001_create_work_schedules_table.php @@ -0,0 +1,25 @@ +string('timezone')->default('UTC')->after('name'); + $table->integer('hours_per_week')->default(40)->after('timezone'); + $table->boolean('is_active')->default(true)->after('hours_per_week'); + $table->text('description')->nullable()->after('is_active'); + }); + } + + public function down(): void + { + Schema::table('work_schedules', function (Blueprint $table) { + $table->dropColumn(['timezone', 'hours_per_week', 'is_active', 'description']); + }); + } +}; diff --git a/erp/database/migrations/2026_07_30_100002_create_work_schedule_shifts_table.php b/erp/database/migrations/2026_07_30_100002_create_work_schedule_shifts_table.php new file mode 100644 index 00000000000..06d891cf2b7 --- /dev/null +++ b/erp/database/migrations/2026_07_30_100002_create_work_schedule_shifts_table.php @@ -0,0 +1,27 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->unsignedBigInteger('work_schedule_id'); + $table->string('day_of_week'); // monday, tuesday, ..., sunday + $table->time('start_time'); + $table->time('end_time'); + $table->decimal('break_minutes', 5, 2)->default(0); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('work_schedule_shifts'); + } +}; diff --git a/erp/database/migrations/2026_07_30_100003_create_employee_schedules_table.php b/erp/database/migrations/2026_07_30_100003_create_employee_schedules_table.php new file mode 100644 index 00000000000..ccceee812b4 --- /dev/null +++ b/erp/database/migrations/2026_07_30_100003_create_employee_schedules_table.php @@ -0,0 +1,27 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->unsignedBigInteger('employee_id'); + $table->unsignedBigInteger('work_schedule_id'); + $table->date('effective_from'); + $table->date('effective_to')->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('employee_schedules'); + } +}; diff --git a/erp/database/migrations/2026_07_31_100001_create_price_lists_table.php b/erp/database/migrations/2026_07_31_100001_create_price_lists_table.php new file mode 100644 index 00000000000..d6a53b4269c --- /dev/null +++ b/erp/database/migrations/2026_07_31_100001_create_price_lists_table.php @@ -0,0 +1,53 @@ +string('currency', 3)->default('USD')->after('name'); + } + if (! Schema::hasColumn('price_lists', 'notes')) { + $table->text('notes')->nullable()->after('valid_to'); + } + }); + + // Add price column to price_list_items + Schema::table('price_list_items', function (Blueprint $table) { + if (! Schema::hasColumn('price_list_items', 'price')) { + $table->decimal('price', 15, 2)->default(0)->after('product_id'); + } + // Make unit_price nullable so Inventory inserts (which don't set unit_price) work + if (Schema::hasColumn('price_list_items', 'unit_price')) { + $table->decimal('unit_price', 14, 4)->nullable()->default(null)->change(); + } + }); + } + + public function down(): void + { + Schema::table('price_lists', function (Blueprint $table) { + if (Schema::hasColumn('price_lists', 'currency')) { + $table->dropColumn('currency'); + } + if (Schema::hasColumn('price_lists', 'notes')) { + $table->dropColumn('notes'); + } + }); + + Schema::table('price_list_items', function (Blueprint $table) { + if (Schema::hasColumn('price_list_items', 'price')) { + $table->dropColumn('price'); + } + if (Schema::hasColumn('price_list_items', 'unit_price')) { + $table->decimal('unit_price', 14, 4)->nullable(false)->change(); + } + }); + } +}; diff --git a/erp/database/migrations/2026_07_31_100002_create_price_list_items_table.php b/erp/database/migrations/2026_07_31_100002_create_price_list_items_table.php new file mode 100644 index 00000000000..2b58221cd64 --- /dev/null +++ b/erp/database/migrations/2026_07_31_100002_create_price_list_items_table.php @@ -0,0 +1,12 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->unsignedBigInteger('customer_id')->nullable(); + $table->string('discount_type'); // percentage, fixed + $table->decimal('discount_value', 10, 2); + $table->string('applies_to')->default('all'); // all, category, product + $table->unsignedBigInteger('applies_to_id')->nullable(); + $table->date('valid_from')->nullable(); + $table->date('valid_to')->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('customer_discounts'); + } +}; diff --git a/erp/database/migrations/2026_08_01_100001_create_vendor_bills_table.php b/erp/database/migrations/2026_08_01_100001_create_vendor_bills_table.php new file mode 100644 index 00000000000..19ea1243075 --- /dev/null +++ b/erp/database/migrations/2026_08_01_100001_create_vendor_bills_table.php @@ -0,0 +1,38 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->string('bill_number')->unique(); + $table->unsignedBigInteger('supplier_id')->nullable(); + $table->string('reference')->nullable(); + $table->string('status')->default('draft'); // draft, pending, approved, paid, cancelled + $table->date('bill_date'); + $table->date('due_date')->nullable(); + $table->string('currency', 3)->default('USD'); + $table->decimal('subtotal', 15, 2)->default(0); + $table->decimal('tax', 15, 2)->default(0); + $table->decimal('total', 15, 2)->default(0); + $table->text('notes')->nullable(); + $table->unsignedBigInteger('created_by')->nullable(); + $table->unsignedBigInteger('approved_by')->nullable(); + $table->timestamp('approved_at')->nullable(); + $table->timestamp('paid_at')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('vendor_bills'); + } +}; diff --git a/erp/database/migrations/2026_08_01_100002_create_vendor_bill_items_table.php b/erp/database/migrations/2026_08_01_100002_create_vendor_bill_items_table.php new file mode 100644 index 00000000000..5db3dfe5acc --- /dev/null +++ b/erp/database/migrations/2026_08_01_100002_create_vendor_bill_items_table.php @@ -0,0 +1,27 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->unsignedBigInteger('vendor_bill_id'); + $table->unsignedBigInteger('product_id')->nullable(); + $table->string('description'); + $table->decimal('quantity', 10, 2)->default(1); + $table->decimal('unit_price', 15, 2)->default(0); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('vendor_bill_items'); + } +}; diff --git a/erp/database/migrations/2026_08_02_100001_add_phase103_columns_to_credit_notes_table.php b/erp/database/migrations/2026_08_02_100001_add_phase103_columns_to_credit_notes_table.php new file mode 100644 index 00000000000..8302ad27f16 --- /dev/null +++ b/erp/database/migrations/2026_08_02_100001_add_phase103_columns_to_credit_notes_table.php @@ -0,0 +1,50 @@ +string('credit_note_number')->unique()->nullable()->after('tenant_id'); + } + if (! Schema::hasColumn('credit_notes', 'customer_id')) { + $table->unsignedBigInteger('customer_id')->nullable(); + } + if (! Schema::hasColumn('credit_notes', 'currency')) { + $table->string('currency', 3)->default('USD'); + } + if (! Schema::hasColumn('credit_notes', 'subtotal')) { + $table->decimal('subtotal', 15, 2)->default(0); + } + if (! Schema::hasColumn('credit_notes', 'tax')) { + $table->decimal('tax', 15, 2)->default(0); + } + if (! Schema::hasColumn('credit_notes', 'total')) { + $table->decimal('total', 15, 2)->default(0); + } + if (! Schema::hasColumn('credit_notes', 'reason')) { + $table->text('reason')->nullable(); + } + if (! Schema::hasColumn('credit_notes', 'created_by')) { + $table->unsignedBigInteger('created_by')->nullable(); + } + }); + } + + public function down(): void + { + Schema::table('credit_notes', function (Blueprint $table) { + $cols = Schema::getColumnListing('credit_notes'); + foreach (['credit_note_number', 'customer_id', 'currency', 'subtotal', 'tax', 'total', 'reason', 'created_by'] as $col) { + if (in_array($col, $cols)) { + $table->dropColumn($col); + } + } + }); + } +}; diff --git a/erp/database/migrations/2026_08_02_100002_add_tenant_id_to_credit_note_items_table.php b/erp/database/migrations/2026_08_02_100002_add_tenant_id_to_credit_note_items_table.php new file mode 100644 index 00000000000..88108641f33 --- /dev/null +++ b/erp/database/migrations/2026_08_02_100002_add_tenant_id_to_credit_note_items_table.php @@ -0,0 +1,26 @@ +unsignedBigInteger('tenant_id')->nullable()->after('id'); + }); + } + } + + public function down(): void + { + if (Schema::hasColumn('credit_note_items', 'tenant_id')) { + Schema::table('credit_note_items', function (Blueprint $table) { + $table->dropColumn('tenant_id'); + }); + } + } +}; diff --git a/erp/database/migrations/2026_08_03_100001_create_employee_documents_table.php b/erp/database/migrations/2026_08_03_100001_create_employee_documents_table.php new file mode 100644 index 00000000000..f9726a4791b --- /dev/null +++ b/erp/database/migrations/2026_08_03_100001_create_employee_documents_table.php @@ -0,0 +1,34 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->unsignedBigInteger('employee_id'); + $table->string('document_type'); // contract, id, passport, permit, certificate, other + $table->string('document_name'); + $table->string('document_number')->nullable(); + $table->string('file_url')->nullable(); + $table->date('issued_date')->nullable(); + $table->date('expiry_date')->nullable(); + $table->boolean('is_verified')->default(false); + $table->unsignedBigInteger('verified_by')->nullable(); + $table->timestamp('verified_at')->nullable(); + $table->text('notes')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('employee_documents'); + } +}; diff --git a/erp/database/migrations/2026_08_04_100001_create_payment_terms_table.php b/erp/database/migrations/2026_08_04_100001_create_payment_terms_table.php new file mode 100644 index 00000000000..7b244b25c39 --- /dev/null +++ b/erp/database/migrations/2026_08_04_100001_create_payment_terms_table.php @@ -0,0 +1,29 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->string('name'); + $table->unsignedInteger('days'); + $table->unsignedInteger('discount_days')->default(0); + $table->decimal('discount_percent', 5, 2)->default(0); + $table->text('description')->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('payment_terms'); + } +}; diff --git a/erp/database/migrations/2026_08_05_100001_create_skill_definitions_table.php b/erp/database/migrations/2026_08_05_100001_create_skill_definitions_table.php new file mode 100644 index 00000000000..d8cfab43f06 --- /dev/null +++ b/erp/database/migrations/2026_08_05_100001_create_skill_definitions_table.php @@ -0,0 +1,26 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->string('name'); + $table->string('category')->nullable(); // e.g. technical, soft, language, management + $table->text('description')->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('skill_definitions'); + } +}; diff --git a/erp/database/migrations/2026_08_05_100002_create_employee_skills_table.php b/erp/database/migrations/2026_08_05_100002_create_employee_skills_table.php new file mode 100644 index 00000000000..337c683eb9c --- /dev/null +++ b/erp/database/migrations/2026_08_05_100002_create_employee_skills_table.php @@ -0,0 +1,31 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->unsignedBigInteger('employee_id'); + $table->unsignedBigInteger('skill_definition_id')->nullable(); + $table->string('skill_name'); // denormalized for easy querying even without definition + $table->unsignedTinyInteger('proficiency_level')->default(1); // 1=beginner, 2=basic, 3=intermediate, 4=advanced, 5=expert + $table->boolean('is_verified')->default(false); + $table->unsignedBigInteger('verified_by')->nullable(); + $table->timestamp('verified_at')->nullable(); + $table->date('acquired_date')->nullable(); + $table->text('notes')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('employee_skills'); + } +}; diff --git a/erp/database/migrations/2026_08_06_100001_create_hr_announcements_table.php b/erp/database/migrations/2026_08_06_100001_create_hr_announcements_table.php new file mode 100644 index 00000000000..d12195d763b --- /dev/null +++ b/erp/database/migrations/2026_08_06_100001_create_hr_announcements_table.php @@ -0,0 +1,32 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->string('title'); + $table->text('body'); + $table->string('target_audience')->default('all'); // all, department, role + $table->unsignedBigInteger('department_id')->nullable(); + $table->boolean('is_published')->default(false); + $table->timestamp('publish_at')->nullable(); + $table->timestamp('expire_at')->nullable(); + $table->unsignedBigInteger('created_by')->nullable(); + $table->string('priority')->default('normal'); // low, normal, high, urgent + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('hr_announcements'); + } +}; diff --git a/erp/database/migrations/2026_08_07_100001_add_columns_to_units_of_measure_table.php b/erp/database/migrations/2026_08_07_100001_add_columns_to_units_of_measure_table.php new file mode 100644 index 00000000000..316d335b795 --- /dev/null +++ b/erp/database/migrations/2026_08_07_100001_add_columns_to_units_of_measure_table.php @@ -0,0 +1,33 @@ +string('type')->default('unit')->after('abbreviation'); + } + if (! Schema::hasColumn('units_of_measure', 'is_base')) { + $table->boolean('is_base')->default(false)->after('type'); + } + if (! Schema::hasColumn('units_of_measure', 'conversion_factor')) { + $table->decimal('conversion_factor', 15, 6)->default(1.000000)->after('is_base'); + } + if (! Schema::hasColumn('units_of_measure', 'is_active')) { + $table->boolean('is_active')->default(true)->after('conversion_factor'); + } + }); + } + + public function down(): void + { + Schema::table('units_of_measure', function (Blueprint $table) { + $table->dropColumn(['type', 'is_base', 'conversion_factor', 'is_active']); + }); + } +}; diff --git a/erp/database/migrations/2026_08_08_100001_create_employee_exits_table.php b/erp/database/migrations/2026_08_08_100001_create_employee_exits_table.php new file mode 100644 index 00000000000..b4b3084a6ea --- /dev/null +++ b/erp/database/migrations/2026_08_08_100001_create_employee_exits_table.php @@ -0,0 +1,32 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->unsignedBigInteger('employee_id')->unique(); // one exit record per employee + $table->date('exit_date'); + $table->string('exit_type'); // resignation, termination, retirement, redundancy, contract_end + $table->text('reason')->nullable(); + $table->text('exit_interview_notes')->nullable(); + $table->boolean('equipment_returned')->default(false); + $table->boolean('access_revoked')->default(false); + $table->string('status')->default('pending'); // pending, in_progress, completed + $table->unsignedBigInteger('processed_by')->nullable(); + $table->timestamp('processed_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('employee_exits'); + } +}; diff --git a/erp/database/migrations/2026_08_09_100001_create_petty_cash_funds_table.php b/erp/database/migrations/2026_08_09_100001_create_petty_cash_funds_table.php new file mode 100644 index 00000000000..3188ecc0d62 --- /dev/null +++ b/erp/database/migrations/2026_08_09_100001_create_petty_cash_funds_table.php @@ -0,0 +1,30 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->string('name'); + $table->unsignedBigInteger('custodian_id')->nullable(); + $table->decimal('authorized_amount', 15, 2)->default(0); + $table->decimal('current_balance', 15, 2)->default(0); + $table->string('currency', 3)->default('USD'); + $table->boolean('is_active')->default(true); + $table->timestamp('last_replenished_at')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('petty_cash_funds'); + } +}; diff --git a/erp/database/migrations/2026_08_09_100002_create_petty_cash_transactions_table.php b/erp/database/migrations/2026_08_09_100002_create_petty_cash_transactions_table.php new file mode 100644 index 00000000000..73231a6e27f --- /dev/null +++ b/erp/database/migrations/2026_08_09_100002_create_petty_cash_transactions_table.php @@ -0,0 +1,30 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->unsignedBigInteger('fund_id'); + $table->string('type'); + $table->decimal('amount', 15, 2); + $table->string('description'); + $table->date('transaction_date'); + $table->string('reference')->nullable(); + $table->string('category')->nullable(); + $table->unsignedBigInteger('created_by')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('petty_cash_transactions'); + } +}; diff --git a/erp/database/migrations/2026_08_10_100001_create_cycle_counts_table.php b/erp/database/migrations/2026_08_10_100001_create_cycle_counts_table.php new file mode 100644 index 00000000000..33e5fbff1dd --- /dev/null +++ b/erp/database/migrations/2026_08_10_100001_create_cycle_counts_table.php @@ -0,0 +1,31 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->unsignedBigInteger('warehouse_id'); + $table->string('count_number')->unique(); + $table->date('count_date'); + $table->string('status')->default('draft'); // draft, in_progress, completed, cancelled + $table->text('notes')->nullable(); + $table->unsignedBigInteger('created_by')->nullable(); + $table->timestamp('started_at')->nullable(); + $table->timestamp('completed_at')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('cycle_counts'); + } +}; diff --git a/erp/database/migrations/2026_08_10_100002_create_cycle_count_items_table.php b/erp/database/migrations/2026_08_10_100002_create_cycle_count_items_table.php new file mode 100644 index 00000000000..3a56aadd227 --- /dev/null +++ b/erp/database/migrations/2026_08_10_100002_create_cycle_count_items_table.php @@ -0,0 +1,27 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->unsignedBigInteger('cycle_count_id'); + $table->unsignedBigInteger('product_id'); + $table->decimal('system_qty', 10, 2)->default(0); // qty from StockLevel at time of count + $table->decimal('counted_qty', 10, 2)->nullable(); // actual counted qty (null = not yet counted) + $table->text('notes')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('cycle_count_items'); + } +}; diff --git a/erp/database/migrations/2026_08_11_100001_create_employee_position_changes_table.php b/erp/database/migrations/2026_08_11_100001_create_employee_position_changes_table.php new file mode 100644 index 00000000000..a03bbffe191 --- /dev/null +++ b/erp/database/migrations/2026_08_11_100001_create_employee_position_changes_table.php @@ -0,0 +1,35 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->unsignedBigInteger('employee_id'); + $table->string('change_type'); // promotion, demotion, transfer, salary_change, title_change, department_change + $table->string('from_title')->nullable(); + $table->string('to_title')->nullable(); + $table->unsignedBigInteger('from_department_id')->nullable(); + $table->unsignedBigInteger('to_department_id')->nullable(); + $table->decimal('from_salary', 15, 2)->nullable(); + $table->decimal('to_salary', 15, 2)->nullable(); + $table->date('effective_date'); + $table->text('reason')->nullable(); + $table->unsignedBigInteger('approved_by')->nullable(); + $table->timestamp('approved_at')->nullable(); + $table->text('notes')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('employee_position_changes'); + } +}; diff --git a/erp/database/migrations/2026_08_12_100001_create_product_tags_table.php b/erp/database/migrations/2026_08_12_100001_create_product_tags_table.php new file mode 100644 index 00000000000..ade31b29bdf --- /dev/null +++ b/erp/database/migrations/2026_08_12_100001_create_product_tags_table.php @@ -0,0 +1,27 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->string('name'); + $table->string('color', 20)->default('#6366f1'); // hex color for UI badge + $table->text('description')->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + $table->unique(['tenant_id', 'name']); + }); + } + + public function down(): void + { + Schema::dropIfExists('product_tags'); + } +}; diff --git a/erp/database/migrations/2026_08_12_100002_create_product_tag_assignments_table.php b/erp/database/migrations/2026_08_12_100002_create_product_tag_assignments_table.php new file mode 100644 index 00000000000..8a2c7e6670a --- /dev/null +++ b/erp/database/migrations/2026_08_12_100002_create_product_tag_assignments_table.php @@ -0,0 +1,24 @@ +id(); + $table->unsignedBigInteger('product_id'); + $table->unsignedBigInteger('product_tag_id'); + $table->timestamps(); + $table->unique(['product_id', 'product_tag_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('product_tag_assignments'); + } +}; diff --git a/erp/database/migrations/2026_08_13_100001_create_bank_transfers_table.php b/erp/database/migrations/2026_08_13_100001_create_bank_transfers_table.php new file mode 100644 index 00000000000..8ae8048b00c --- /dev/null +++ b/erp/database/migrations/2026_08_13_100001_create_bank_transfers_table.php @@ -0,0 +1,33 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->unsignedBigInteger('from_account_id'); + $table->unsignedBigInteger('to_account_id'); + $table->decimal('amount', 15, 2); + $table->string('currency', 3)->default('USD'); + $table->date('transfer_date'); + $table->string('reference')->nullable(); + $table->string('status')->default('pending'); + $table->text('notes')->nullable(); + $table->unsignedBigInteger('created_by')->nullable(); + $table->timestamp('processed_at')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('bank_transfers'); + } +}; diff --git a/erp/database/migrations/2026_08_14_100001_create_salary_grades_table.php b/erp/database/migrations/2026_08_14_100001_create_salary_grades_table.php new file mode 100644 index 00000000000..596d767201f --- /dev/null +++ b/erp/database/migrations/2026_08_14_100001_create_salary_grades_table.php @@ -0,0 +1,32 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->string('name'); + $table->string('code')->nullable(); + $table->decimal('min_salary', 15, 2); + $table->decimal('mid_salary', 15, 2)->nullable(); + $table->decimal('max_salary', 15, 2); + $table->string('currency', 3)->default('USD'); + $table->text('description')->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + $table->softDeletes(); + $table->unique(['tenant_id', 'name']); + }); + } + + public function down(): void + { + Schema::dropIfExists('salary_grades'); + } +}; diff --git a/erp/database/migrations/2026_08_14_100002_add_salary_grade_id_to_employees_table.php b/erp/database/migrations/2026_08_14_100002_add_salary_grade_id_to_employees_table.php new file mode 100644 index 00000000000..d68d5fb743c --- /dev/null +++ b/erp/database/migrations/2026_08_14_100002_add_salary_grade_id_to_employees_table.php @@ -0,0 +1,26 @@ +unsignedBigInteger('salary_grade_id')->nullable()->after('salary_amount'); + } + }); + } + + public function down(): void + { + Schema::table('employees', function (Blueprint $table) { + if (Schema::hasColumn('employees', 'salary_grade_id')) { + $table->dropColumn('salary_grade_id'); + } + }); + } +}; diff --git a/erp/database/migrations/2026_08_15_100001_create_customer_groups_table.php b/erp/database/migrations/2026_08_15_100001_create_customer_groups_table.php new file mode 100644 index 00000000000..2d9478b63a2 --- /dev/null +++ b/erp/database/migrations/2026_08_15_100001_create_customer_groups_table.php @@ -0,0 +1,30 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->string('name'); + $table->text('description')->nullable(); + $table->decimal('discount_percent', 5, 2)->default(0); + $table->decimal('credit_limit', 15, 2)->default(0); + $table->unsignedBigInteger('payment_term_id')->nullable(); + $table->string('currency', 3)->default('USD'); + $table->boolean('is_active')->default(true); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('customer_groups'); + } +}; diff --git a/erp/database/migrations/2026_08_15_100002_create_customer_group_members_table.php b/erp/database/migrations/2026_08_15_100002_create_customer_group_members_table.php new file mode 100644 index 00000000000..465952f422c --- /dev/null +++ b/erp/database/migrations/2026_08_15_100002_create_customer_group_members_table.php @@ -0,0 +1,24 @@ +id(); + $table->unsignedBigInteger('customer_group_id'); + $table->unsignedBigInteger('contact_id'); + $table->timestamps(); + $table->unique(['customer_group_id', 'contact_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('customer_group_members'); + } +}; diff --git a/erp/database/migrations/2026_08_16_100001_create_advance_payments_table.php b/erp/database/migrations/2026_08_16_100001_create_advance_payments_table.php new file mode 100644 index 00000000000..3ea0ac78328 --- /dev/null +++ b/erp/database/migrations/2026_08_16_100001_create_advance_payments_table.php @@ -0,0 +1,34 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->unsignedBigInteger('contact_id')->nullable(); + $table->string('reference')->nullable(); + $table->decimal('amount', 15, 2); + $table->decimal('applied_amount', 15, 2)->default(0); + $table->string('currency', 3)->default('USD'); + $table->date('payment_date'); + $table->string('status')->default('received'); + $table->string('payment_method')->nullable(); + $table->text('notes')->nullable(); + $table->unsignedBigInteger('created_by')->nullable(); + $table->timestamp('refunded_at')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('advance_payments'); + } +}; diff --git a/erp/database/migrations/2026_08_17_100001_create_product_substitutes_table.php b/erp/database/migrations/2026_08_17_100001_create_product_substitutes_table.php new file mode 100644 index 00000000000..7ff7a526c08 --- /dev/null +++ b/erp/database/migrations/2026_08_17_100001_create_product_substitutes_table.php @@ -0,0 +1,29 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->unsignedBigInteger('product_id'); + $table->unsignedBigInteger('substitute_product_id'); + $table->unsignedTinyInteger('priority')->default(1); + $table->boolean('is_bidirectional')->default(false); + $table->boolean('is_active')->default(true); + $table->text('notes')->nullable(); + $table->timestamps(); + $table->unique(['product_id', 'substitute_product_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('product_substitutes'); + } +}; diff --git a/erp/database/migrations/2026_08_18_100001_create_overtime_requests_table.php b/erp/database/migrations/2026_08_18_100001_create_overtime_requests_table.php new file mode 100644 index 00000000000..32c267f9665 --- /dev/null +++ b/erp/database/migrations/2026_08_18_100001_create_overtime_requests_table.php @@ -0,0 +1,31 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->unsignedBigInteger('employee_id'); + $table->date('work_date'); + $table->decimal('hours', 5, 2); + $table->decimal('rate_multiplier', 4, 2)->default(1.5); + $table->text('reason')->nullable(); + $table->string('status')->default('pending'); // pending/approved/rejected/cancelled + $table->unsignedBigInteger('approved_by')->nullable(); + $table->timestamp('approved_at')->nullable(); + $table->text('rejection_reason')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('overtime_requests'); + } +}; diff --git a/erp/database/migrations/2026_08_19_100001_create_debit_notes_table.php b/erp/database/migrations/2026_08_19_100001_create_debit_notes_table.php new file mode 100644 index 00000000000..d6eb04c0857 --- /dev/null +++ b/erp/database/migrations/2026_08_19_100001_create_debit_notes_table.php @@ -0,0 +1,34 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->string('debit_note_number')->nullable(); + $table->unsignedBigInteger('vendor_id')->nullable(); // references contacts + $table->unsignedBigInteger('vendor_bill_id')->nullable(); // optional link to bill + $table->date('issue_date'); + $table->string('currency')->default('USD'); + $table->decimal('subtotal', 15, 2)->default(0); + $table->decimal('tax', 15, 2)->default(0); + $table->decimal('total', 15, 2)->default(0); + $table->string('status')->default('draft'); // draft/issued/applied/void + $table->text('reason')->nullable(); + $table->unsignedBigInteger('created_by')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('debit_notes'); + } +}; diff --git a/erp/database/migrations/2026_08_19_100002_create_debit_note_items_table.php b/erp/database/migrations/2026_08_19_100002_create_debit_note_items_table.php new file mode 100644 index 00000000000..49d9f89475d --- /dev/null +++ b/erp/database/migrations/2026_08_19_100002_create_debit_note_items_table.php @@ -0,0 +1,28 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->unsignedBigInteger('debit_note_id'); + $table->string('description'); + $table->decimal('quantity', 10, 2)->default(1); + $table->decimal('unit_price', 15, 2)->default(0); + $table->decimal('tax_rate', 5, 2)->default(0); + $table->decimal('line_total', 15, 2)->default(0); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('debit_note_items'); + } +}; diff --git a/erp/database/migrations/2026_08_20_100001_create_employee_surveys_table.php b/erp/database/migrations/2026_08_20_100001_create_employee_surveys_table.php new file mode 100644 index 00000000000..2b91c3cf57f --- /dev/null +++ b/erp/database/migrations/2026_08_20_100001_create_employee_surveys_table.php @@ -0,0 +1,30 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->string('title'); + $table->text('description')->nullable(); + $table->string('status')->default('draft'); // draft/published/closed + $table->date('start_date')->nullable(); + $table->date('end_date')->nullable(); + $table->boolean('is_anonymous')->default(false); + $table->unsignedBigInteger('created_by')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('employee_surveys'); + } +}; diff --git a/erp/database/migrations/2026_08_20_100002_create_survey_questions_table.php b/erp/database/migrations/2026_08_20_100002_create_survey_questions_table.php new file mode 100644 index 00000000000..a5e63e6dd8e --- /dev/null +++ b/erp/database/migrations/2026_08_20_100002_create_survey_questions_table.php @@ -0,0 +1,28 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->unsignedBigInteger('employee_survey_id'); + $table->string('question_text'); + $table->string('question_type')->default('text'); // text/rating/yes_no/multiple_choice + $table->json('options')->nullable(); // for multiple_choice + $table->integer('sort_order')->default(0); + $table->boolean('is_required')->default(true); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('survey_questions'); + } +}; diff --git a/erp/database/migrations/2026_08_20_100003_create_survey_responses_table.php b/erp/database/migrations/2026_08_20_100003_create_survey_responses_table.php new file mode 100644 index 00000000000..4a9af3a23a0 --- /dev/null +++ b/erp/database/migrations/2026_08_20_100003_create_survey_responses_table.php @@ -0,0 +1,27 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->unsignedBigInteger('employee_survey_id'); + $table->unsignedBigInteger('employee_id')->nullable(); + $table->json('answers'); + $table->timestamp('submitted_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('employee_survey_responses'); + } +}; diff --git a/erp/database/migrations/2026_08_21_100001_create_backorders_table.php b/erp/database/migrations/2026_08_21_100001_create_backorders_table.php new file mode 100644 index 00000000000..e941b8c4222 --- /dev/null +++ b/erp/database/migrations/2026_08_21_100001_create_backorders_table.php @@ -0,0 +1,32 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->string('backorder_number')->nullable(); + $table->unsignedBigInteger('product_id'); + $table->unsignedBigInteger('warehouse_id'); + $table->unsignedBigInteger('customer_id')->nullable(); // references contacts + $table->decimal('quantity_ordered', 10, 2); + $table->decimal('quantity_fulfilled', 10, 2)->default(0); + $table->string('status')->default('pending'); // pending/partial/fulfilled/cancelled + $table->date('expected_date')->nullable(); + $table->text('notes')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('backorders'); + } +}; diff --git a/erp/database/migrations/2026_08_22_100001_create_write_offs_table.php b/erp/database/migrations/2026_08_22_100001_create_write_offs_table.php new file mode 100644 index 00000000000..3ec3a6f1de4 --- /dev/null +++ b/erp/database/migrations/2026_08_22_100001_create_write_offs_table.php @@ -0,0 +1,35 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->string('write_off_number')->nullable(); + $table->unsignedBigInteger('customer_id')->nullable(); // references contacts + $table->unsignedBigInteger('invoice_id')->nullable(); // references invoices + $table->decimal('amount', 15, 2); + $table->string('currency')->default('USD'); + $table->date('write_off_date'); + $table->string('reason'); // bad_debt/dispute/other + $table->text('notes')->nullable(); + $table->string('status')->default('pending'); // pending/approved/reversed + $table->unsignedBigInteger('approved_by')->nullable(); + $table->timestamp('approved_at')->nullable(); + $table->unsignedBigInteger('created_by')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('write_offs'); + } +}; diff --git a/erp/database/migrations/2026_08_23_100001_create_flexible_work_arrangements_table.php b/erp/database/migrations/2026_08_23_100001_create_flexible_work_arrangements_table.php new file mode 100644 index 00000000000..22691e29d62 --- /dev/null +++ b/erp/database/migrations/2026_08_23_100001_create_flexible_work_arrangements_table.php @@ -0,0 +1,33 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->unsignedBigInteger('employee_id'); + $table->string('arrangement_type'); // remote/hybrid/compressed_hours/part_time/flexible_hours + $table->date('start_date'); + $table->date('end_date')->nullable(); + $table->integer('hours_per_week')->nullable(); + $table->text('description')->nullable(); + $table->string('status')->default('pending'); // pending/approved/rejected/expired + $table->unsignedBigInteger('approved_by')->nullable(); + $table->timestamp('approved_at')->nullable(); + $table->text('rejection_reason')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('flexible_work_arrangements'); + } +}; diff --git a/erp/database/migrations/2026_08_24_100001_create_intercompany_transactions_table.php b/erp/database/migrations/2026_08_24_100001_create_intercompany_transactions_table.php new file mode 100644 index 00000000000..2d62d83b467 --- /dev/null +++ b/erp/database/migrations/2026_08_24_100001_create_intercompany_transactions_table.php @@ -0,0 +1,33 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->string('transaction_number')->nullable(); + $table->string('from_entity'); // name/identifier of sending entity + $table->string('to_entity'); // name/identifier of receiving entity + $table->decimal('amount', 15, 2); + $table->string('currency')->default('USD'); + $table->date('transaction_date'); + $table->string('transaction_type'); // loan/dividend/recharge/transfer/other + $table->text('description')->nullable(); + $table->string('status')->default('draft'); // draft/posted/reconciled/reversed + $table->unsignedBigInteger('created_by')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('intercompany_transactions'); + } +}; diff --git a/erp/database/migrations/2026_08_25_100001_create_product_bundles_table.php b/erp/database/migrations/2026_08_25_100001_create_product_bundles_table.php new file mode 100644 index 00000000000..53eee7641ba --- /dev/null +++ b/erp/database/migrations/2026_08_25_100001_create_product_bundles_table.php @@ -0,0 +1,28 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->string('name'); + $table->string('sku')->nullable(); + $table->text('description')->nullable(); + $table->decimal('bundle_price', 15, 2)->nullable(); // null means sum of components + $table->boolean('is_active')->default(true); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('product_bundles'); + } +}; diff --git a/erp/database/migrations/2026_08_25_100002_create_product_bundle_items_table.php b/erp/database/migrations/2026_08_25_100002_create_product_bundle_items_table.php new file mode 100644 index 00000000000..476109a2ed1 --- /dev/null +++ b/erp/database/migrations/2026_08_25_100002_create_product_bundle_items_table.php @@ -0,0 +1,27 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->unsignedBigInteger('product_bundle_id'); + $table->unsignedBigInteger('product_id'); + $table->decimal('quantity', 10, 2)->default(1); + $table->timestamps(); + $table->unique(['product_bundle_id', 'product_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('product_bundle_items'); + } +}; diff --git a/erp/database/migrations/2026_08_26_100001_create_job_offer_letters_table.php b/erp/database/migrations/2026_08_26_100001_create_job_offer_letters_table.php new file mode 100644 index 00000000000..f9e818aca39 --- /dev/null +++ b/erp/database/migrations/2026_08_26_100001_create_job_offer_letters_table.php @@ -0,0 +1,35 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->unsignedBigInteger('job_application_id')->nullable(); + $table->string('candidate_name'); + $table->string('candidate_email'); + $table->string('position_title'); + $table->decimal('offered_salary', 15, 2)->nullable(); + $table->date('proposed_start_date')->nullable(); + $table->date('offer_expiry_date')->nullable(); + $table->text('offer_terms')->nullable(); + $table->string('status')->default('draft'); // draft/sent/accepted/declined/expired + $table->timestamp('sent_at')->nullable(); + $table->timestamp('responded_at')->nullable(); + $table->unsignedBigInteger('created_by')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('job_offer_letters'); + } +}; diff --git a/erp/database/migrations/2026_08_27_100001_create_budgets_table.php b/erp/database/migrations/2026_08_27_100001_create_budgets_table.php new file mode 100644 index 00000000000..65faef76cc8 --- /dev/null +++ b/erp/database/migrations/2026_08_27_100001_create_budgets_table.php @@ -0,0 +1,60 @@ +string('budget_number')->nullable()->after('name'); + } + if (!Schema::hasColumn('budgets', 'department')) { + $table->string('department')->nullable()->after('budget_number'); + } + if (!Schema::hasColumn('budgets', 'budget_type')) { + $table->string('budget_type')->default('annual')->after('department'); + } + if (!Schema::hasColumn('budgets', 'total_amount')) { + $table->decimal('total_amount', 15, 2)->default(0)->after('budget_type'); + } + if (!Schema::hasColumn('budgets', 'allocated_amount')) { + $table->decimal('allocated_amount', 15, 2)->default(0)->after('total_amount'); + } + if (!Schema::hasColumn('budgets', 'spent_amount')) { + $table->decimal('spent_amount', 15, 2)->default(0)->after('allocated_amount'); + } + if (!Schema::hasColumn('budgets', 'approved_by')) { + $table->foreignId('approved_by')->nullable()->constrained('users')->nullOnDelete()->after('spent_amount'); + } + if (!Schema::hasColumn('budgets', 'approved_at')) { + $table->timestamp('approved_at')->nullable()->after('approved_by'); + } + if (!Schema::hasColumn('budgets', 'start_date')) { + $table->date('start_date')->nullable()->after('approved_at'); + } + if (!Schema::hasColumn('budgets', 'end_date')) { + $table->date('end_date')->nullable()->after('start_date'); + } + }); + } + + public function down(): void + { + Schema::table('budgets', function (Blueprint $table) { + foreach ([ + 'budget_number', 'department', 'budget_type', + 'total_amount', 'allocated_amount', 'spent_amount', + 'approved_by', 'approved_at', 'start_date', 'end_date', + ] as $col) { + if (Schema::hasColumn('budgets', $col)) { + $table->dropColumn($col); + } + } + }); + } +}; diff --git a/erp/database/migrations/2026_08_27_100002_create_budget_line_items_table.php b/erp/database/migrations/2026_08_27_100002_create_budget_line_items_table.php new file mode 100644 index 00000000000..9df32755c33 --- /dev/null +++ b/erp/database/migrations/2026_08_27_100002_create_budget_line_items_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('budget_id')->constrained()->cascadeOnDelete(); + $table->string('category'); + $table->text('description')->nullable(); + $table->decimal('planned_amount', 15, 2)->default(0); + $table->decimal('actual_amount', 15, 2)->default(0); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('budget_line_items'); + } +}; diff --git a/erp/database/migrations/2026_08_28_100001_create_training_sessions_table.php b/erp/database/migrations/2026_08_28_100001_create_training_sessions_table.php new file mode 100644 index 00000000000..82e7f955ce5 --- /dev/null +++ b/erp/database/migrations/2026_08_28_100001_create_training_sessions_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->foreignId('training_course_id')->constrained()->cascadeOnDelete(); + $table->string('title'); + $table->string('session_number')->nullable(); + $table->text('description')->nullable(); + $table->string('location')->nullable(); + $table->string('delivery_mode')->default('in-person'); + $table->string('status')->default('scheduled'); + $table->dateTime('scheduled_at')->nullable(); + $table->dateTime('ends_at')->nullable(); + $table->integer('max_participants')->default(20); + $table->integer('enrolled_count')->default(0); + $table->foreignId('facilitator_id')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('training_sessions'); + } +}; diff --git a/erp/database/migrations/2026_08_29_100001_create_reorder_rules_table.php b/erp/database/migrations/2026_08_29_100001_create_reorder_rules_table.php new file mode 100644 index 00000000000..5d54f185128 --- /dev/null +++ b/erp/database/migrations/2026_08_29_100001_create_reorder_rules_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->foreignId('product_id')->constrained()->cascadeOnDelete(); + $table->foreignId('warehouse_id')->nullable()->constrained()->nullOnDelete(); + $table->string('rule_number')->nullable(); + $table->decimal('reorder_point', 15, 2)->default(0); + $table->decimal('reorder_quantity', 15, 2)->default(0); + $table->decimal('max_stock_level', 15, 2)->nullable(); + $table->string('rule_type')->default('fixed'); + $table->boolean('is_active')->default(true); + $table->string('status')->default('active'); + $table->timestamp('last_triggered_at')->nullable(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('reorder_rules'); + } +}; diff --git a/erp/database/migrations/2026_08_30_100001_create_cash_flow_forecasts_table.php b/erp/database/migrations/2026_08_30_100001_create_cash_flow_forecasts_table.php new file mode 100644 index 00000000000..fd37a80d6b5 --- /dev/null +++ b/erp/database/migrations/2026_08_30_100001_create_cash_flow_forecasts_table.php @@ -0,0 +1,39 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->string('forecast_number')->nullable(); + $table->string('name'); + $table->string('period_type')->default('monthly'); + $table->date('period_start'); + $table->date('period_end'); + $table->decimal('opening_balance', 15, 2)->default(0); + $table->decimal('projected_inflows', 15, 2)->default(0); + $table->decimal('projected_outflows', 15, 2)->default(0); + $table->decimal('actual_inflows', 15, 2)->default(0); + $table->decimal('actual_outflows', 15, 2)->default(0); + $table->string('status')->default('draft'); + $table->text('notes')->nullable(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('approved_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('approved_at')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('cash_flow_forecasts'); + } +}; diff --git a/erp/database/migrations/2026_08_31_100001_create_competency_frameworks_table.php b/erp/database/migrations/2026_08_31_100001_create_competency_frameworks_table.php new file mode 100644 index 00000000000..b9ab245eb94 --- /dev/null +++ b/erp/database/migrations/2026_08_31_100001_create_competency_frameworks_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('code')->nullable(); + $table->text('description')->nullable(); + $table->string('status')->default('draft'); // draft/active/archived + $table->boolean('is_default')->default(false); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('competency_frameworks'); + } +}; diff --git a/erp/database/migrations/2026_08_31_100002_create_competencies_table.php b/erp/database/migrations/2026_08_31_100002_create_competencies_table.php new file mode 100644 index 00000000000..d4a58037e20 --- /dev/null +++ b/erp/database/migrations/2026_08_31_100002_create_competencies_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignId('competency_framework_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('category')->nullable(); // technical/behavioural/leadership + $table->text('description')->nullable(); + $table->integer('max_level')->default(5); // e.g. 1-5 proficiency scale + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('competencies'); + } +}; diff --git a/erp/database/migrations/2026_09_01_100001_create_supplier_scorecards_table.php b/erp/database/migrations/2026_09_01_100001_create_supplier_scorecards_table.php new file mode 100644 index 00000000000..f254081af07 --- /dev/null +++ b/erp/database/migrations/2026_09_01_100001_create_supplier_scorecards_table.php @@ -0,0 +1,38 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->string('supplier_name'); + $table->string('supplier_code')->nullable(); + $table->string('scorecard_number')->nullable(); + $table->string('period'); + $table->decimal('quality_score', 5, 2)->default(0); + $table->decimal('delivery_score', 5, 2)->default(0); + $table->decimal('pricing_score', 5, 2)->default(0); + $table->decimal('service_score', 5, 2)->default(0); + $table->decimal('overall_score', 5, 2)->default(0); + $table->string('rating')->default('pending'); + $table->string('status')->default('draft'); + $table->text('notes')->nullable(); + $table->foreignId('evaluated_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('published_at')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('supplier_scorecards'); + } +}; diff --git a/erp/database/migrations/2026_09_02_100001_create_recurring_expenses_table.php b/erp/database/migrations/2026_09_02_100001_create_recurring_expenses_table.php new file mode 100644 index 00000000000..249d61f4085 --- /dev/null +++ b/erp/database/migrations/2026_09_02_100001_create_recurring_expenses_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('expense_number')->nullable(); + $table->string('category')->nullable(); + $table->decimal('amount', 15, 2); + $table->string('currency')->default('USD'); + $table->string('frequency')->default('monthly'); // monthly/quarterly/annual/weekly + $table->date('start_date'); + $table->date('end_date')->nullable(); + $table->date('next_due_date')->nullable(); + $table->date('last_processed_date')->nullable(); + $table->string('status')->default('active'); // active/paused/cancelled/expired + $table->text('notes')->nullable(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('recurring_expenses'); + } +}; diff --git a/erp/database/migrations/2026_09_03_100001_create_employee_goals_table.php b/erp/database/migrations/2026_09_03_100001_create_employee_goals_table.php new file mode 100644 index 00000000000..6af497a7167 --- /dev/null +++ b/erp/database/migrations/2026_09_03_100001_create_employee_goals_table.php @@ -0,0 +1,39 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->foreignId('employee_id')->constrained()->cascadeOnDelete(); + $table->string('title'); + $table->text('description')->nullable(); + $table->string('goal_type')->default('individual'); // individual/team/department + $table->string('category')->nullable(); // performance/development/sales/operational + $table->decimal('target_value', 15, 2)->nullable(); // numeric target if applicable + $table->decimal('current_value', 15, 2)->default(0); + $table->string('unit')->nullable(); // e.g. '%', 'units', 'hours' + $table->date('start_date'); + $table->date('due_date'); + $table->date('completed_at')->nullable(); + $table->string('status')->default('active'); // active/completed/missed/cancelled + $table->integer('progress_percent')->default(0); // 0-100 + $table->string('priority')->default('medium'); // low/medium/high + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('employee_goals'); + } +}; diff --git a/erp/database/migrations/2026_09_04_100001_create_quality_alerts_table.php b/erp/database/migrations/2026_09_04_100001_create_quality_alerts_table.php new file mode 100644 index 00000000000..79a12fdff86 --- /dev/null +++ b/erp/database/migrations/2026_09_04_100001_create_quality_alerts_table.php @@ -0,0 +1,39 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->foreignId('product_id')->nullable()->constrained()->nullOnDelete(); + $table->string('alert_number')->nullable(); + $table->string('title'); + $table->text('description')->nullable(); + $table->string('alert_type')->default('defect'); // defect/contamination/non-conformance/recall/expiry + $table->string('severity')->default('medium'); // low/medium/high/critical + $table->string('status')->default('open'); // open/investigating/resolved/closed + $table->integer('affected_quantity')->default(0); + $table->string('affected_batch')->nullable(); + $table->text('root_cause')->nullable(); + $table->text('corrective_action')->nullable(); + $table->timestamp('resolved_at')->nullable(); + $table->foreignId('reported_by')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('assigned_to')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('quality_alerts'); + } +}; diff --git a/erp/database/migrations/2026_09_05_100001_create_vendor_payments_table.php b/erp/database/migrations/2026_09_05_100001_create_vendor_payments_table.php new file mode 100644 index 00000000000..d4f603ec4c8 --- /dev/null +++ b/erp/database/migrations/2026_09_05_100001_create_vendor_payments_table.php @@ -0,0 +1,39 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->string('payment_number')->nullable(); + $table->string('vendor_name'); + $table->string('vendor_code')->nullable(); + $table->decimal('amount', 15, 2); + $table->string('currency')->default('USD'); + $table->string('payment_method')->default('bank_transfer'); // bank_transfer/cheque/cash/online + $table->string('reference')->nullable(); + $table->date('payment_date'); + $table->date('due_date')->nullable(); + $table->string('status')->default('pending'); // pending/approved/processed/rejected/cancelled + $table->text('notes')->nullable(); + $table->foreignId('approved_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('approved_at')->nullable(); + $table->timestamp('processed_at')->nullable(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('vendor_payments'); + } +}; diff --git a/erp/database/migrations/2026_09_06_100001_create_succession_plans_table.php b/erp/database/migrations/2026_09_06_100001_create_succession_plans_table.php new file mode 100644 index 00000000000..9bb406fbc95 --- /dev/null +++ b/erp/database/migrations/2026_09_06_100001_create_succession_plans_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->string('position_title'); + $table->string('department')->nullable(); + $table->text('description')->nullable(); + $table->string('status')->default('active'); + $table->boolean('is_critical')->default(false); + $table->foreignId('current_holder_id')->nullable()->constrained('employees')->nullOnDelete(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('succession_plans'); + } +}; diff --git a/erp/database/migrations/2026_09_06_100002_create_succession_candidates_table.php b/erp/database/migrations/2026_09_06_100002_create_succession_candidates_table.php new file mode 100644 index 00000000000..3111461cfb6 --- /dev/null +++ b/erp/database/migrations/2026_09_06_100002_create_succession_candidates_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('succession_plan_id')->constrained()->cascadeOnDelete(); + $table->foreignId('employee_id')->constrained()->cascadeOnDelete(); + $table->string('readiness_level')->default('not-ready'); + $table->integer('priority')->default(1); + $table->integer('readiness_score')->default(0); + $table->text('development_notes')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('succession_candidates'); + } +}; diff --git a/erp/database/migrations/2026_09_07_100001_create_stock_reservations_table.php b/erp/database/migrations/2026_09_07_100001_create_stock_reservations_table.php new file mode 100644 index 00000000000..c08f2638b8e --- /dev/null +++ b/erp/database/migrations/2026_09_07_100001_create_stock_reservations_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->foreignId('product_id')->constrained()->cascadeOnDelete(); + $table->string('reservation_number')->nullable(); + $table->string('reference_type')->nullable(); + $table->string('reference_id')->nullable(); + $table->decimal('quantity', 15, 2); + $table->decimal('quantity_fulfilled', 15, 2)->default(0); + $table->date('reserved_until')->nullable(); + $table->string('status')->default('active'); + $table->text('notes')->nullable(); + $table->foreignId('reserved_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('stock_reservations'); + } +}; diff --git a/erp/database/migrations/2026_09_08_100001_create_payment_schedules_table.php b/erp/database/migrations/2026_09_08_100001_create_payment_schedules_table.php new file mode 100644 index 00000000000..60c7b9d5284 --- /dev/null +++ b/erp/database/migrations/2026_09_08_100001_create_payment_schedules_table.php @@ -0,0 +1,38 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->string('schedule_number')->nullable(); + $table->string('name'); + $table->string('reference_type')->nullable(); + $table->unsignedBigInteger('reference_id')->nullable(); + $table->decimal('total_amount', 15, 2); + $table->decimal('paid_amount', 15, 2)->default(0); + $table->string('currency')->default('USD'); + $table->string('frequency')->default('monthly'); + $table->integer('installments')->default(1); + $table->date('start_date'); + $table->date('end_date')->nullable(); + $table->string('status')->default('active'); + $table->text('notes')->nullable(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('payment_schedules'); + } +}; diff --git a/erp/database/migrations/2026_09_08_100002_create_payment_schedule_items_table.php b/erp/database/migrations/2026_09_08_100002_create_payment_schedule_items_table.php new file mode 100644 index 00000000000..6e01f03dbaf --- /dev/null +++ b/erp/database/migrations/2026_09_08_100002_create_payment_schedule_items_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('payment_schedule_id')->constrained()->cascadeOnDelete(); + $table->integer('installment_number'); + $table->decimal('amount', 15, 2); + $table->date('due_date'); + $table->date('paid_date')->nullable(); + $table->string('status')->default('pending'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('payment_schedule_items'); + } +}; diff --git a/erp/database/migrations/2026_09_09_100001_create_mentorship_programs_table.php b/erp/database/migrations/2026_09_09_100001_create_mentorship_programs_table.php new file mode 100644 index 00000000000..815055491f4 --- /dev/null +++ b/erp/database/migrations/2026_09_09_100001_create_mentorship_programs_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->foreignId('mentor_id')->constrained('employees')->cascadeOnDelete(); + $table->foreignId('mentee_id')->constrained('employees')->cascadeOnDelete(); + $table->string('program_number')->nullable(); + $table->string('title'); + $table->text('objectives')->nullable(); + $table->date('start_date'); + $table->date('end_date')->nullable(); + $table->string('status')->default('active'); // active/completed/cancelled/paused + $table->string('meeting_frequency')->default('monthly'); // weekly/biweekly/monthly + $table->integer('sessions_completed')->default(0); + $table->integer('sessions_planned')->default(0); + $table->text('notes')->nullable(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('mentorship_programs'); + } +}; diff --git a/erp/database/migrations/2026_09_10_100001_create_purchase_requests_table.php b/erp/database/migrations/2026_09_10_100001_create_purchase_requests_table.php new file mode 100644 index 00000000000..96e9759be43 --- /dev/null +++ b/erp/database/migrations/2026_09_10_100001_create_purchase_requests_table.php @@ -0,0 +1,39 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->string('request_number')->nullable(); + $table->string('title'); + $table->text('description')->nullable(); + $table->string('department')->nullable(); + $table->decimal('estimated_cost', 15, 2)->default(0); + $table->string('currency')->default('USD'); + $table->string('priority')->default('medium'); // low/medium/high/urgent + $table->string('status')->default('draft'); // draft/submitted/approved/rejected/ordered/cancelled + $table->date('required_by')->nullable(); + $table->text('justification')->nullable(); + $table->foreignId('requested_by')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('approved_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('approved_at')->nullable(); + $table->timestamp('submitted_at')->nullable(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('purchase_requests'); + } +}; diff --git a/erp/database/migrations/2026_09_11_100001_create_customer_credits_table.php b/erp/database/migrations/2026_09_11_100001_create_customer_credits_table.php new file mode 100644 index 00000000000..dbfe533614d --- /dev/null +++ b/erp/database/migrations/2026_09_11_100001_create_customer_credits_table.php @@ -0,0 +1,39 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->string('credit_number')->nullable(); + $table->string('customer_name'); + $table->string('customer_code')->nullable(); + $table->decimal('credit_amount', 15, 2); + $table->decimal('used_amount', 15, 2)->default(0); + $table->string('currency')->default('USD'); + $table->string('reason')->nullable(); + $table->string('status')->default('active'); // active/exhausted/expired/cancelled + $table->date('expiry_date')->nullable(); + $table->string('reference_type')->nullable(); // Invoice, ReturnRequest, etc. + $table->unsignedBigInteger('reference_id')->nullable(); + $table->text('notes')->nullable(); + $table->foreignId('issued_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('issued_at')->nullable(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('customer_credits'); + } +}; diff --git a/erp/database/migrations/2026_09_12_100001_create_interview_schedules_table.php b/erp/database/migrations/2026_09_12_100001_create_interview_schedules_table.php new file mode 100644 index 00000000000..a3ea018c809 --- /dev/null +++ b/erp/database/migrations/2026_09_12_100001_create_interview_schedules_table.php @@ -0,0 +1,40 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->string('interview_number')->nullable(); + $table->string('candidate_name'); + $table->string('candidate_email')->nullable(); + $table->string('position_title'); + $table->string('interview_type')->default('in-person'); // in-person/video/phone/panel + $table->string('status')->default('scheduled'); // scheduled/confirmed/completed/cancelled/no-show + $table->dateTime('scheduled_at'); + $table->integer('duration_minutes')->default(60); + $table->string('location')->nullable(); + $table->string('meeting_link')->nullable(); + $table->text('notes')->nullable(); + $table->text('feedback')->nullable(); + $table->string('outcome')->nullable(); // pass/fail/hold + $table->foreignId('interviewer_id')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('job_application_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('interview_schedules'); + } +}; diff --git a/erp/database/migrations/2026_09_13_100001_create_goods_receipts_table.php b/erp/database/migrations/2026_09_13_100001_create_goods_receipts_table.php new file mode 100644 index 00000000000..97ac7e3d374 --- /dev/null +++ b/erp/database/migrations/2026_09_13_100001_create_goods_receipts_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->string('receipt_number')->nullable(); + $table->string('supplier_name'); + $table->string('supplier_reference')->nullable(); + $table->date('receipt_date'); + $table->string('status')->default('draft'); + $table->text('notes')->nullable(); + $table->foreignId('warehouse_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('received_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('confirmed_at')->nullable(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('goods_receipts'); + } +}; diff --git a/erp/database/migrations/2026_09_13_100002_create_goods_receipt_items_table.php b/erp/database/migrations/2026_09_13_100002_create_goods_receipt_items_table.php new file mode 100644 index 00000000000..2a8df8313c1 --- /dev/null +++ b/erp/database/migrations/2026_09_13_100002_create_goods_receipt_items_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignId('goods_receipt_id')->constrained()->cascadeOnDelete(); + $table->foreignId('product_id')->nullable()->constrained()->nullOnDelete(); + $table->decimal('quantity_expected', 15, 2)->default(0); + $table->decimal('quantity_received', 15, 2)->default(0); + $table->decimal('unit_cost', 15, 2)->default(0); + $table->string('condition')->default('good'); + $table->text('notes')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('goods_receipt_items'); + } +}; diff --git a/erp/database/migrations/2026_09_14_100001_create_expense_budgets_table.php b/erp/database/migrations/2026_09_14_100001_create_expense_budgets_table.php new file mode 100644 index 00000000000..5811c43635b --- /dev/null +++ b/erp/database/migrations/2026_09_14_100001_create_expense_budgets_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->string('budget_code')->nullable(); + $table->string('department'); + $table->string('category')->nullable(); // travel/office/marketing/IT/etc. + $table->string('period'); // e.g. "2026-Q1", "2026-01", "2026" + $table->decimal('allocated_amount', 15, 2)->default(0); + $table->decimal('spent_amount', 15, 2)->default(0); + $table->string('currency')->default('USD'); + $table->string('status')->default('active'); // active/frozen/closed + $table->text('notes')->nullable(); + $table->foreignId('owner_id')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('expense_budgets'); + } +}; diff --git a/erp/database/migrations/2026_09_15_100001_create_shipments_table.php b/erp/database/migrations/2026_09_15_100001_create_shipments_table.php new file mode 100644 index 00000000000..5f71fbbd6d5 --- /dev/null +++ b/erp/database/migrations/2026_09_15_100001_create_shipments_table.php @@ -0,0 +1,39 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->string('shipment_number')->nullable(); + $table->string('type')->default('outbound'); // inbound|outbound + $table->string('status')->default('pending'); // pending|in-transit|delivered|returned|cancelled + $table->string('carrier')->nullable(); + $table->string('tracking_number')->nullable(); + $table->string('service_level')->nullable(); // standard|express|overnight + $table->text('origin_address')->nullable(); + $table->text('destination_address')->nullable(); + $table->date('ship_date')->nullable(); + $table->date('estimated_delivery')->nullable(); + $table->date('actual_delivery')->nullable(); + $table->decimal('weight_kg', 10, 3)->nullable(); + $table->decimal('freight_cost', 15, 2)->nullable(); + $table->text('notes')->nullable(); + $table->foreignId('warehouse_id')->nullable()->constrained('warehouses')->nullOnDelete(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('shipments'); + } +}; diff --git a/erp/database/migrations/2026_09_15_100002_create_shipment_items_table.php b/erp/database/migrations/2026_09_15_100002_create_shipment_items_table.php new file mode 100644 index 00000000000..231dc32ca32 --- /dev/null +++ b/erp/database/migrations/2026_09_15_100002_create_shipment_items_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignId('shipment_id')->constrained()->cascadeOnDelete(); + $table->foreignId('product_id')->nullable()->constrained('products')->nullOnDelete(); + $table->string('description')->nullable(); + $table->decimal('quantity', 15, 4)->default(1); + $table->decimal('weight_kg', 10, 3)->nullable(); + $table->string('sku')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('shipment_items'); + } +}; diff --git a/erp/database/migrations/2026_09_16_100001_create_profit_centers_table.php b/erp/database/migrations/2026_09_16_100001_create_profit_centers_table.php new file mode 100644 index 00000000000..2923e79fd41 --- /dev/null +++ b/erp/database/migrations/2026_09_16_100001_create_profit_centers_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->string('code')->unique(); + $table->string('name'); + $table->string('type')->default('profit'); // profit|cost|investment + $table->string('status')->default('active'); // active|inactive + $table->foreignId('parent_id')->nullable()->constrained('profit_centers')->nullOnDelete(); + $table->foreignId('manager_id')->nullable()->constrained('users')->nullOnDelete(); + $table->decimal('budget', 15, 2)->nullable(); + $table->text('description')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('profit_centers'); + } +}; diff --git a/erp/database/migrations/2026_09_17_100001_create_employee_emergency_contacts_table.php b/erp/database/migrations/2026_09_17_100001_create_employee_emergency_contacts_table.php new file mode 100644 index 00000000000..588a64b449c --- /dev/null +++ b/erp/database/migrations/2026_09_17_100001_create_employee_emergency_contacts_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('employee_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('relationship'); + $table->string('phone_primary'); + $table->string('phone_secondary')->nullable(); + $table->string('email')->nullable(); + $table->text('address')->nullable(); + $table->boolean('is_primary')->default(false); + $table->text('notes')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('employee_emergency_contacts'); + } +}; diff --git a/erp/database/migrations/2026_09_18_100001_create_rma_requests_table.php b/erp/database/migrations/2026_09_18_100001_create_rma_requests_table.php new file mode 100644 index 00000000000..74bcbcac95b --- /dev/null +++ b/erp/database/migrations/2026_09_18_100001_create_rma_requests_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->string('rma_number')->nullable(); + $table->string('type')->default('customer_return'); // customer_return|supplier_return + $table->string('status')->default('pending'); // pending|approved|received|inspected|closed|rejected + $table->string('contact_name')->nullable(); + $table->string('reference')->nullable(); + $table->text('reason'); + $table->string('disposition')->default('restock'); // restock|scrap|repair|replace|credit + $table->date('requested_date')->nullable(); + $table->date('received_date')->nullable(); + $table->date('inspected_date')->nullable(); + $table->text('notes')->nullable(); + $table->foreignId('warehouse_id')->nullable()->constrained('warehouses')->nullOnDelete(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('approved_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('rma_requests'); + } +}; diff --git a/erp/database/migrations/2026_09_18_100002_create_rma_request_items_table.php b/erp/database/migrations/2026_09_18_100002_create_rma_request_items_table.php new file mode 100644 index 00000000000..cfc287a0ba4 --- /dev/null +++ b/erp/database/migrations/2026_09_18_100002_create_rma_request_items_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignId('rma_request_id')->constrained()->cascadeOnDelete(); + $table->foreignId('product_id')->nullable()->constrained('products')->nullOnDelete(); + $table->string('description')->nullable(); + $table->decimal('quantity_requested', 15, 4); + $table->decimal('quantity_received', 15, 4)->default(0); + $table->string('condition')->default('good'); // good|damaged|scrap + $table->string('disposition')->nullable(); // item-level override + $table->text('notes')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('rma_request_items'); + } +}; diff --git a/erp/database/migrations/2026_09_19_100001_create_product_warranties_table.php b/erp/database/migrations/2026_09_19_100001_create_product_warranties_table.php new file mode 100644 index 00000000000..bdbf5cdf2c1 --- /dev/null +++ b/erp/database/migrations/2026_09_19_100001_create_product_warranties_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->foreignId('product_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->integer('duration_months'); + $table->string('warranty_type')->default('standard'); + $table->text('terms')->nullable(); + $table->boolean('is_default')->default(false); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('product_warranties'); + } +}; diff --git a/erp/database/migrations/2026_09_19_100002_create_warranty_claims_table.php b/erp/database/migrations/2026_09_19_100002_create_warranty_claims_table.php new file mode 100644 index 00000000000..28f3bb9803b --- /dev/null +++ b/erp/database/migrations/2026_09_19_100002_create_warranty_claims_table.php @@ -0,0 +1,39 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->string('claim_number')->nullable(); + $table->foreignId('product_warranty_id')->constrained()->cascadeOnDelete(); + $table->foreignId('serial_number_id')->nullable()->constrained('serial_numbers')->nullOnDelete(); + $table->string('customer_name'); + $table->string('customer_email')->nullable(); + $table->string('customer_phone')->nullable(); + $table->date('purchase_date')->nullable(); + $table->date('claim_date'); + $table->date('warranty_expiry')->nullable(); + $table->string('status')->default('open'); + $table->text('issue_description'); + $table->string('resolution_type')->nullable(); + $table->text('resolution_notes')->nullable(); + $table->date('resolved_date')->nullable(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('assigned_to')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('warranty_claims'); + } +}; diff --git a/erp/database/migrations/2026_09_20_100001_create_put_away_rules_table.php b/erp/database/migrations/2026_09_20_100001_create_put_away_rules_table.php new file mode 100644 index 00000000000..d17e61527f2 --- /dev/null +++ b/erp/database/migrations/2026_09_20_100001_create_put_away_rules_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->foreignId('warehouse_id')->constrained()->cascadeOnDelete(); + $table->foreignId('product_id')->nullable()->constrained('products')->nullOnDelete(); + $table->foreignId('product_category_id')->nullable()->constrained('product_categories')->nullOnDelete(); + $table->foreignId('location_in_zone_id')->nullable()->constrained('warehouse_zones')->nullOnDelete(); + $table->foreignId('location_out_bin_id')->nullable()->constrained('warehouse_bins')->nullOnDelete(); + $table->foreignId('location_out_zone_id')->nullable()->constrained('warehouse_zones')->nullOnDelete(); + $table->integer('sequence')->default(10); + $table->boolean('is_active')->default(true); + $table->text('notes')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('put_away_rules'); + } +}; diff --git a/erp/database/migrations/2026_09_21_100001_create_stock_pickings_table.php b/erp/database/migrations/2026_09_21_100001_create_stock_pickings_table.php new file mode 100644 index 00000000000..704e59b55d1 --- /dev/null +++ b/erp/database/migrations/2026_09_21_100001_create_stock_pickings_table.php @@ -0,0 +1,36 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->string('picking_number')->nullable(); + $table->string('picking_type')->default('incoming'); // incoming|outgoing|internal|return + $table->string('status')->default('draft'); // draft|confirmed|in_progress|done|cancelled + $table->foreignId('warehouse_id')->nullable()->constrained('warehouses')->nullOnDelete(); + $table->foreignId('source_location_id')->nullable()->constrained('warehouse_zones')->nullOnDelete(); + $table->foreignId('destination_location_id')->nullable()->constrained('warehouse_zones')->nullOnDelete(); + $table->string('origin')->nullable(); // reference document (PO number, SO number) + $table->string('partner_name')->nullable(); + $table->date('scheduled_date')->nullable(); + $table->date('done_date')->nullable(); + $table->text('notes')->nullable(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('validated_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('stock_pickings'); + } +}; diff --git a/erp/database/migrations/2026_09_21_100002_create_stock_picking_lines_table.php b/erp/database/migrations/2026_09_21_100002_create_stock_picking_lines_table.php new file mode 100644 index 00000000000..3de71312b9a --- /dev/null +++ b/erp/database/migrations/2026_09_21_100002_create_stock_picking_lines_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('stock_picking_id')->constrained()->cascadeOnDelete(); + $table->foreignId('product_id')->nullable()->constrained('products')->nullOnDelete(); + $table->string('description')->nullable(); + $table->decimal('qty_demanded', 15, 4)->default(0); // planned quantity + $table->decimal('qty_done', 15, 4)->default(0); // actual quantity processed + $table->foreignId('lot_id')->nullable()->constrained('lot_numbers')->nullOnDelete(); + $table->foreignId('serial_id')->nullable()->constrained('serial_numbers')->nullOnDelete(); + $table->string('state')->default('pending'); // pending|done|cancelled + $table->text('notes')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('stock_picking_lines'); + } +}; diff --git a/erp/database/migrations/2026_09_22_100001_create_replenishment_orders_table.php b/erp/database/migrations/2026_09_22_100001_create_replenishment_orders_table.php new file mode 100644 index 00000000000..059301a2139 --- /dev/null +++ b/erp/database/migrations/2026_09_22_100001_create_replenishment_orders_table.php @@ -0,0 +1,36 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->string('order_number')->nullable(); + $table->foreignId('product_id')->constrained()->cascadeOnDelete(); + $table->foreignId('warehouse_id')->constrained()->cascadeOnDelete(); + $table->decimal('qty_on_hand', 15, 4)->default(0); + $table->decimal('qty_needed', 15, 4); + $table->decimal('qty_to_order', 15, 4); + $table->string('route')->default('buy'); // buy|manufacture|resupply + $table->string('status')->default('draft'); // draft|confirmed|in_progress|done|cancelled + $table->date('scheduled_date')->nullable(); + $table->foreignId('supplier_id')->nullable()->constrained('suppliers')->nullOnDelete(); + $table->foreignId('reorder_rule_id')->nullable()->constrained('reorder_rules')->nullOnDelete(); + $table->text('notes')->nullable(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('replenishment_orders'); + } +}; diff --git a/erp/database/migrations/2026_09_23_100001_add_traceability_to_stock_movements_table.php b/erp/database/migrations/2026_09_23_100001_add_traceability_to_stock_movements_table.php new file mode 100644 index 00000000000..a2e9ebfea38 --- /dev/null +++ b/erp/database/migrations/2026_09_23_100001_add_traceability_to_stock_movements_table.php @@ -0,0 +1,25 @@ +foreignId('lot_id')->nullable()->constrained('lot_numbers')->nullOnDelete()->after('notes'); + $table->foreignId('serial_id')->nullable()->constrained('serial_numbers')->nullOnDelete()->after('lot_id'); + }); + } + + public function down(): void + { + Schema::table('stock_movements', function (Blueprint $table) { + $table->dropForeign(['lot_id']); + $table->dropForeign(['serial_id']); + $table->dropColumn(['lot_id', 'serial_id']); + }); + } +}; diff --git a/erp/database/migrations/2026_09_25_100001_create_companies_table.php b/erp/database/migrations/2026_09_25_100001_create_companies_table.php new file mode 100644 index 00000000000..03ed19aa220 --- /dev/null +++ b/erp/database/migrations/2026_09_25_100001_create_companies_table.php @@ -0,0 +1,38 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('code', 20)->nullable(); + $table->string('tax_id')->nullable(); + $table->string('currency_code', 3)->default('USD'); + $table->unsignedTinyInteger('fiscal_year_start')->default(1); + $table->string('logo_path')->nullable(); + $table->text('address')->nullable(); + $table->string('phone')->nullable(); + $table->string('email')->nullable(); + $table->string('website')->nullable(); + $table->string('industry')->nullable(); + $table->boolean('is_active')->default(true); + $table->foreignId('parent_company_id')->nullable()->constrained('companies')->nullOnDelete(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->softDeletes(); + $table->unique(['tenant_id', 'code']); + }); + } + + public function down(): void + { + Schema::dropIfExists('companies'); + } +}; diff --git a/erp/database/migrations/2026_09_25_100002_create_company_user_table.php b/erp/database/migrations/2026_09_25_100002_create_company_user_table.php new file mode 100644 index 00000000000..88f1e9b28dd --- /dev/null +++ b/erp/database/migrations/2026_09_25_100002_create_company_user_table.php @@ -0,0 +1,25 @@ +id(); + $table->foreignId('company_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->boolean('is_default')->default(false); + $table->timestamps(); + $table->unique(['company_id', 'user_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('company_user'); + } +}; diff --git a/erp/database/migrations/2026_09_26_100001_add_warehouse_settings.php b/erp/database/migrations/2026_09_26_100001_add_warehouse_settings.php new file mode 100644 index 00000000000..9fecd57486c --- /dev/null +++ b/erp/database/migrations/2026_09_26_100001_add_warehouse_settings.php @@ -0,0 +1,42 @@ +string('address')->nullable()->after('name'); + } + if (! Schema::hasColumn('warehouses', 'city')) { + $table->string('city')->nullable()->after('address'); + } + if (! Schema::hasColumn('warehouses', 'country')) { + $table->string('country')->nullable()->after('city'); + } + if (! Schema::hasColumn('warehouses', 'phone')) { + $table->string('phone')->nullable()->after('country'); + } + if (! Schema::hasColumn('warehouses', 'email')) { + $table->string('email')->nullable()->after('phone'); + } + if (! Schema::hasColumn('warehouses', 'timezone')) { + $table->string('timezone')->nullable()->after('email'); + } + if (! Schema::hasColumn('warehouses', 'costing_method')) { + $table->string('costing_method')->default('average')->after('timezone'); + } + }); + } + + public function down(): void + { + Schema::table('warehouses', function (Blueprint $table) { + $table->dropColumn(['address', 'city', 'country', 'phone', 'email', 'timezone', 'costing_method']); + }); + } +}; diff --git a/erp/database/migrations/2026_09_30_100001_rename_finance_projects_tables.php b/erp/database/migrations/2026_09_30_100001_rename_finance_projects_tables.php new file mode 100644 index 00000000000..fe400482919 --- /dev/null +++ b/erp/database/migrations/2026_09_30_100001_rename_finance_projects_tables.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->foreignId('product_id')->constrained()->cascadeOnDelete(); + $table->string('code')->nullable(); + $table->string('name'); + $table->string('type')->default('manufacture'); // manufacture|kit|subcontracting + $table->decimal('qty_per_bom', 15, 4)->default(1); + $table->string('uom')->nullable(); + $table->boolean('is_active')->default(true); + $table->unsignedSmallInteger('version')->default(1); + $table->text('notes')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('bills_of_materials'); + } +}; diff --git a/erp/database/migrations/2026_10_01_100002_create_bom_lines_table.php b/erp/database/migrations/2026_10_01_100002_create_bom_lines_table.php new file mode 100644 index 00000000000..07c23fabf74 --- /dev/null +++ b/erp/database/migrations/2026_10_01_100002_create_bom_lines_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('bom_id')->constrained('bills_of_materials')->cascadeOnDelete(); + $table->foreignId('component_id')->constrained('products')->cascadeOnDelete(); + $table->decimal('quantity', 15, 4)->default(1); + $table->string('uom')->nullable(); + $table->unsignedSmallInteger('sequence')->default(10); + $table->boolean('is_optional')->default(false); + $table->text('notes')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('bom_lines'); + } +}; diff --git a/erp/database/migrations/2026_10_02_100001_create_work_centers_table.php b/erp/database/migrations/2026_10_02_100001_create_work_centers_table.php new file mode 100644 index 00000000000..17cf1ecca52 --- /dev/null +++ b/erp/database/migrations/2026_10_02_100001_create_work_centers_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('code')->nullable(); + $table->decimal('capacity', 8, 2)->default(1); + $table->decimal('efficiency_factor', 5, 2)->default(100); + $table->decimal('time_efficiency', 5, 2)->default(100); + $table->decimal('hourly_cost', 10, 2)->default(0); + $table->boolean('is_active')->default(true); + $table->text('description')->nullable(); + $table->timestamps(); + $table->softDeletes(); + $table->unique(['tenant_id', 'code']); + }); + } + + public function down(): void + { + Schema::dropIfExists('work_centers'); + } +}; diff --git a/erp/database/migrations/2026_10_03_100001_create_manufacturing_orders_table.php b/erp/database/migrations/2026_10_03_100001_create_manufacturing_orders_table.php new file mode 100644 index 00000000000..32f610da822 --- /dev/null +++ b/erp/database/migrations/2026_10_03_100001_create_manufacturing_orders_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->string('mo_number')->nullable(); + $table->foreignId('product_id')->constrained()->cascadeOnDelete(); + $table->foreignId('bom_id')->nullable()->constrained('bills_of_materials')->nullOnDelete(); + $table->decimal('qty_to_produce', 15, 4); + $table->decimal('qty_produced', 15, 4)->default(0); + $table->string('status')->default('draft'); // draft|confirmed|in_progress|done|cancelled + $table->date('scheduled_date')->nullable(); + $table->date('start_date')->nullable(); + $table->date('finish_date')->nullable(); + $table->foreignId('warehouse_id')->nullable()->constrained()->nullOnDelete(); + $table->string('origin')->nullable(); + $table->text('notes')->nullable(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('responsible_id')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('manufacturing_orders'); + } +}; diff --git a/erp/database/migrations/2026_10_03_100002_create_mo_components_table.php b/erp/database/migrations/2026_10_03_100002_create_mo_components_table.php new file mode 100644 index 00000000000..988b802be0d --- /dev/null +++ b/erp/database/migrations/2026_10_03_100002_create_mo_components_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignId('manufacturing_order_id')->constrained()->cascadeOnDelete(); + $table->foreignId('product_id')->constrained()->cascadeOnDelete(); + $table->decimal('qty_required', 15, 4); + $table->decimal('qty_consumed', 15, 4)->default(0); + $table->string('uom')->nullable(); + $table->boolean('is_available')->default(false); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('mo_components'); + } +}; diff --git a/erp/database/migrations/2026_10_04_100001_create_work_orders_table.php b/erp/database/migrations/2026_10_04_100001_create_work_orders_table.php new file mode 100644 index 00000000000..b3e57d1e21e --- /dev/null +++ b/erp/database/migrations/2026_10_04_100001_create_work_orders_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('manufacturing_order_id')->constrained()->cascadeOnDelete(); + $table->foreignId('work_center_id')->nullable()->constrained()->nullOnDelete(); + $table->string('operation_name'); + $table->unsignedSmallInteger('sequence')->default(10); + $table->decimal('duration_expected', 8, 2)->default(0); + $table->decimal('duration_actual', 8, 2)->default(0); + $table->dateTime('scheduled_start')->nullable(); + $table->dateTime('actual_start')->nullable(); + $table->dateTime('actual_finish')->nullable(); + $table->string('status')->default('pending'); // pending|in_progress|done|cancelled + $table->text('notes')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('work_orders'); + } +}; diff --git a/erp/database/migrations/2026_10_05_100001_create_crm_stages_table.php b/erp/database/migrations/2026_10_05_100001_create_crm_stages_table.php new file mode 100644 index 00000000000..9c14472c0ba --- /dev/null +++ b/erp/database/migrations/2026_10_05_100001_create_crm_stages_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->unsignedSmallInteger('sequence')->default(10); + $table->string('type')->default('open'); // open|won|lost + $table->decimal('probability', 5, 2)->default(0); // 0-100 % + $table->string('color')->nullable(); // hex color for kanban + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('crm_stages'); + } +}; diff --git a/erp/database/migrations/2026_10_05_100002_create_crm_leads_table.php b/erp/database/migrations/2026_10_05_100002_create_crm_leads_table.php new file mode 100644 index 00000000000..ba3bf13e3b7 --- /dev/null +++ b/erp/database/migrations/2026_10_05_100002_create_crm_leads_table.php @@ -0,0 +1,44 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->string('reference')->nullable(); // CRM-2026-00001 + $table->string('title'); + $table->string('type')->default('lead'); // lead|opportunity + $table->foreignId('stage_id')->nullable()->constrained('crm_stages')->nullOnDelete(); + $table->string('contact_name')->nullable(); + $table->string('company_name')->nullable(); + $table->string('email')->nullable(); + $table->string('phone')->nullable(); + $table->string('website')->nullable(); + $table->string('source')->nullable(); // website, referral, cold_call, trade_show, other + $table->decimal('expected_revenue', 15, 2)->default(0); + $table->decimal('probability', 5, 2)->default(0); // 0-100 + $table->date('expected_close_date')->nullable(); + $table->string('priority')->default('normal'); // low|normal|high|urgent + $table->string('status')->default('open'); // open|won|lost + $table->text('description')->nullable(); + $table->text('lost_reason')->nullable(); + $table->foreignId('assigned_to')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('won_at')->nullable(); + $table->timestamp('lost_at')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('crm_leads'); + } +}; diff --git a/erp/database/migrations/2026_10_05_100003_create_crm_activities_table.php b/erp/database/migrations/2026_10_05_100003_create_crm_activities_table.php new file mode 100644 index 00000000000..0bf1f1a9f31 --- /dev/null +++ b/erp/database/migrations/2026_10_05_100003_create_crm_activities_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->foreignId('lead_id')->constrained('crm_leads')->cascadeOnDelete(); + $table->string('type'); // call|meeting|email|task|note + $table->string('subject'); + $table->text('description')->nullable(); + $table->dateTime('scheduled_at')->nullable(); + $table->dateTime('completed_at')->nullable(); + $table->boolean('is_done')->default(false); + $table->foreignId('assigned_to')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('crm_activities'); + } +}; diff --git a/erp/database/migrations/2026_10_10_100001_create_projects_table.php b/erp/database/migrations/2026_10_10_100001_create_projects_table.php new file mode 100644 index 00000000000..30cc51492b0 --- /dev/null +++ b/erp/database/migrations/2026_10_10_100001_create_projects_table.php @@ -0,0 +1,40 @@ +id(); + $table->foreignId('tenant_id')->constrained('tenants'); + $table->string('name'); + $table->string('code')->nullable(); + $table->text('description')->nullable(); + $table->enum('status', ['draft', 'active', 'on_hold', 'completed', 'cancelled'])->default('draft'); + $table->enum('priority', ['low', 'medium', 'high', 'critical'])->default('medium'); + $table->decimal('budget', 12, 2)->nullable(); + $table->decimal('spent_budget', 12, 2)->default(0); + $table->date('start_date')->nullable(); + $table->date('end_date')->nullable(); + $table->string('client_name')->nullable(); + $table->foreignId('manager_id')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('projects'); + } +}; diff --git a/erp/database/migrations/2026_10_10_100002_create_project_members_table.php b/erp/database/migrations/2026_10_10_100002_create_project_members_table.php new file mode 100644 index 00000000000..13b8879dbb2 --- /dev/null +++ b/erp/database/migrations/2026_10_10_100002_create_project_members_table.php @@ -0,0 +1,26 @@ +id(); + $table->foreignId('project_id')->constrained('projects')->cascadeOnDelete(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->string('role')->default('member'); + $table->timestamps(); + + $table->unique(['project_id', 'user_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('project_members'); + } +}; diff --git a/erp/database/migrations/2026_10_11_100001_create_tasks_table.php b/erp/database/migrations/2026_10_11_100001_create_tasks_table.php new file mode 100644 index 00000000000..fb1ddc11dfa --- /dev/null +++ b/erp/database/migrations/2026_10_11_100001_create_tasks_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('tenant_id')->constrained('tenants'); + $table->foreignId('project_id')->constrained('projects')->cascadeOnDelete(); + $table->string('title'); + $table->text('description')->nullable(); + $table->enum('status', ['todo', 'in_progress', 'review', 'done', 'cancelled'])->default('todo'); + $table->enum('priority', ['low', 'medium', 'high', 'urgent'])->default('medium'); + $table->foreignId('assignee_id')->nullable()->constrained('users')->nullOnDelete(); + $table->date('due_date')->nullable(); + $table->decimal('estimated_hours', 6, 2)->nullable(); + $table->decimal('actual_hours', 6, 2)->default(0); + $table->unsignedInteger('sequence')->default(0); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('tasks'); + } +}; diff --git a/erp/database/migrations/2026_10_11_100002_create_milestones_table.php b/erp/database/migrations/2026_10_11_100002_create_milestones_table.php new file mode 100644 index 00000000000..45d7918d82e --- /dev/null +++ b/erp/database/migrations/2026_10_11_100002_create_milestones_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignId('tenant_id')->constrained('tenants'); + $table->foreignId('project_id')->constrained('projects')->cascadeOnDelete(); + $table->string('name'); + $table->text('description')->nullable(); + $table->date('due_date')->nullable(); + $table->boolean('is_completed')->default(false); + $table->dateTime('completed_at')->nullable(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('milestones'); + } +}; diff --git a/erp/database/migrations/2026_10_12_100001_create_time_entries_table.php b/erp/database/migrations/2026_10_12_100001_create_time_entries_table.php new file mode 100644 index 00000000000..1704ba88cb4 --- /dev/null +++ b/erp/database/migrations/2026_10_12_100001_create_time_entries_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignId('tenant_id')->constrained('tenants'); + $table->foreignId('task_id')->constrained('tasks')->cascadeOnDelete(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->text('description')->nullable(); + $table->decimal('hours', 6, 2); + $table->date('date'); + $table->boolean('is_billable')->default(true); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('time_entries'); + } +}; diff --git a/erp/database/migrations/2026_10_15_100001_create_pos_sessions_table.php b/erp/database/migrations/2026_10_15_100001_create_pos_sessions_table.php new file mode 100644 index 00000000000..ac3894e3b0c --- /dev/null +++ b/erp/database/migrations/2026_10_15_100001_create_pos_sessions_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete(); + $table->string('name'); + $table->foreignId('warehouse_id')->nullable()->constrained('warehouses')->nullOnDelete(); + $table->foreignId('opened_by')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('closed_by')->nullable()->constrained('users')->nullOnDelete(); + $table->enum('status', ['open', 'closed'])->default('open'); + $table->dateTime('opened_at')->nullable(); + $table->dateTime('closed_at')->nullable(); + $table->decimal('opening_cash', 10, 2)->default(0); + $table->decimal('closing_cash', 10, 2)->nullable(); + $table->decimal('expected_cash', 10, 2)->default(0); + $table->decimal('total_sales', 12, 2)->default(0); + $table->decimal('total_refunds', 12, 2)->default(0); + $table->text('notes')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('pos_sessions'); + } +}; diff --git a/erp/database/migrations/2026_10_15_100002_create_pos_orders_table.php b/erp/database/migrations/2026_10_15_100002_create_pos_orders_table.php new file mode 100644 index 00000000000..d8ee9b43f2e --- /dev/null +++ b/erp/database/migrations/2026_10_15_100002_create_pos_orders_table.php @@ -0,0 +1,38 @@ +id(); + $table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete(); + $table->foreignId('session_id')->constrained('pos_sessions')->cascadeOnDelete(); + $table->string('receipt_number')->nullable()->unique(); + $table->string('customer_name')->nullable(); + $table->string('customer_email')->nullable(); + $table->decimal('subtotal', 12, 2)->default(0); + $table->decimal('discount_amount', 12, 2)->default(0); + $table->decimal('tax_amount', 12, 2)->default(0); + $table->decimal('total', 12, 2)->default(0); + $table->decimal('amount_paid', 12, 2)->default(0); + $table->decimal('change_given', 12, 2)->default(0); + $table->enum('payment_method', ['cash', 'card', 'digital_wallet', 'split'])->default('cash'); + $table->enum('status', ['pending', 'completed', 'refunded', 'voided'])->default('pending'); + $table->text('notes')->nullable(); + $table->foreignId('served_by')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('pos_orders'); + } +}; diff --git a/erp/database/migrations/2026_10_15_100003_create_pos_order_items_table.php b/erp/database/migrations/2026_10_15_100003_create_pos_order_items_table.php new file mode 100644 index 00000000000..aa8add545f9 --- /dev/null +++ b/erp/database/migrations/2026_10_15_100003_create_pos_order_items_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignId('order_id')->constrained('pos_orders')->cascadeOnDelete(); + $table->foreignId('product_id')->nullable()->constrained('products')->nullOnDelete(); + $table->string('product_name'); + $table->string('product_sku')->nullable(); + $table->decimal('quantity', 10, 3)->default(1); + $table->decimal('unit_price', 10, 2); + $table->decimal('discount_percent', 5, 2)->default(0); + $table->decimal('line_total', 12, 2); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('pos_order_items'); + } +}; diff --git a/erp/database/migrations/2026_10_16_100001_create_pos_payments_table.php b/erp/database/migrations/2026_10_16_100001_create_pos_payments_table.php new file mode 100644 index 00000000000..f5ccc48a865 --- /dev/null +++ b/erp/database/migrations/2026_10_16_100001_create_pos_payments_table.php @@ -0,0 +1,25 @@ +id(); + $table->foreignId('order_id')->constrained('pos_orders')->cascadeOnDelete(); + $table->enum('method', ['cash', 'card', 'digital_wallet']); + $table->decimal('amount', 10, 2); + $table->string('reference')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('pos_payments'); + } +}; diff --git a/erp/database/migrations/2026_10_20_100001_create_helpdesk_teams_table.php b/erp/database/migrations/2026_10_20_100001_create_helpdesk_teams_table.php new file mode 100644 index 00000000000..59331f5d47b --- /dev/null +++ b/erp/database/migrations/2026_10_20_100001_create_helpdesk_teams_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete(); + $table->string('name'); + $table->text('description')->nullable(); + $table->boolean('auto_assign')->default(false); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + + Schema::create('helpdesk_team_user', function (Blueprint $table) { + $table->foreignId('helpdesk_team_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->primary(['helpdesk_team_id', 'user_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('helpdesk_team_user'); + Schema::dropIfExists('helpdesk_teams'); + } +}; diff --git a/erp/database/migrations/2026_10_20_100002_create_helpdesk_tickets_table.php b/erp/database/migrations/2026_10_20_100002_create_helpdesk_tickets_table.php new file mode 100644 index 00000000000..bab0df96a83 --- /dev/null +++ b/erp/database/migrations/2026_10_20_100002_create_helpdesk_tickets_table.php @@ -0,0 +1,38 @@ +id(); + $table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete(); + $table->string('ticket_number')->nullable()->unique(); + $table->string('subject'); + $table->text('description')->nullable(); + $table->enum('type', ['question', 'issue', 'feature_request', 'other'])->default('issue'); + $table->enum('priority', ['low', 'medium', 'high', 'urgent'])->default('medium'); + $table->enum('status', ['open', 'in_progress', 'pending', 'resolved', 'closed'])->default('open'); + $table->foreignId('team_id')->nullable()->constrained('helpdesk_teams')->nullOnDelete(); + $table->foreignId('assigned_to')->nullable()->constrained('users')->nullOnDelete(); + $table->string('customer_name')->nullable(); + $table->string('customer_email')->nullable(); + $table->dateTime('sla_deadline')->nullable(); + $table->dateTime('first_response_at')->nullable(); + $table->dateTime('resolved_at')->nullable(); + $table->dateTime('closed_at')->nullable(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('helpdesk_tickets'); + } +}; diff --git a/erp/database/migrations/2026_10_20_100003_create_helpdesk_messages_table.php b/erp/database/migrations/2026_10_20_100003_create_helpdesk_messages_table.php new file mode 100644 index 00000000000..ded3c595cce --- /dev/null +++ b/erp/database/migrations/2026_10_20_100003_create_helpdesk_messages_table.php @@ -0,0 +1,25 @@ +id(); + $table->foreignId('ticket_id')->constrained('helpdesk_tickets')->cascadeOnDelete(); + $table->text('body'); + $table->boolean('is_internal')->default(false); + $table->foreignId('author_id')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('helpdesk_messages'); + } +}; diff --git a/erp/database/migrations/2026_10_21_100001_create_helpdesk_sla_policies_table.php b/erp/database/migrations/2026_10_21_100001_create_helpdesk_sla_policies_table.php new file mode 100644 index 00000000000..fd509471572 --- /dev/null +++ b/erp/database/migrations/2026_10_21_100001_create_helpdesk_sla_policies_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete(); + $table->string('name'); + $table->enum('priority', ['low', 'medium', 'high', 'urgent']); + $table->unsignedInteger('response_hours'); + $table->unsignedInteger('resolution_hours'); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('helpdesk_sla_policies'); + } +}; diff --git a/erp/database/migrations/2026_10_25_100001_create_chart_of_accounts_table.php b/erp/database/migrations/2026_10_25_100001_create_chart_of_accounts_table.php new file mode 100644 index 00000000000..61426aa3ccb --- /dev/null +++ b/erp/database/migrations/2026_10_25_100001_create_chart_of_accounts_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->string('code', 20); + $table->string('name'); + $table->enum('type', ['asset', 'liability', 'equity', 'revenue', 'expense']); + $table->string('sub_type')->nullable(); + $table->foreignId('parent_id')->nullable()->constrained('chart_of_accounts')->nullOnDelete(); + $table->boolean('is_active')->default(true); + $table->enum('normal_balance', ['debit', 'credit']); + $table->text('description')->nullable(); + $table->timestamps(); + + $table->unique(['tenant_id', 'code']); + }); + } + + public function down(): void + { + Schema::dropIfExists('chart_of_accounts'); + } +}; diff --git a/erp/database/migrations/2026_10_25_100002_create_accounting_periods_table.php b/erp/database/migrations/2026_10_25_100002_create_accounting_periods_table.php new file mode 100644 index 00000000000..fbc85cb3c44 --- /dev/null +++ b/erp/database/migrations/2026_10_25_100002_create_accounting_periods_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->date('start_date'); + $table->date('end_date'); + $table->enum('status', ['open', 'closed', 'locked'])->default('open'); + $table->unsignedSmallInteger('fiscal_year'); + $table->unsignedTinyInteger('quarter')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('accounting_periods'); + } +}; diff --git a/erp/database/migrations/2026_10_25_100003_create_accounting_journal_entries_table.php b/erp/database/migrations/2026_10_25_100003_create_accounting_journal_entries_table.php new file mode 100644 index 00000000000..7e6cdb3c5a9 --- /dev/null +++ b/erp/database/migrations/2026_10_25_100003_create_accounting_journal_entries_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->string('entry_number')->nullable(); + $table->string('reference')->nullable(); + $table->text('description')->nullable(); + $table->date('entry_date'); + $table->foreignId('period_id')->nullable()->constrained('accounting_periods')->nullOnDelete(); + $table->enum('status', ['draft', 'posted', 'reversed'])->default('draft'); + $table->boolean('is_adjusting')->default(false); + $table->foreignId('reversed_by')->nullable()->constrained('accounting_journal_entries')->nullOnDelete(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('posted_by')->nullable()->constrained('users')->nullOnDelete(); + $table->dateTime('posted_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('accounting_journal_entries'); + } +}; diff --git a/erp/database/migrations/2026_10_25_100004_create_journal_entry_lines_table.php b/erp/database/migrations/2026_10_25_100004_create_journal_entry_lines_table.php new file mode 100644 index 00000000000..55ef47c5d66 --- /dev/null +++ b/erp/database/migrations/2026_10_25_100004_create_journal_entry_lines_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignId('journal_entry_id')->constrained('accounting_journal_entries')->cascadeOnDelete(); + $table->foreignId('account_id')->constrained('chart_of_accounts'); + $table->string('description')->nullable(); + // debit >= 0 and credit >= 0 (enforced at application level; SQLite does not support check constraints natively) + $table->decimal('debit', 14, 2)->default(0); + $table->decimal('credit', 14, 2)->default(0); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('accounting_journal_entry_lines'); + } +}; diff --git a/erp/database/migrations/2026_10_26_100001_create_account_balances_table.php b/erp/database/migrations/2026_10_26_100001_create_account_balances_table.php new file mode 100644 index 00000000000..829c3770671 --- /dev/null +++ b/erp/database/migrations/2026_10_26_100001_create_account_balances_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->foreignId('account_id')->constrained('chart_of_accounts')->cascadeOnDelete(); + $table->foreignId('period_id')->nullable()->constrained('accounting_periods')->nullOnDelete(); + $table->decimal('opening_balance', 14, 2)->default(0); + $table->decimal('debit_total', 14, 2)->default(0); + $table->decimal('credit_total', 14, 2)->default(0); + $table->decimal('closing_balance', 14, 2)->default(0); + $table->timestamps(); + + $table->unique(['account_id', 'period_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('account_balances'); + } +}; diff --git a/erp/database/migrations/2026_10_28_100001_create_vehicles_table.php b/erp/database/migrations/2026_10_28_100001_create_vehicles_table.php new file mode 100644 index 00000000000..9b7a576c5ed --- /dev/null +++ b/erp/database/migrations/2026_10_28_100001_create_vehicles_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete(); + $table->string('name'); + $table->string('plate_number')->nullable(); + $table->string('make')->nullable(); + $table->string('model')->nullable(); + $table->unsignedSmallInteger('year')->nullable(); + $table->string('color')->nullable(); + $table->string('vin')->nullable(); + $table->enum('type', ['car', 'truck', 'van', 'motorcycle', 'other'])->default('car'); + $table->enum('status', ['active', 'in_service', 'out_of_service', 'sold'])->default('active'); + $table->decimal('odometer_km', 10, 1)->default(0); + $table->enum('fuel_type', ['petrol', 'diesel', 'electric', 'hybrid'])->default('petrol'); + $table->foreignId('assigned_to')->nullable()->constrained('users')->nullOnDelete(); + $table->date('insurance_expiry')->nullable(); + $table->date('registration_expiry')->nullable(); + $table->text('notes')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('fleet_vehicles'); + } +}; diff --git a/erp/database/migrations/2026_10_28_100002_create_fuel_logs_table.php b/erp/database/migrations/2026_10_28_100002_create_fuel_logs_table.php new file mode 100644 index 00000000000..823879d932e --- /dev/null +++ b/erp/database/migrations/2026_10_28_100002_create_fuel_logs_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete(); + $table->foreignId('vehicle_id')->constrained('fleet_vehicles')->cascadeOnDelete(); + $table->date('log_date'); + $table->decimal('odometer_km', 10, 1); + $table->decimal('liters', 8, 2); + $table->decimal('cost_per_liter', 8, 3); + $table->decimal('total_cost', 10, 2); + $table->string('fuel_type')->nullable(); + $table->string('station')->nullable(); + $table->foreignId('driver_id')->nullable()->constrained('users')->nullOnDelete(); + $table->text('notes')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('fleet_fuel_logs'); + } +}; diff --git a/erp/database/migrations/2026_10_28_100003_create_vehicle_maintenances_table.php b/erp/database/migrations/2026_10_28_100003_create_vehicle_maintenances_table.php new file mode 100644 index 00000000000..f6bc9f7b9d2 --- /dev/null +++ b/erp/database/migrations/2026_10_28_100003_create_vehicle_maintenances_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete(); + $table->foreignId('vehicle_id')->constrained('fleet_vehicles')->cascadeOnDelete(); + $table->enum('type', ['scheduled', 'repair', 'inspection', 'other'])->default('scheduled'); + $table->text('description')->nullable(); + $table->string('vendor')->nullable(); + $table->date('service_date'); + $table->date('due_date')->nullable(); + $table->decimal('odometer_km', 10, 1)->nullable(); + $table->decimal('cost', 10, 2)->default(0); + $table->enum('status', ['scheduled', 'in_progress', 'completed', 'cancelled'])->default('scheduled'); + $table->text('notes')->nullable(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('fleet_maintenances'); + } +}; diff --git a/erp/database/migrations/2026_10_29_100001_create_vehicle_assignments_table.php b/erp/database/migrations/2026_10_29_100001_create_vehicle_assignments_table.php new file mode 100644 index 00000000000..957b9b873a3 --- /dev/null +++ b/erp/database/migrations/2026_10_29_100001_create_vehicle_assignments_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete(); + $table->foreignId('vehicle_id')->constrained('fleet_vehicles')->cascadeOnDelete(); + $table->foreignId('driver_id')->nullable()->constrained('users')->nullOnDelete(); + $table->string('purpose')->nullable(); + $table->dateTime('assigned_at'); + $table->dateTime('returned_at')->nullable(); + $table->decimal('start_odometer', 10, 1)->nullable(); + $table->decimal('end_odometer', 10, 1)->nullable(); + $table->text('notes')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('fleet_vehicle_assignments'); + } +}; diff --git a/erp/database/migrations/2026_11_01_100001_create_mailing_lists_table.php b/erp/database/migrations/2026_11_01_100001_create_mailing_lists_table.php new file mode 100644 index 00000000000..c80a955cc99 --- /dev/null +++ b/erp/database/migrations/2026_11_01_100001_create_mailing_lists_table.php @@ -0,0 +1,25 @@ +id(); + $table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete(); + $table->string('name'); + $table->text('description')->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('mailing_lists'); + } +}; diff --git a/erp/database/migrations/2026_11_01_100002_create_subscribers_table.php b/erp/database/migrations/2026_11_01_100002_create_subscribers_table.php new file mode 100644 index 00000000000..39e9257f944 --- /dev/null +++ b/erp/database/migrations/2026_11_01_100002_create_subscribers_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete(); + $table->string('email'); + $table->string('name')->nullable(); + $table->enum('status', ['subscribed', 'unsubscribed', 'bounced'])->default('subscribed'); + $table->dateTime('subscribed_at')->nullable(); + $table->dateTime('unsubscribed_at')->nullable(); + $table->string('source')->nullable(); + $table->timestamps(); + $table->unique(['tenant_id', 'email']); + }); + } + + public function down(): void + { + Schema::dropIfExists('subscribers'); + } +}; diff --git a/erp/database/migrations/2026_11_01_100003_create_mailing_list_subscriber_table.php b/erp/database/migrations/2026_11_01_100003_create_mailing_list_subscriber_table.php new file mode 100644 index 00000000000..7134117ffb6 --- /dev/null +++ b/erp/database/migrations/2026_11_01_100003_create_mailing_list_subscriber_table.php @@ -0,0 +1,22 @@ +foreignId('mailing_list_id')->constrained('mailing_lists')->cascadeOnDelete(); + $table->foreignId('subscriber_id')->constrained('subscribers')->cascadeOnDelete(); + $table->primary(['mailing_list_id', 'subscriber_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('mailing_list_subscriber'); + } +}; diff --git a/erp/database/migrations/2026_11_02_100001_create_email_campaigns_table.php b/erp/database/migrations/2026_11_02_100001_create_email_campaigns_table.php new file mode 100644 index 00000000000..0abf0717fba --- /dev/null +++ b/erp/database/migrations/2026_11_02_100001_create_email_campaigns_table.php @@ -0,0 +1,40 @@ +id(); + $table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete(); + $table->string('name'); + $table->string('subject'); + $table->string('preview_text')->nullable(); + $table->longText('body_html'); + $table->text('body_text')->nullable(); + $table->string('from_name')->nullable(); + $table->string('from_email')->nullable(); + $table->foreignId('mailing_list_id')->nullable()->constrained('mailing_lists')->nullOnDelete(); + $table->enum('status', ['draft', 'scheduled', 'sending', 'sent', 'cancelled'])->default('draft'); + $table->dateTime('scheduled_at')->nullable(); + $table->dateTime('sent_at')->nullable(); + $table->unsignedInteger('total_recipients')->default(0); + $table->unsignedInteger('sent_count')->default(0); + $table->unsignedInteger('open_count')->default(0); + $table->unsignedInteger('click_count')->default(0); + $table->unsignedInteger('bounce_count')->default(0); + $table->unsignedInteger('unsubscribe_count')->default(0); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('email_campaigns'); + } +}; diff --git a/erp/database/migrations/2026_11_02_100002_create_campaign_sends_table.php b/erp/database/migrations/2026_11_02_100002_create_campaign_sends_table.php new file mode 100644 index 00000000000..a527fc5c936 --- /dev/null +++ b/erp/database/migrations/2026_11_02_100002_create_campaign_sends_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('campaign_id')->constrained('email_campaigns')->cascadeOnDelete(); + $table->foreignId('subscriber_id')->constrained('subscribers')->cascadeOnDelete(); + $table->enum('status', ['pending', 'sent', 'delivered', 'opened', 'clicked', 'bounced', 'unsubscribed'])->default('pending'); + $table->dateTime('sent_at')->nullable(); + $table->dateTime('opened_at')->nullable(); + $table->dateTime('clicked_at')->nullable(); + $table->timestamps(); + $table->unique(['campaign_id', 'subscriber_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('campaign_sends'); + } +}; diff --git a/erp/database/migrations/2026_11_05_100001_create_service_orders_table.php b/erp/database/migrations/2026_11_05_100001_create_service_orders_table.php new file mode 100644 index 00000000000..660bb073ce9 --- /dev/null +++ b/erp/database/migrations/2026_11_05_100001_create_service_orders_table.php @@ -0,0 +1,41 @@ +id(); + $table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete(); + $table->string('order_number')->nullable(); + $table->string('title'); + $table->text('description')->nullable(); + $table->enum('type', ['installation', 'repair', 'maintenance', 'inspection', 'other'])->default('repair'); + $table->enum('priority', ['low', 'medium', 'high', 'urgent'])->default('medium'); + $table->enum('status', ['pending', 'assigned', 'in_progress', 'on_hold', 'completed', 'cancelled'])->default('pending'); + $table->string('customer_name')->nullable(); + $table->string('customer_email')->nullable(); + $table->string('customer_phone')->nullable(); + $table->text('address')->nullable(); + $table->dateTime('scheduled_at')->nullable(); + $table->dateTime('started_at')->nullable(); + $table->dateTime('completed_at')->nullable(); + $table->unsignedInteger('estimated_duration')->nullable(); + $table->unsignedInteger('actual_duration')->nullable(); + $table->foreignId('assigned_to')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->text('notes')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('service_orders'); + } +}; diff --git a/erp/database/migrations/2026_11_05_100002_create_service_order_items_table.php b/erp/database/migrations/2026_11_05_100002_create_service_order_items_table.php new file mode 100644 index 00000000000..88ecc0ce0bf --- /dev/null +++ b/erp/database/migrations/2026_11_05_100002_create_service_order_items_table.php @@ -0,0 +1,26 @@ +id(); + $table->foreignId('service_order_id')->constrained('service_orders')->cascadeOnDelete(); + $table->string('description'); + $table->decimal('quantity', 8, 2)->default(1); + $table->decimal('unit_price', 10, 2)->default(0); + $table->decimal('line_total', 12, 2)->default(0); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('service_order_items'); + } +}; diff --git a/erp/database/migrations/2026_11_06_100001_create_service_checklists_table.php b/erp/database/migrations/2026_11_06_100001_create_service_checklists_table.php new file mode 100644 index 00000000000..8cdef939fd2 --- /dev/null +++ b/erp/database/migrations/2026_11_06_100001_create_service_checklists_table.php @@ -0,0 +1,25 @@ +id(); + $table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete(); + $table->string('name'); + $table->text('description')->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('service_checklists'); + } +}; diff --git a/erp/database/migrations/2026_11_06_100002_create_service_checklist_items_table.php b/erp/database/migrations/2026_11_06_100002_create_service_checklist_items_table.php new file mode 100644 index 00000000000..6b6f95d9eeb --- /dev/null +++ b/erp/database/migrations/2026_11_06_100002_create_service_checklist_items_table.php @@ -0,0 +1,24 @@ +id(); + $table->foreignId('checklist_id')->constrained('service_checklists')->cascadeOnDelete(); + $table->string('label'); + $table->unsignedInteger('sequence')->default(0); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('service_checklist_items'); + } +}; diff --git a/erp/database/migrations/2026_11_06_100003_create_service_order_checklist_results_table.php b/erp/database/migrations/2026_11_06_100003_create_service_order_checklist_results_table.php new file mode 100644 index 00000000000..47399451f67 --- /dev/null +++ b/erp/database/migrations/2026_11_06_100003_create_service_order_checklist_results_table.php @@ -0,0 +1,25 @@ +id(); + $table->foreignId('service_order_id')->constrained('service_orders')->cascadeOnDelete(); + $table->foreignId('checklist_item_id')->constrained('service_checklist_items')->cascadeOnDelete(); + $table->boolean('is_checked')->default(false); + $table->string('notes')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('service_order_checklist_results'); + } +}; diff --git a/erp/database/migrations/2026_11_10_100001_create_approval_workflows_table.php b/erp/database/migrations/2026_11_10_100001_create_approval_workflows_table.php new file mode 100644 index 00000000000..fddd80fc7df --- /dev/null +++ b/erp/database/migrations/2026_11_10_100001_create_approval_workflows_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete(); + $table->string('name'); + $table->string('entity_type'); + $table->decimal('min_amount', 14, 2)->nullable(); + $table->decimal('max_amount', 14, 2)->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('approval_workflows'); + } +}; diff --git a/erp/database/migrations/2026_11_10_100002_create_approval_steps_table.php b/erp/database/migrations/2026_11_10_100002_create_approval_steps_table.php new file mode 100644 index 00000000000..9fb796cdb79 --- /dev/null +++ b/erp/database/migrations/2026_11_10_100002_create_approval_steps_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignId('workflow_id')->constrained('approval_workflows')->cascadeOnDelete(); + $table->unsignedTinyInteger('step_number'); + $table->string('name'); + $table->foreignId('approver_id')->nullable()->constrained('users')->nullOnDelete(); + $table->string('approver_role')->nullable(); + $table->boolean('is_required')->default(true); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('approval_steps'); + } +}; diff --git a/erp/database/migrations/2026_11_10_100003_create_approval_requests_table.php b/erp/database/migrations/2026_11_10_100003_create_approval_requests_table.php new file mode 100644 index 00000000000..439698900fe --- /dev/null +++ b/erp/database/migrations/2026_11_10_100003_create_approval_requests_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete(); + $table->foreignId('workflow_id')->nullable()->constrained('approval_workflows')->nullOnDelete(); + $table->string('entity_type'); + $table->unsignedBigInteger('entity_id'); + $table->string('entity_title'); + $table->enum('status', ['pending', 'approved', 'rejected', 'cancelled'])->default('pending'); + $table->unsignedTinyInteger('current_step')->default(1); + $table->unsignedTinyInteger('total_steps')->default(1); + $table->foreignId('requested_by')->nullable()->constrained('users')->nullOnDelete(); + $table->dateTime('approved_at')->nullable(); + $table->dateTime('rejected_at')->nullable(); + $table->text('rejection_reason')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('approval_requests'); + } +}; diff --git a/erp/database/migrations/2026_11_10_100004_create_approval_actions_table.php b/erp/database/migrations/2026_11_10_100004_create_approval_actions_table.php new file mode 100644 index 00000000000..965d916e06d --- /dev/null +++ b/erp/database/migrations/2026_11_10_100004_create_approval_actions_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignId('request_id')->constrained('approval_requests')->cascadeOnDelete(); + $table->unsignedTinyInteger('step_number'); + $table->enum('action', ['approved', 'rejected', 'delegated']); + $table->foreignId('actor_id')->nullable()->constrained('users')->nullOnDelete(); + $table->text('comments')->nullable(); + $table->dateTime('acted_at'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('approval_actions'); + } +}; diff --git a/erp/database/migrations/2026_11_15_100001_create_store_settings_table.php b/erp/database/migrations/2026_11_15_100001_create_store_settings_table.php new file mode 100644 index 00000000000..84cd3949319 --- /dev/null +++ b/erp/database/migrations/2026_11_15_100001_create_store_settings_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('tenant_id')->unique()->constrained('tenants')->cascadeOnDelete(); + $table->string('store_name'); + $table->string('store_slug')->unique(); + $table->text('description')->nullable(); + $table->string('currency_code', 3)->default('USD'); + $table->boolean('is_active')->default(true); + $table->string('logo_path')->nullable(); + $table->string('primary_color', 7)->default('#4f46e5'); + $table->boolean('allow_guest_checkout')->default(true); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('store_settings'); + } +}; diff --git a/erp/database/migrations/2026_11_15_100002_create_store_categories_table.php b/erp/database/migrations/2026_11_15_100002_create_store_categories_table.php new file mode 100644 index 00000000000..f978053605f --- /dev/null +++ b/erp/database/migrations/2026_11_15_100002_create_store_categories_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete(); + $table->string('name'); + $table->string('slug'); + $table->text('description')->nullable(); + $table->foreignId('parent_id')->nullable()->constrained('store_categories')->nullOnDelete(); + $table->unsignedInteger('sort_order')->default(0); + $table->boolean('is_active')->default(true); + $table->timestamps(); + + $table->unique(['tenant_id', 'slug']); + }); + } + + public function down(): void + { + Schema::dropIfExists('store_categories'); + } +}; diff --git a/erp/database/migrations/2026_11_15_100003_create_store_products_table.php b/erp/database/migrations/2026_11_15_100003_create_store_products_table.php new file mode 100644 index 00000000000..d65ceae1128 --- /dev/null +++ b/erp/database/migrations/2026_11_15_100003_create_store_products_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete(); + $table->foreignId('product_id')->constrained('products')->cascadeOnDelete(); + $table->foreignId('category_id')->nullable()->constrained('store_categories')->nullOnDelete(); + $table->decimal('store_price', 10, 2); + $table->decimal('compare_price', 10, 2)->nullable(); + $table->boolean('is_featured')->default(false); + $table->boolean('is_visible')->default(true); + $table->unsignedInteger('sort_order')->default(0); + $table->text('short_description')->nullable(); + $table->longText('long_description')->nullable(); + $table->string('meta_title')->nullable(); + $table->text('meta_description')->nullable(); + $table->timestamps(); + + $table->unique(['tenant_id', 'product_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('store_products'); + } +}; diff --git a/erp/database/migrations/2026_11_15_100004_create_store_orders_table.php b/erp/database/migrations/2026_11_15_100004_create_store_orders_table.php new file mode 100644 index 00000000000..3def82411d0 --- /dev/null +++ b/erp/database/migrations/2026_11_15_100004_create_store_orders_table.php @@ -0,0 +1,40 @@ +id(); + $table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete(); + $table->string('order_number')->nullable(); + $table->enum('status', ['pending','confirmed','processing','shipped','delivered','cancelled','refunded'])->default('pending'); + $table->string('customer_name'); + $table->string('customer_email'); + $table->string('customer_phone')->nullable(); + $table->text('shipping_address')->nullable(); + $table->text('billing_address')->nullable(); + $table->decimal('subtotal', 12, 2)->default(0); + $table->decimal('discount_amount', 12, 2)->default(0); + $table->decimal('shipping_amount', 12, 2)->default(0); + $table->decimal('tax_amount', 12, 2)->default(0); + $table->decimal('total', 12, 2)->default(0); + $table->string('payment_method')->nullable(); + $table->enum('payment_status', ['pending','paid','failed','refunded'])->default('pending'); + $table->text('notes')->nullable(); + $table->string('ip_address')->nullable(); + $table->foreignId('processed_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('store_orders'); + } +}; diff --git a/erp/database/migrations/2026_11_15_100005_create_store_order_items_table.php b/erp/database/migrations/2026_11_15_100005_create_store_order_items_table.php new file mode 100644 index 00000000000..5d6496843ac --- /dev/null +++ b/erp/database/migrations/2026_11_15_100005_create_store_order_items_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('order_id')->constrained('store_orders')->cascadeOnDelete(); + $table->foreignId('store_product_id')->nullable()->constrained('store_products')->nullOnDelete(); + $table->string('product_name'); + $table->string('product_sku')->nullable(); + $table->unsignedInteger('quantity')->default(1); + $table->decimal('unit_price', 10, 2); + $table->decimal('line_total', 12, 2); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('store_order_items'); + } +}; diff --git a/erp/database/migrations/2026_11_20_100001_add_two_factor_to_users_table.php b/erp/database/migrations/2026_11_20_100001_add_two_factor_to_users_table.php new file mode 100644 index 00000000000..fd21486f7eb --- /dev/null +++ b/erp/database/migrations/2026_11_20_100001_add_two_factor_to_users_table.php @@ -0,0 +1,30 @@ +text('two_factor_secret')->nullable()->after('password'); + } + if (!Schema::hasColumn('users', 'two_factor_enabled')) { + $table->boolean('two_factor_enabled')->default(false)->after('two_factor_secret'); + } + if (!Schema::hasColumn('users', 'two_factor_recovery_codes')) { + $table->text('two_factor_recovery_codes')->nullable()->after('two_factor_enabled'); + } + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn(['two_factor_secret', 'two_factor_enabled', 'two_factor_recovery_codes']); + }); + } +}; diff --git a/erp/database/migrations/2026_11_20_100002_create_webhooks_table.php b/erp/database/migrations/2026_11_20_100002_create_webhooks_table.php new file mode 100644 index 00000000000..aed0761f0c2 --- /dev/null +++ b/erp/database/migrations/2026_11_20_100002_create_webhooks_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete(); + $table->string('name'); + $table->string('url'); + $table->json('events')->nullable(); + $table->string('secret')->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('webhooks'); + } +}; diff --git a/erp/database/migrations/2026_11_20_100003_create_webhook_deliveries_table.php b/erp/database/migrations/2026_11_20_100003_create_webhook_deliveries_table.php new file mode 100644 index 00000000000..a016446eec4 --- /dev/null +++ b/erp/database/migrations/2026_11_20_100003_create_webhook_deliveries_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignId('webhook_id')->constrained('webhooks')->cascadeOnDelete(); + $table->string('event'); + $table->json('payload'); + $table->unsignedSmallInteger('response_status')->nullable(); + $table->text('response_body')->nullable(); + $table->dateTime('delivered_at')->nullable(); + $table->dateTime('failed_at')->nullable(); + $table->unsignedTinyInteger('attempts')->default(0); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('webhook_deliveries'); + } +}; diff --git a/erp/database/migrations/2026_12_01_100001_create_discuss_channels_table.php b/erp/database/migrations/2026_12_01_100001_create_discuss_channels_table.php new file mode 100644 index 00000000000..5f47f06fc9e --- /dev/null +++ b/erp/database/migrations/2026_12_01_100001_create_discuss_channels_table.php @@ -0,0 +1,29 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->string('name'); + $table->string('slug')->unique(); + $table->text('description')->nullable(); + $table->string('type')->default('public'); // public, private, direct + $table->boolean('is_archived')->default(false); + $table->unsignedBigInteger('created_by'); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('discuss_channels'); + } +}; diff --git a/erp/database/migrations/2026_12_01_100002_create_discuss_channel_members_table.php b/erp/database/migrations/2026_12_01_100002_create_discuss_channel_members_table.php new file mode 100644 index 00000000000..2b197348bc4 --- /dev/null +++ b/erp/database/migrations/2026_12_01_100002_create_discuss_channel_members_table.php @@ -0,0 +1,25 @@ +id(); + $table->foreignId('channel_id')->constrained('discuss_channels')->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->timestamp('last_read_at')->nullable(); + $table->timestamps(); + $table->unique(['channel_id', 'user_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('discuss_channel_members'); + } +}; diff --git a/erp/database/migrations/2026_12_01_100003_create_discuss_messages_table.php b/erp/database/migrations/2026_12_01_100003_create_discuss_messages_table.php new file mode 100644 index 00000000000..46464edea43 --- /dev/null +++ b/erp/database/migrations/2026_12_01_100003_create_discuss_messages_table.php @@ -0,0 +1,29 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->foreignId('channel_id')->constrained('discuss_channels')->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->text('body'); + $table->unsignedBigInteger('parent_id')->nullable(); // for thread replies + $table->boolean('is_edited')->default(false); + $table->boolean('is_pinned')->default(false); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('discuss_messages'); + } +}; diff --git a/erp/database/migrations/2026_12_10_100001_create_salary_structures_table.php b/erp/database/migrations/2026_12_10_100001_create_salary_structures_table.php new file mode 100644 index 00000000000..ac67d7cc9b3 --- /dev/null +++ b/erp/database/migrations/2026_12_10_100001_create_salary_structures_table.php @@ -0,0 +1,19 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->string('name'); + $table->string('code')->unique(); + $table->text('description')->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + $table->softDeletes(); + }); + } + public function down(): void { Schema::dropIfExists('salary_structures'); } +}; diff --git a/erp/database/migrations/2026_12_10_100002_create_salary_rules_table.php b/erp/database/migrations/2026_12_10_100002_create_salary_rules_table.php new file mode 100644 index 00000000000..be3e5fb8581 --- /dev/null +++ b/erp/database/migrations/2026_12_10_100002_create_salary_rules_table.php @@ -0,0 +1,25 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->foreignId('structure_id')->constrained('salary_structures')->cascadeOnDelete(); + $table->string('name'); + $table->string('code'); + $table->string('category')->default('earnings'); + $table->integer('sequence')->default(10); + $table->string('amount_type')->default('fixed'); + $table->decimal('amount', 12, 4)->default(0); + $table->decimal('percentage', 8, 4)->default(0); + $table->string('base_rule_code')->nullable(); + $table->text('description')->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + } + public function down(): void { Schema::dropIfExists('salary_rules'); } +}; diff --git a/erp/database/migrations/2026_12_10_100003_create_payslip_lines_table.php b/erp/database/migrations/2026_12_10_100003_create_payslip_lines_table.php new file mode 100644 index 00000000000..396dd35327f --- /dev/null +++ b/erp/database/migrations/2026_12_10_100003_create_payslip_lines_table.php @@ -0,0 +1,20 @@ +id(); + $table->foreignId('payslip_id')->constrained('payslips')->cascadeOnDelete(); + $table->unsignedBigInteger('salary_rule_id')->nullable(); + $table->string('code'); + $table->string('name'); + $table->string('category'); + $table->integer('sequence')->default(10); + $table->decimal('amount', 12, 2)->default(0); + $table->timestamps(); + }); + } + public function down(): void { Schema::dropIfExists('payslip_lines'); } +}; diff --git a/erp/database/migrations/2026_12_10_100004_add_salary_structure_to_employees.php b/erp/database/migrations/2026_12_10_100004_add_salary_structure_to_employees.php new file mode 100644 index 00000000000..03777984ef2 --- /dev/null +++ b/erp/database/migrations/2026_12_10_100004_add_salary_structure_to_employees.php @@ -0,0 +1,18 @@ +unsignedBigInteger('salary_structure_id')->nullable()->after('salary_grade_id'); + $table->foreign('salary_structure_id')->references('id')->on('salary_structures')->nullOnDelete(); + }); + } + public function down(): void { + Schema::table('employees', function (Blueprint $table) { + $table->dropForeign(['salary_structure_id']); + $table->dropColumn('salary_structure_id'); + }); + } +}; diff --git a/erp/database/migrations/2026_12_15_100001_create_subcontracts_table.php b/erp/database/migrations/2026_12_15_100001_create_subcontracts_table.php new file mode 100644 index 00000000000..20d21bed2b5 --- /dev/null +++ b/erp/database/migrations/2026_12_15_100001_create_subcontracts_table.php @@ -0,0 +1,32 @@ +id(); + $table->unsignedBigInteger('tenant_id')->index(); + $table->unsignedBigInteger('vendor_id')->nullable()->index(); + $table->string('reference')->unique(); + $table->enum('status', ['draft', 'sent', 'in_progress', 'received', 'cancelled'])->default('draft'); + $table->string('finished_product'); + $table->decimal('finished_qty', 10, 2); + $table->decimal('unit_price', 10, 2); + $table->text('notes')->nullable(); + $table->timestamp('sent_at')->nullable(); + $table->timestamp('received_at')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('subcontracts'); + } +}; diff --git a/erp/database/migrations/2026_12_15_100002_create_subcontract_components_table.php b/erp/database/migrations/2026_12_15_100002_create_subcontract_components_table.php new file mode 100644 index 00000000000..d75eeb99e9b --- /dev/null +++ b/erp/database/migrations/2026_12_15_100002_create_subcontract_components_table.php @@ -0,0 +1,26 @@ +id(); + $table->foreignId('subcontract_id')->constrained('subcontracts')->cascadeOnDelete(); + $table->unsignedBigInteger('tenant_id')->index(); + $table->string('component_name'); + $table->decimal('quantity', 10, 2); + $table->string('unit')->default('pcs'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('subcontract_components'); + } +}; diff --git a/erp/database/migrations/2026_12_16_100001_create_rental_items_table.php b/erp/database/migrations/2026_12_16_100001_create_rental_items_table.php new file mode 100644 index 00000000000..852ebbd5f78 --- /dev/null +++ b/erp/database/migrations/2026_12_16_100001_create_rental_items_table.php @@ -0,0 +1,29 @@ +id(); + $table->unsignedBigInteger('tenant_id')->index(); + $table->string('name'); + $table->text('description')->nullable(); + $table->string('category')->nullable(); + $table->decimal('daily_rate', 10, 2); + $table->enum('status', ['available', 'rented', 'maintenance'])->default('available'); + $table->string('serial_number')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('rental_items'); + } +}; diff --git a/erp/database/migrations/2026_12_16_100002_create_rental_agreements_table.php b/erp/database/migrations/2026_12_16_100002_create_rental_agreements_table.php new file mode 100644 index 00000000000..303d82c9acb --- /dev/null +++ b/erp/database/migrations/2026_12_16_100002_create_rental_agreements_table.php @@ -0,0 +1,33 @@ +id(); + $table->unsignedBigInteger('tenant_id')->index(); + $table->foreignId('rental_item_id')->constrained('rental_items')->cascadeOnDelete(); + $table->string('customer_name'); + $table->string('customer_email')->nullable(); + $table->date('start_date'); + $table->date('end_date')->nullable(); + $table->decimal('daily_rate', 10, 2); + $table->decimal('deposit', 10, 2)->default(0); + $table->enum('status', ['active', 'returned', 'overdue', 'cancelled'])->default('active'); + $table->text('notes')->nullable(); + $table->timestamp('returned_at')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('rental_agreements'); + } +}; diff --git a/erp/database/migrations/2026_12_17_100001_create_subscription_plans_table.php b/erp/database/migrations/2026_12_17_100001_create_subscription_plans_table.php new file mode 100644 index 00000000000..c9290d14eae --- /dev/null +++ b/erp/database/migrations/2026_12_17_100001_create_subscription_plans_table.php @@ -0,0 +1,30 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->string('name'); + $table->text('description')->nullable(); + $table->enum('billing_cycle', ['monthly', 'quarterly', 'annual'])->default('monthly'); + $table->decimal('price', 10, 2); + $table->integer('trial_days')->default(0); + $table->boolean('is_active')->default(true); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('subscription_plans'); + } +}; diff --git a/erp/database/migrations/2026_12_17_100002_create_subscriptions_table.php b/erp/database/migrations/2026_12_17_100002_create_subscriptions_table.php new file mode 100644 index 00000000000..15ff4236ec8 --- /dev/null +++ b/erp/database/migrations/2026_12_17_100002_create_subscriptions_table.php @@ -0,0 +1,33 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->foreignId('plan_id')->constrained('subscription_plans')->cascadeOnDelete(); + $table->string('customer_name')->nullable(); + $table->string('customer_email')->nullable(); + $table->enum('status', ['trial', 'active', 'past_due', 'cancelled', 'expired'])->default('active'); + $table->timestamp('trial_ends_at')->nullable(); + $table->date('current_period_start')->nullable(); + $table->date('current_period_end')->nullable(); + $table->timestamp('cancelled_at')->nullable(); + $table->text('notes')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('subscriptions'); + } +}; diff --git a/erp/database/migrations/2026_12_17_100003_create_subscription_invoices_table.php b/erp/database/migrations/2026_12_17_100003_create_subscription_invoices_table.php new file mode 100644 index 00000000000..af6840355af --- /dev/null +++ b/erp/database/migrations/2026_12_17_100003_create_subscription_invoices_table.php @@ -0,0 +1,30 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->foreignId('subscription_id')->constrained('subscriptions')->cascadeOnDelete(); + $table->decimal('amount', 10, 2); + $table->enum('status', ['pending', 'paid', 'failed'])->default('pending'); + $table->date('due_date'); + $table->timestamp('paid_at')->nullable(); + $table->date('period_start'); + $table->date('period_end'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('subscription_invoices'); + } +}; diff --git a/erp/database/migrations/2026_12_18_100001_create_surveys_table.php b/erp/database/migrations/2026_12_18_100001_create_surveys_table.php new file mode 100644 index 00000000000..335976442ab --- /dev/null +++ b/erp/database/migrations/2026_12_18_100001_create_surveys_table.php @@ -0,0 +1,30 @@ +id(); + $table->unsignedBigInteger('tenant_id')->index(); + $table->string('title'); + $table->text('description')->nullable(); + $table->enum('status', ['draft', 'published', 'closed'])->default('draft'); + $table->timestamp('starts_at')->nullable(); + $table->timestamp('ends_at')->nullable(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('surveys'); + } +}; diff --git a/erp/database/migrations/2026_12_18_100002_create_survey_questions_table.php b/erp/database/migrations/2026_12_18_100002_create_survey_questions_table.php new file mode 100644 index 00000000000..b01a5760978 --- /dev/null +++ b/erp/database/migrations/2026_12_18_100002_create_survey_questions_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignId('survey_id')->constrained('surveys')->cascadeOnDelete(); + $table->unsignedBigInteger('tenant_id')->index(); + $table->text('question_text'); + $table->enum('question_type', ['text', 'single_choice', 'multiple_choice', 'rating', 'yes_no'])->default('text'); + $table->boolean('is_required')->default(true); + $table->integer('sequence')->default(0); + $table->json('options')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('survey_questions'); + } +}; diff --git a/erp/database/migrations/2026_12_18_100003_create_survey_responses_table.php b/erp/database/migrations/2026_12_18_100003_create_survey_responses_table.php new file mode 100644 index 00000000000..2faa3146d6c --- /dev/null +++ b/erp/database/migrations/2026_12_18_100003_create_survey_responses_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignId('survey_id')->constrained('surveys')->cascadeOnDelete(); + $table->unsignedBigInteger('tenant_id')->index(); + $table->string('respondent_name')->nullable(); + $table->string('respondent_email')->nullable(); + $table->timestamp('submitted_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('survey_responses'); + } +}; diff --git a/erp/database/migrations/2026_12_18_100004_create_survey_answers_table.php b/erp/database/migrations/2026_12_18_100004_create_survey_answers_table.php new file mode 100644 index 00000000000..2a311372295 --- /dev/null +++ b/erp/database/migrations/2026_12_18_100004_create_survey_answers_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignId('survey_response_id')->constrained('survey_responses')->cascadeOnDelete(); + $table->foreignId('survey_question_id')->constrained('survey_questions')->cascadeOnDelete(); + $table->unsignedBigInteger('tenant_id')->index(); + $table->text('answer_text')->nullable(); + $table->json('answer_options')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('survey_answers'); + } +}; diff --git a/erp/database/migrations/2026_12_19_100001_create_events_table.php b/erp/database/migrations/2026_12_19_100001_create_events_table.php new file mode 100644 index 00000000000..76f35d9601a --- /dev/null +++ b/erp/database/migrations/2026_12_19_100001_create_events_table.php @@ -0,0 +1,32 @@ +id(); + $table->unsignedBigInteger('tenant_id')->index(); + $table->string('title'); + $table->text('description')->nullable(); + $table->string('location')->nullable(); + $table->datetime('starts_at'); + $table->datetime('ends_at')->nullable(); + $table->integer('capacity')->nullable(); + $table->enum('status', ['draft', 'published', 'cancelled'])->default('draft'); + $table->foreignId('organizer_id')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('events'); + } +}; diff --git a/erp/database/migrations/2026_12_19_100002_create_event_registrations_table.php b/erp/database/migrations/2026_12_19_100002_create_event_registrations_table.php new file mode 100644 index 00000000000..4dab2d4064b --- /dev/null +++ b/erp/database/migrations/2026_12_19_100002_create_event_registrations_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignId('event_id')->constrained('events')->cascadeOnDelete(); + $table->unsignedBigInteger('tenant_id')->index(); + $table->string('attendee_name'); + $table->string('attendee_email'); + $table->enum('status', ['registered', 'confirmed', 'cancelled', 'attended'])->default('registered'); + $table->timestamp('registered_at'); + $table->text('notes')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('event_registrations'); + } +}; diff --git a/erp/database/migrations/2026_12_20_100001_create_document_folders_table.php b/erp/database/migrations/2026_12_20_100001_create_document_folders_table.php new file mode 100644 index 00000000000..e558370541e --- /dev/null +++ b/erp/database/migrations/2026_12_20_100001_create_document_folders_table.php @@ -0,0 +1,27 @@ +id(); + $table->unsignedBigInteger('tenant_id')->index(); + $table->string('name'); + $table->foreignId('parent_id')->nullable()->constrained('document_folders')->nullOnDelete(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('document_folders'); + } +}; diff --git a/erp/database/migrations/2026_12_20_100002_create_documents_table.php b/erp/database/migrations/2026_12_20_100002_create_documents_table.php new file mode 100644 index 00000000000..609e3570ea7 --- /dev/null +++ b/erp/database/migrations/2026_12_20_100002_create_documents_table.php @@ -0,0 +1,34 @@ +id(); + $table->unsignedBigInteger('tenant_id')->index(); + $table->foreignId('folder_id')->nullable()->constrained('document_folders')->nullOnDelete(); + $table->string('title'); + $table->text('description')->nullable(); + $table->string('file_path'); + $table->string('file_name'); + $table->integer('file_size')->nullable(); + $table->string('mime_type')->nullable(); + $table->integer('version')->default(1); + $table->json('tags')->nullable(); + $table->foreignId('uploaded_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('documents'); + } +}; diff --git a/erp/database/migrations/2026_12_20_100003_create_document_versions_table.php b/erp/database/migrations/2026_12_20_100003_create_document_versions_table.php new file mode 100644 index 00000000000..52116e18bc3 --- /dev/null +++ b/erp/database/migrations/2026_12_20_100003_create_document_versions_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('document_id')->constrained('documents')->cascadeOnDelete(); + $table->unsignedBigInteger('tenant_id')->index(); + $table->integer('version'); + $table->string('file_path'); + $table->string('file_name'); + $table->integer('file_size')->nullable(); + $table->foreignId('uploaded_by')->nullable()->constrained('users')->nullOnDelete(); + $table->text('notes')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('document_versions'); + } +}; diff --git a/erp/database/migrations/2026_12_21_000001_create_shifts_table.php b/erp/database/migrations/2026_12_21_000001_create_shifts_table.php new file mode 100644 index 00000000000..1bf431eb8a1 --- /dev/null +++ b/erp/database/migrations/2026_12_21_000001_create_shifts_table.php @@ -0,0 +1,32 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->unsignedBigInteger('employee_id'); + $table->foreign('employee_id')->references('id')->on('users')->onDelete('cascade'); + $table->string('title'); + $table->dateTime('starts_at'); + $table->dateTime('ends_at'); + $table->integer('break_minutes')->default(0); + $table->enum('status', ['scheduled', 'confirmed', 'completed', 'cancelled'])->default('scheduled'); + $table->text('notes')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('shifts'); + } +}; diff --git a/erp/database/migrations/2026_12_21_000002_create_shift_swaps_table.php b/erp/database/migrations/2026_12_21_000002_create_shift_swaps_table.php new file mode 100644 index 00000000000..04b33092e8a --- /dev/null +++ b/erp/database/migrations/2026_12_21_000002_create_shift_swaps_table.php @@ -0,0 +1,32 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->unsignedBigInteger('shift_id'); + $table->foreign('shift_id')->references('id')->on('shifts')->onDelete('cascade'); + $table->unsignedBigInteger('requested_by'); + $table->foreign('requested_by')->references('id')->on('users')->onDelete('cascade'); + $table->unsignedBigInteger('requested_to'); + $table->foreign('requested_to')->references('id')->on('users')->onDelete('cascade'); + $table->text('reason')->nullable(); + $table->enum('status', ['pending', 'approved', 'rejected'])->default('pending'); + $table->timestamp('responded_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('shift_swaps'); + } +}; diff --git a/erp/database/migrations/2026_12_22_100001_create_kb_categories_table.php b/erp/database/migrations/2026_12_22_100001_create_kb_categories_table.php new file mode 100644 index 00000000000..878b15b8772 --- /dev/null +++ b/erp/database/migrations/2026_12_22_100001_create_kb_categories_table.php @@ -0,0 +1,29 @@ +id(); + $table->unsignedBigInteger('tenant_id')->index(); + $table->string('name'); + $table->string('slug'); + $table->text('description')->nullable(); + $table->foreignId('parent_id')->nullable()->constrained('kb_categories')->nullOnDelete(); + $table->integer('sequence')->default(0); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('kb_categories'); + } +}; diff --git a/erp/database/migrations/2026_12_22_100002_create_kb_articles_table.php b/erp/database/migrations/2026_12_22_100002_create_kb_articles_table.php new file mode 100644 index 00000000000..ed58e3b35de --- /dev/null +++ b/erp/database/migrations/2026_12_22_100002_create_kb_articles_table.php @@ -0,0 +1,34 @@ +id(); + $table->unsignedBigInteger('tenant_id')->index(); + $table->foreignId('category_id')->nullable()->constrained('kb_categories')->nullOnDelete(); + $table->string('title'); + $table->string('slug'); + $table->longText('content'); + $table->text('excerpt')->nullable(); + $table->enum('status', ['draft', 'published', 'archived'])->default('draft'); + $table->foreignId('author_id')->nullable()->constrained('users')->nullOnDelete(); + $table->integer('views')->default(0); + $table->json('tags')->nullable(); + $table->timestamp('published_at')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('kb_articles'); + } +}; diff --git a/erp/database/migrations/2026_12_23_100001_create_sign_requests_table.php b/erp/database/migrations/2026_12_23_100001_create_sign_requests_table.php new file mode 100644 index 00000000000..761d6a66d48 --- /dev/null +++ b/erp/database/migrations/2026_12_23_100001_create_sign_requests_table.php @@ -0,0 +1,31 @@ +id(); + $table->unsignedBigInteger('tenant_id')->index(); + $table->string('title'); + $table->string('document_path'); + $table->string('document_name'); + $table->enum('status', ['draft', 'sent', 'completed', 'cancelled'])->default('draft'); + $table->text('message')->nullable(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('completed_at')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('sign_requests'); + } +}; diff --git a/erp/database/migrations/2026_12_23_100002_create_sign_request_signers_table.php b/erp/database/migrations/2026_12_23_100002_create_sign_request_signers_table.php new file mode 100644 index 00000000000..10ac654e4e6 --- /dev/null +++ b/erp/database/migrations/2026_12_23_100002_create_sign_request_signers_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('sign_request_id')->constrained('sign_requests')->cascadeOnDelete(); + $table->unsignedBigInteger('tenant_id')->index(); + $table->string('signer_name'); + $table->string('signer_email'); + $table->enum('status', ['pending', 'signed', 'declined'])->default('pending'); + $table->timestamp('signed_at')->nullable(); + $table->timestamp('declined_at')->nullable(); + $table->string('token')->unique(); + $table->integer('sequence')->default(0); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('sign_request_signers'); + } +}; diff --git a/erp/database/migrations/2027_01_01_100001_create_production_schedules_table.php b/erp/database/migrations/2027_01_01_100001_create_production_schedules_table.php new file mode 100644 index 00000000000..143eec2c159 --- /dev/null +++ b/erp/database/migrations/2027_01_01_100001_create_production_schedules_table.php @@ -0,0 +1,30 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->foreignId('work_order_id')->constrained('work_orders')->cascadeOnDelete(); + $table->foreignId('work_center_id')->constrained('work_centers')->cascadeOnDelete(); + $table->dateTime('scheduled_start'); + $table->dateTime('scheduled_end'); + $table->enum('status', ['planned', 'confirmed', 'in_progress', 'done'])->default('planned'); + $table->text('notes')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('production_schedules'); + } +}; diff --git a/erp/database/migrations/2027_01_01_100002_create_work_center_capacities_table.php b/erp/database/migrations/2027_01_01_100002_create_work_center_capacities_table.php new file mode 100644 index 00000000000..d73a0122183 --- /dev/null +++ b/erp/database/migrations/2027_01_01_100002_create_work_center_capacities_table.php @@ -0,0 +1,29 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->foreignId('work_center_id')->constrained('work_centers')->cascadeOnDelete(); + $table->tinyInteger('day_of_week')->unsigned()->comment('0=Sunday, 6=Saturday'); + $table->time('start_time'); + $table->time('end_time'); + $table->decimal('capacity_hours', 5, 2); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('work_center_capacities'); + } +}; diff --git a/erp/database/migrations/2027_01_01_100003_create_scrap_orders_table.php b/erp/database/migrations/2027_01_01_100003_create_scrap_orders_table.php new file mode 100644 index 00000000000..c177743192b --- /dev/null +++ b/erp/database/migrations/2027_01_01_100003_create_scrap_orders_table.php @@ -0,0 +1,32 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->foreignId('manufacturing_order_id')->nullable()->constrained('manufacturing_orders')->nullOnDelete(); + $table->foreignId('product_id')->constrained('products')->cascadeOnDelete(); + $table->decimal('quantity', 10, 2); + $table->string('uom')->default('pcs'); + $table->text('reason')->nullable(); + $table->foreignId('scrapped_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('scrapped_at')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('scrap_orders'); + } +}; diff --git a/erp/database/migrations/2027_01_02_100001_create_store_coupons_table.php b/erp/database/migrations/2027_01_02_100001_create_store_coupons_table.php new file mode 100644 index 00000000000..da730ffcf4b --- /dev/null +++ b/erp/database/migrations/2027_01_02_100001_create_store_coupons_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete(); + $table->string('code'); + $table->enum('type', ['fixed', 'percentage'])->default('percentage'); + $table->decimal('value', 10, 2); + $table->decimal('min_order_amount', 10, 2)->default(0); + $table->integer('max_uses')->nullable(); + $table->integer('uses_count')->default(0); + $table->date('valid_from')->nullable(); + $table->date('valid_until')->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + $table->softDeletes(); + $table->unique(['tenant_id', 'code']); + }); + } + + public function down(): void + { + Schema::dropIfExists('store_coupons'); + } +}; diff --git a/erp/database/migrations/2027_01_02_100002_create_store_reviews_table.php b/erp/database/migrations/2027_01_02_100002_create_store_reviews_table.php new file mode 100644 index 00000000000..d50308b5a81 --- /dev/null +++ b/erp/database/migrations/2027_01_02_100002_create_store_reviews_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete(); + $table->foreignId('store_product_id')->constrained('store_products')->cascadeOnDelete(); + $table->foreignId('order_id')->nullable()->constrained('store_orders')->nullOnDelete(); + $table->string('reviewer_name'); + $table->string('reviewer_email')->nullable(); + $table->tinyInteger('rating'); + $table->string('title')->nullable(); + $table->text('body')->nullable(); + $table->boolean('is_approved')->default(false); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('store_reviews'); + } +}; diff --git a/erp/database/migrations/2027_01_02_100003_create_store_carts_table.php b/erp/database/migrations/2027_01_02_100003_create_store_carts_table.php new file mode 100644 index 00000000000..7e922309aec --- /dev/null +++ b/erp/database/migrations/2027_01_02_100003_create_store_carts_table.php @@ -0,0 +1,25 @@ +id(); + $table->foreignId('tenant_id')->nullable()->constrained('tenants')->nullOnDelete(); + $table->string('session_key')->index(); + $table->foreignId('store_product_id')->constrained('store_products')->cascadeOnDelete(); + $table->integer('quantity')->default(1); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('store_carts'); + } +}; diff --git a/erp/database/migrations/2027_01_03_100001_create_bank_accounts_table.php b/erp/database/migrations/2027_01_03_100001_create_bank_accounts_table.php new file mode 100644 index 00000000000..c2417e02368 --- /dev/null +++ b/erp/database/migrations/2027_01_03_100001_create_bank_accounts_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete(); + $table->foreignId('account_id')->nullable()->constrained('chart_of_accounts')->nullOnDelete(); + $table->string('name'); + $table->string('bank_name'); + $table->string('account_number')->nullable(); + $table->string('currency', 3)->default('USD'); + $table->decimal('current_balance', 18, 2)->default(0); + $table->date('last_reconciled_at')->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('bank_accounts'); + } +}; diff --git a/erp/database/migrations/2027_01_03_100002_create_bank_transactions_table.php b/erp/database/migrations/2027_01_03_100002_create_bank_transactions_table.php new file mode 100644 index 00000000000..24e21d00e78 --- /dev/null +++ b/erp/database/migrations/2027_01_03_100002_create_bank_transactions_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete(); + $table->foreignId('bank_account_id')->constrained('bank_accounts')->cascadeOnDelete(); + $table->foreignId('journal_entry_id')->nullable()->constrained('journal_entries')->nullOnDelete(); + $table->date('transaction_date'); + $table->string('reference')->nullable(); + $table->text('description')->nullable(); + $table->enum('type', ['debit', 'credit']); + $table->decimal('amount', 18, 2); + $table->enum('status', ['unreconciled', 'reconciled'])->default('unreconciled'); + $table->boolean('is_reconciled')->default(false); + $table->timestamp('reconciled_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('bank_transactions'); + } +}; diff --git a/erp/database/migrations/2027_01_03_100003_create_auto_posting_rules_table.php b/erp/database/migrations/2027_01_03_100003_create_auto_posting_rules_table.php new file mode 100644 index 00000000000..4f8a809b3a6 --- /dev/null +++ b/erp/database/migrations/2027_01_03_100003_create_auto_posting_rules_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete(); + $table->foreignId('bank_account_id')->constrained('bank_accounts')->cascadeOnDelete(); + $table->foreignId('debit_account_id')->constrained('chart_of_accounts')->cascadeOnDelete(); + $table->foreignId('credit_account_id')->constrained('chart_of_accounts')->cascadeOnDelete(); + $table->string('name'); + $table->string('match_keyword')->nullable(); + $table->enum('match_type', ['description', 'reference', 'amount'])->default('description'); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('auto_posting_rules'); + } +}; diff --git a/erp/database/migrations/2027_01_05_100002_create_ticket_escalations_table.php b/erp/database/migrations/2027_01_05_100002_create_ticket_escalations_table.php new file mode 100644 index 00000000000..712e7a1003a --- /dev/null +++ b/erp/database/migrations/2027_01_05_100002_create_ticket_escalations_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete(); + $table->foreignId('ticket_id')->constrained('helpdesk_tickets')->cascadeOnDelete(); + $table->enum('escalation_type', ['response_breach', 'resolution_breach']); + $table->timestamp('escalated_at'); + $table->timestamp('resolved_at')->nullable(); + $table->foreignId('escalated_to_id')->nullable()->constrained('users')->nullOnDelete(); + $table->text('notes')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('helpdesk_ticket_escalations'); + } +}; diff --git a/erp/database/migrations/2027_01_06_100001_create_email_sequences_table.php b/erp/database/migrations/2027_01_06_100001_create_email_sequences_table.php new file mode 100644 index 00000000000..fbf531cce78 --- /dev/null +++ b/erp/database/migrations/2027_01_06_100001_create_email_sequences_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete(); + $table->string('name'); + $table->text('description')->nullable(); + $table->enum('status', ['active', 'paused', 'archived'])->default('active'); + $table->integer('total_steps')->default(0); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('email_sequences'); + } +}; diff --git a/erp/database/migrations/2027_01_06_100002_create_email_sequence_steps_table.php b/erp/database/migrations/2027_01_06_100002_create_email_sequence_steps_table.php new file mode 100644 index 00000000000..c144d07d99c --- /dev/null +++ b/erp/database/migrations/2027_01_06_100002_create_email_sequence_steps_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete(); + $table->foreignId('sequence_id')->constrained('email_sequences')->cascadeOnDelete(); + $table->integer('step_number'); + $table->string('subject'); + $table->text('body'); + $table->integer('delay_days')->default(0); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('email_sequence_steps'); + } +}; diff --git a/erp/database/migrations/2027_01_06_100003_create_email_sequence_enrollments_table.php b/erp/database/migrations/2027_01_06_100003_create_email_sequence_enrollments_table.php new file mode 100644 index 00000000000..f0e7059c631 --- /dev/null +++ b/erp/database/migrations/2027_01_06_100003_create_email_sequence_enrollments_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete(); + $table->foreignId('sequence_id')->constrained('email_sequences')->cascadeOnDelete(); + $table->foreignId('lead_id')->constrained('crm_leads')->cascadeOnDelete(); + $table->integer('current_step')->default(0); + $table->enum('status', ['active', 'completed', 'unsubscribed', 'paused'])->default('active'); + $table->timestamp('next_send_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('email_sequence_enrollments'); + } +}; diff --git a/erp/database/migrations/2027_01_06_100004_create_lead_scoring_rules_table.php b/erp/database/migrations/2027_01_06_100004_create_lead_scoring_rules_table.php new file mode 100644 index 00000000000..484576ce64b --- /dev/null +++ b/erp/database/migrations/2027_01_06_100004_create_lead_scoring_rules_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete(); + $table->string('name'); + $table->enum('field', ['source', 'stage', 'tag', 'email_open', 'email_click', 'website_visit'])->default('source'); + $table->string('condition_value')->nullable(); + $table->integer('points'); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('lead_scoring_rules'); + } +}; diff --git a/erp/database/migrations/2027_01_07_100001_create_failed_jobs_table.php b/erp/database/migrations/2027_01_07_100001_create_failed_jobs_table.php new file mode 100644 index 00000000000..cb0c45d1e26 --- /dev/null +++ b/erp/database/migrations/2027_01_07_100001_create_failed_jobs_table.php @@ -0,0 +1,28 @@ +id(); + $table->string('uuid')->unique(); + $table->text('connection'); + $table->text('queue'); + $table->longText('payload'); + $table->longText('exception'); + $table->timestamp('failed_at')->useCurrent(); + }); + } + + public function down(): void + { + Schema::dropIfExists('failed_jobs'); + } +}; diff --git a/erp/database/migrations/2027_01_07_100004_create_sso_providers_table.php b/erp/database/migrations/2027_01_07_100004_create_sso_providers_table.php new file mode 100644 index 00000000000..c454cf17eb7 --- /dev/null +++ b/erp/database/migrations/2027_01_07_100004_create_sso_providers_table.php @@ -0,0 +1,48 @@ +id(); + $table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete(); + $table->string('name'); + $table->enum('provider_type', ['saml', 'oauth2', 'oidc'])->default('saml'); + $table->boolean('is_active')->default(true); + + // SAML fields + $table->string('entity_id', 500)->nullable(); + $table->string('sso_url', 500)->nullable()->comment('IdP SSO URL (where to send AuthnRequest)'); + $table->string('slo_url', 500)->nullable()->comment('IdP Single Logout URL'); + $table->text('idp_certificate')->nullable()->comment('IdP X.509 certificate for verifying assertions'); + + // OAuth2/OIDC fields + $table->string('client_id', 500)->nullable(); + $table->string('client_secret', 500)->nullable(); + $table->string('authorization_url', 500)->nullable(); + $table->string('token_url', 500)->nullable(); + $table->string('userinfo_url', 500)->nullable(); + + // Attribute mapping + $table->string('email_attribute', 100)->nullable()->default('email'); + $table->string('name_attribute', 100)->nullable()->default('displayName'); + + // Metadata + $table->string('metadata_url', 500)->nullable()->comment('URL to fetch IdP metadata XML'); + + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('sso_providers'); + } +}; diff --git a/erp/database/migrations/2027_01_08_100001_create_qc_checklists_table.php b/erp/database/migrations/2027_01_08_100001_create_qc_checklists_table.php new file mode 100644 index 00000000000..cbc1f071847 --- /dev/null +++ b/erp/database/migrations/2027_01_08_100001_create_qc_checklists_table.php @@ -0,0 +1,32 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->string('name', 255); + $table->text('description')->nullable(); + $table->enum('category', ['incoming', 'process', 'final', 'audit'])->default('process'); + $table->boolean('is_active')->default(true); + $table->unsignedBigInteger('created_by')->nullable(); + $table->timestamps(); + + $table->foreign('tenant_id')->references('id')->on('tenants')->cascadeOnDelete(); + $table->foreign('created_by')->references('id')->on('users')->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::dropIfExists('quality_checklists'); + } +}; diff --git a/erp/database/migrations/2027_01_08_100002_create_qc_checklist_items_table.php b/erp/database/migrations/2027_01_08_100002_create_qc_checklist_items_table.php new file mode 100644 index 00000000000..fa0d68ec41a --- /dev/null +++ b/erp/database/migrations/2027_01_08_100002_create_qc_checklist_items_table.php @@ -0,0 +1,34 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->unsignedBigInteger('checklist_id'); + $table->text('description'); + $table->enum('check_type', ['pass_fail', 'measurement', 'visual', 'count'])->default('pass_fail'); + $table->string('expected_value', 255)->nullable(); + $table->string('unit', 50)->nullable(); + $table->boolean('is_required')->default(true); + $table->integer('sequence')->default(0); + $table->timestamps(); + + $table->foreign('tenant_id')->references('id')->on('tenants')->cascadeOnDelete(); + $table->foreign('checklist_id')->references('id')->on('quality_checklists')->cascadeOnDelete(); + }); + } + + public function down(): void + { + Schema::dropIfExists('quality_checklist_items'); + } +}; diff --git a/erp/database/migrations/2027_01_08_100003_create_qc_inspections_table.php b/erp/database/migrations/2027_01_08_100003_create_qc_inspections_table.php new file mode 100644 index 00000000000..337cc2adad1 --- /dev/null +++ b/erp/database/migrations/2027_01_08_100003_create_qc_inspections_table.php @@ -0,0 +1,36 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->unsignedBigInteger('checklist_id'); + $table->string('reference_type', 100)->nullable()->comment('polymorphic: manufacturing_orders, purchase_orders, etc.'); + $table->unsignedBigInteger('reference_id')->nullable(); + $table->unsignedBigInteger('inspector_id')->nullable(); + $table->enum('status', ['pending', 'in_progress', 'passed', 'failed', 'cancelled'])->default('pending'); + $table->text('notes')->nullable(); + $table->timestamp('started_at')->nullable(); + $table->timestamp('completed_at')->nullable(); + $table->timestamps(); + + $table->foreign('tenant_id')->references('id')->on('tenants')->cascadeOnDelete(); + $table->foreign('checklist_id')->references('id')->on('quality_checklists')->cascadeOnDelete(); + $table->foreign('inspector_id')->references('id')->on('users')->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::dropIfExists('quality_inspections'); + } +}; diff --git a/erp/database/migrations/2027_01_08_100004_create_qc_inspection_results_table.php b/erp/database/migrations/2027_01_08_100004_create_qc_inspection_results_table.php new file mode 100644 index 00000000000..b2da6eb4553 --- /dev/null +++ b/erp/database/migrations/2027_01_08_100004_create_qc_inspection_results_table.php @@ -0,0 +1,33 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->unsignedBigInteger('inspection_id'); + $table->unsignedBigInteger('checklist_item_id'); + $table->enum('result', ['pass', 'fail', 'na'])->nullable(); + $table->string('measured_value', 255)->nullable(); + $table->text('notes')->nullable(); + $table->timestamps(); + + $table->foreign('tenant_id')->references('id')->on('tenants')->cascadeOnDelete(); + $table->foreign('inspection_id')->references('id')->on('quality_inspections')->cascadeOnDelete(); + $table->foreign('checklist_item_id')->references('id')->on('quality_checklist_items')->cascadeOnDelete(); + }); + } + + public function down(): void + { + Schema::dropIfExists('quality_inspection_results'); + } +}; diff --git a/erp/database/migrations/2027_01_08_100005_create_non_conformance_reports_table.php b/erp/database/migrations/2027_01_08_100005_create_non_conformance_reports_table.php new file mode 100644 index 00000000000..bf73546ba18 --- /dev/null +++ b/erp/database/migrations/2027_01_08_100005_create_non_conformance_reports_table.php @@ -0,0 +1,41 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->unsignedBigInteger('inspection_id')->nullable(); + $table->string('ncr_number', 50)->unique(); + $table->string('title', 255); + $table->text('description'); + $table->enum('severity', ['minor', 'major', 'critical'])->default('major'); + $table->enum('status', ['open', 'under_review', 'resolved', 'closed'])->default('open'); + $table->unsignedBigInteger('reported_by')->nullable(); + $table->unsignedBigInteger('assigned_to')->nullable(); + $table->text('root_cause')->nullable(); + $table->text('corrective_action')->nullable(); + $table->date('due_date')->nullable(); + $table->timestamp('resolved_at')->nullable(); + $table->timestamps(); + + $table->foreign('tenant_id')->references('id')->on('tenants')->cascadeOnDelete(); + $table->foreign('inspection_id')->references('id')->on('qc_inspections')->nullOnDelete(); + $table->foreign('reported_by')->references('id')->on('users')->nullOnDelete(); + $table->foreign('assigned_to')->references('id')->on('users')->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::dropIfExists('non_conformance_reports'); + } +}; diff --git a/erp/database/migrations/2027_01_08_100006_create_equipment_table.php b/erp/database/migrations/2027_01_08_100006_create_equipment_table.php new file mode 100644 index 00000000000..1f7b7eadfc1 --- /dev/null +++ b/erp/database/migrations/2027_01_08_100006_create_equipment_table.php @@ -0,0 +1,39 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->string('name', 255); + $table->string('code', 100)->unique()->nullable(); + $table->enum('category', ['machinery', 'electrical', 'hvac', 'vehicle', 'it', 'other'])->default('machinery'); + $table->string('location', 255)->nullable(); + $table->string('serial_number', 255)->nullable(); + $table->string('manufacturer', 255)->nullable(); + $table->string('model', 255)->nullable(); + $table->date('purchase_date')->nullable(); + $table->date('warranty_expiry')->nullable(); + $table->enum('status', ['operational', 'under_maintenance', 'out_of_service', 'retired'])->default('operational'); + $table->text('notes')->nullable(); + $table->unsignedBigInteger('assigned_to')->nullable(); + $table->timestamps(); + + $table->foreign('tenant_id')->references('id')->on('tenants')->cascadeOnDelete(); + $table->foreign('assigned_to')->references('id')->on('users')->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::dropIfExists('equipment'); + } +}; diff --git a/erp/database/migrations/2027_01_08_100007_create_maintenance_plans_table.php b/erp/database/migrations/2027_01_08_100007_create_maintenance_plans_table.php new file mode 100644 index 00000000000..9538711ef1c --- /dev/null +++ b/erp/database/migrations/2027_01_08_100007_create_maintenance_plans_table.php @@ -0,0 +1,35 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->unsignedBigInteger('equipment_id'); + $table->string('name', 255); + $table->enum('frequency', ['daily', 'weekly', 'monthly', 'quarterly', 'annual', 'as_needed'])->default('monthly'); + $table->decimal('estimated_duration_hours', 5, 2)->nullable(); + $table->text('description')->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamp('last_performed_at')->nullable(); + $table->timestamp('next_due_at')->nullable(); + $table->timestamps(); + + $table->foreign('tenant_id')->references('id')->on('tenants')->cascadeOnDelete(); + $table->foreign('equipment_id')->references('id')->on('equipment')->cascadeOnDelete(); + }); + } + + public function down(): void + { + Schema::dropIfExists('maintenance_plans'); + } +}; diff --git a/erp/database/migrations/2027_01_08_100008_create_maintenance_orders_table.php b/erp/database/migrations/2027_01_08_100008_create_maintenance_orders_table.php new file mode 100644 index 00000000000..caca5f3ed11 --- /dev/null +++ b/erp/database/migrations/2027_01_08_100008_create_maintenance_orders_table.php @@ -0,0 +1,48 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->unsignedBigInteger('equipment_id'); + $table->unsignedBigInteger('plan_id')->nullable(); + $table->string('order_number', 50)->unique()->notNull(); + $table->enum('type', ['preventive', 'corrective', 'emergency'])->default('preventive'); + $table->enum('priority', ['low', 'medium', 'high', 'critical'])->default('medium'); + $table->enum('status', ['open', 'in_progress', 'completed', 'cancelled'])->default('open'); + $table->string('title', 255); + $table->text('description')->nullable(); + $table->date('scheduled_date')->nullable(); + $table->timestamp('started_at')->nullable(); + $table->timestamp('completed_at')->nullable(); + $table->decimal('estimated_hours', 5, 2)->nullable(); + $table->decimal('actual_hours', 5, 2)->nullable(); + $table->unsignedBigInteger('assigned_to')->nullable(); + $table->unsignedBigInteger('reported_by')->nullable(); + $table->decimal('cost', 10, 2)->nullable(); + $table->text('notes')->nullable(); + $table->text('resolution')->nullable(); + $table->timestamps(); + + $table->foreign('tenant_id')->references('id')->on('tenants')->cascadeOnDelete(); + $table->foreign('equipment_id')->references('id')->on('equipment')->cascadeOnDelete(); + $table->foreign('plan_id')->references('id')->on('maintenance_plans')->nullOnDelete(); + $table->foreign('assigned_to')->references('id')->on('users')->nullOnDelete(); + $table->foreign('reported_by')->references('id')->on('users')->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::dropIfExists('maintenance_orders'); + } +}; diff --git a/erp/database/migrations/2027_01_08_100011_create_task_dependencies_table.php b/erp/database/migrations/2027_01_08_100011_create_task_dependencies_table.php new file mode 100644 index 00000000000..0d98c3a9d01 --- /dev/null +++ b/erp/database/migrations/2027_01_08_100011_create_task_dependencies_table.php @@ -0,0 +1,32 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->foreign('tenant_id')->references('id')->on('tenants'); + $table->unsignedBigInteger('task_id'); + $table->foreign('task_id')->references('id')->on('tasks')->onDelete('cascade'); + $table->unsignedBigInteger('depends_on_id'); + $table->foreign('depends_on_id')->references('id')->on('tasks')->onDelete('cascade'); + $table->enum('dependency_type', ['finish_to_start', 'start_to_start', 'finish_to_finish'])->default('finish_to_start'); + $table->timestamps(); + + $table->unique(['task_id', 'depends_on_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('task_dependencies'); + } +}; diff --git a/erp/database/migrations/2027_01_08_100012_create_project_sprints_table.php b/erp/database/migrations/2027_01_08_100012_create_project_sprints_table.php new file mode 100644 index 00000000000..4bafb8070a8 --- /dev/null +++ b/erp/database/migrations/2027_01_08_100012_create_project_sprints_table.php @@ -0,0 +1,33 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->foreign('tenant_id')->references('id')->on('tenants'); + $table->unsignedBigInteger('project_id'); + $table->foreign('project_id')->references('id')->on('projects')->onDelete('cascade'); + $table->string('name', 255); + $table->text('goal')->nullable(); + $table->enum('status', ['planning', 'active', 'completed', 'cancelled'])->default('planning'); + $table->date('start_date')->nullable(); + $table->date('end_date')->nullable(); + $table->integer('velocity')->nullable()->comment('story points completed'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('project_sprints'); + } +}; diff --git a/erp/database/migrations/2027_01_08_100013_add_sprint_and_estimates_to_tasks_table.php b/erp/database/migrations/2027_01_08_100013_add_sprint_and_estimates_to_tasks_table.php new file mode 100644 index 00000000000..a7d22028554 --- /dev/null +++ b/erp/database/migrations/2027_01_08_100013_add_sprint_and_estimates_to_tasks_table.php @@ -0,0 +1,48 @@ +unsignedBigInteger('sprint_id')->nullable()->after('project_id'); + $table->foreign('sprint_id')->references('id')->on('project_sprints')->onDelete('set null'); + } + if (!Schema::hasColumn('tasks', 'story_points')) { + $table->integer('story_points')->nullable()->after('sprint_id'); + } + if (!Schema::hasColumn('tasks', 'start_date')) { + $table->date('start_date')->nullable()->after('story_points'); + } + if (!Schema::hasColumn('tasks', 'parent_task_id')) { + $table->unsignedBigInteger('parent_task_id')->nullable()->after('start_date'); + $table->foreign('parent_task_id')->references('id')->on('tasks')->onDelete('set null'); + } + }); + } + + public function down(): void + { + Schema::table('tasks', function (Blueprint $table) { + if (Schema::hasColumn('tasks', 'sprint_id')) { + $table->dropForeign(['sprint_id']); + $table->dropColumn('sprint_id'); + } + if (Schema::hasColumn('tasks', 'story_points')) { + $table->dropColumn('story_points'); + } + if (Schema::hasColumn('tasks', 'start_date')) { + $table->dropColumn('start_date'); + } + if (Schema::hasColumn('tasks', 'parent_task_id')) { + $table->dropForeign(['parent_task_id']); + $table->dropColumn('parent_task_id'); + } + }); + } +}; diff --git a/erp/database/migrations/2027_01_08_100014_create_campaign_events_table.php b/erp/database/migrations/2027_01_08_100014_create_campaign_events_table.php new file mode 100644 index 00000000000..fa217e5023a --- /dev/null +++ b/erp/database/migrations/2027_01_08_100014_create_campaign_events_table.php @@ -0,0 +1,36 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->foreign('tenant_id')->references('id')->on('tenants'); + $table->unsignedBigInteger('campaign_id'); + $table->foreign('campaign_id')->references('id')->on('email_campaigns')->onDelete('cascade'); + $table->unsignedBigInteger('send_id')->nullable(); + $table->foreign('send_id')->references('id')->on('campaign_sends')->onDelete('set null'); + $table->string('subscriber_email', 255); + $table->enum('event_type', ['sent', 'opened', 'clicked', 'bounced', 'unsubscribed', 'complained'])->index(); + $table->json('metadata')->nullable()->comment('e.g., {url: "https://...", user_agent: "..."}'); + $table->timestamp('occurred_at')->useCurrent(); + $table->timestamps(); + + $table->index(['campaign_id', 'event_type']); + $table->index('subscriber_email'); + }); + } + + public function down(): void + { + Schema::dropIfExists('campaign_events'); + } +}; diff --git a/erp/database/migrations/2027_01_08_100015_create_ab_test_variants_table.php b/erp/database/migrations/2027_01_08_100015_create_ab_test_variants_table.php new file mode 100644 index 00000000000..1f621263993 --- /dev/null +++ b/erp/database/migrations/2027_01_08_100015_create_ab_test_variants_table.php @@ -0,0 +1,35 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->foreign('tenant_id')->references('id')->on('tenants'); + $table->unsignedBigInteger('campaign_id'); + $table->foreign('campaign_id')->references('id')->on('email_campaigns')->onDelete('cascade'); + $table->string('name', 100)->comment('e.g., A, B, Control'); + $table->string('subject_line', 255)->nullable(); + $table->string('preview_text', 255)->nullable(); + $table->unsignedTinyInteger('send_percentage')->default(50)->comment('0-100'); + $table->boolean('is_winner')->default(false); + $table->unsignedInteger('opens')->default(0); + $table->unsignedInteger('clicks')->default(0); + $table->unsignedInteger('sent')->default(0); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('ab_test_variants'); + } +}; diff --git a/erp/database/migrations/2027_01_09_100001_create_repair_orders_table.php b/erp/database/migrations/2027_01_09_100001_create_repair_orders_table.php new file mode 100644 index 00000000000..146cab63344 --- /dev/null +++ b/erp/database/migrations/2027_01_09_100001_create_repair_orders_table.php @@ -0,0 +1,48 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->string('order_number', 50); + $table->unsignedBigInteger('contact_id')->nullable(); + $table->unsignedBigInteger('product_id')->nullable(); + $table->string('product_name', 255); + $table->string('serial_number', 255)->nullable(); + $table->enum('status', ['draft', 'confirmed', 'in_progress', 'done', 'cancelled'])->default('draft'); + $table->enum('priority', ['low', 'medium', 'high', 'urgent'])->default('low'); + $table->text('diagnosis')->nullable(); + $table->text('internal_notes')->nullable(); + $table->boolean('warranty_claim')->default(false); + $table->date('scheduled_date')->nullable(); + $table->timestamp('started_at')->nullable(); + $table->timestamp('completed_at')->nullable(); + $table->decimal('estimated_hours', 6, 2)->nullable(); + $table->decimal('actual_hours', 6, 2)->nullable(); + $table->decimal('estimated_cost', 12, 2)->nullable(); + $table->decimal('final_cost', 12, 2)->nullable(); + $table->unsignedBigInteger('assigned_to')->nullable(); + $table->timestamps(); + + $table->unique(['tenant_id', 'order_number']); + $table->foreign('tenant_id')->references('id')->on('tenants')->cascadeOnDelete(); + $table->foreign('contact_id')->references('id')->on('contacts')->nullOnDelete(); + $table->foreign('product_id')->references('id')->on('products')->nullOnDelete(); + $table->foreign('assigned_to')->references('id')->on('users')->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::dropIfExists('repair_orders'); + } +}; diff --git a/erp/database/migrations/2027_01_09_100002_create_repair_lines_table.php b/erp/database/migrations/2027_01_09_100002_create_repair_lines_table.php new file mode 100644 index 00000000000..4bf11692b36 --- /dev/null +++ b/erp/database/migrations/2027_01_09_100002_create_repair_lines_table.php @@ -0,0 +1,36 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->unsignedBigInteger('repair_order_id'); + $table->enum('line_type', ['part', 'labor', 'service'])->default('part'); + $table->unsignedBigInteger('product_id')->nullable(); + $table->string('description', 255); + $table->decimal('quantity', 10, 2)->default(1); + $table->decimal('unit_price', 12, 2)->default(0); + $table->decimal('total', 12, 2)->default(0); + $table->boolean('is_invoiced')->default(false); + $table->timestamps(); + + $table->foreign('tenant_id')->references('id')->on('tenants')->cascadeOnDelete(); + $table->foreign('repair_order_id')->references('id')->on('repair_orders')->cascadeOnDelete(); + $table->foreign('product_id')->references('id')->on('products')->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::dropIfExists('repair_lines'); + } +}; diff --git a/erp/database/migrations/2027_01_09_100003_create_chat_channels_table.php b/erp/database/migrations/2027_01_09_100003_create_chat_channels_table.php new file mode 100644 index 00000000000..3bb8aac04f8 --- /dev/null +++ b/erp/database/migrations/2027_01_09_100003_create_chat_channels_table.php @@ -0,0 +1,30 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->string('name'); + $table->string('widget_color')->default('#875A7B'); + $table->text('welcome_message')->nullable(); + $table->text('offline_message')->nullable(); + $table->boolean('is_active')->default(true); + $table->json('assigned_agents')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('chat_channels'); + } +}; diff --git a/erp/database/migrations/2027_01_09_100004_create_chat_sessions_table.php b/erp/database/migrations/2027_01_09_100004_create_chat_sessions_table.php new file mode 100644 index 00000000000..76d8ee08843 --- /dev/null +++ b/erp/database/migrations/2027_01_09_100004_create_chat_sessions_table.php @@ -0,0 +1,35 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->foreignId('channel_id')->constrained('chat_channels')->cascadeOnDelete(); + $table->string('visitor_name')->nullable(); + $table->string('visitor_email')->nullable(); + $table->string('source_url')->nullable(); + $table->enum('status', ['open', 'assigned', 'resolved', 'missed'])->default('open'); + $table->foreignId('assigned_agent_id')->nullable()->constrained('users')->nullOnDelete(); + $table->tinyInteger('rating')->nullable(); + $table->text('rating_note')->nullable(); + $table->timestamp('started_at')->nullable(); + $table->timestamp('ended_at')->nullable(); + $table->timestamp('last_message_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('chat_sessions'); + } +}; diff --git a/erp/database/migrations/2027_01_09_100005_create_chat_messages_table.php b/erp/database/migrations/2027_01_09_100005_create_chat_messages_table.php new file mode 100644 index 00000000000..42ba7647a1d --- /dev/null +++ b/erp/database/migrations/2027_01_09_100005_create_chat_messages_table.php @@ -0,0 +1,32 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->foreignId('session_id')->constrained('chat_sessions')->cascadeOnDelete(); + $table->enum('sender_type', ['visitor', 'agent', 'bot'])->default('visitor'); + $table->foreignId('agent_id')->nullable()->constrained('users')->nullOnDelete(); + $table->text('message'); + $table->boolean('is_read')->default(false); + $table->timestamp('read_at')->nullable(); + $table->timestamps(); + + $table->index(['session_id', 'created_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('chat_messages'); + } +}; diff --git a/erp/database/migrations/2027_01_09_100006_create_social_accounts_table.php b/erp/database/migrations/2027_01_09_100006_create_social_accounts_table.php new file mode 100644 index 00000000000..4a9c7fd9d34 --- /dev/null +++ b/erp/database/migrations/2027_01_09_100006_create_social_accounts_table.php @@ -0,0 +1,33 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->enum('platform', ['facebook', 'twitter', 'linkedin', 'instagram', 'youtube', 'tiktok']); + $table->string('account_name'); + $table->string('account_handle')->nullable(); + $table->string('avatar_url')->nullable(); + $table->boolean('is_connected')->default(true); + $table->boolean('is_active')->default(true); + $table->integer('followers_count')->default(0); + $table->integer('following_count')->default(0); + $table->timestamp('last_synced_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('social_accounts'); + } +}; diff --git a/erp/database/migrations/2027_01_09_100007_create_social_posts_table.php b/erp/database/migrations/2027_01_09_100007_create_social_posts_table.php new file mode 100644 index 00000000000..b5dbc30acb9 --- /dev/null +++ b/erp/database/migrations/2027_01_09_100007_create_social_posts_table.php @@ -0,0 +1,35 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->text('content'); + $table->json('media_urls')->nullable(); + $table->json('platforms'); + $table->json('social_account_ids')->nullable(); + $table->enum('status', ['draft', 'scheduled', 'publishing', 'published', 'failed'])->default('draft'); + $table->timestamp('scheduled_at')->nullable(); + $table->timestamp('published_at')->nullable(); + $table->unsignedBigInteger('campaign_id')->nullable(); + $table->text('error_message')->nullable(); + $table->json('metrics')->nullable(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('social_posts'); + } +}; diff --git a/erp/database/migrations/2027_01_09_100008_create_frontdesk_stations_table.php b/erp/database/migrations/2027_01_09_100008_create_frontdesk_stations_table.php new file mode 100644 index 00000000000..d70f28725ff --- /dev/null +++ b/erp/database/migrations/2027_01_09_100008_create_frontdesk_stations_table.php @@ -0,0 +1,31 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->string('name'); + $table->string('location')->nullable(); + $table->boolean('is_active')->default(true); + $table->unsignedBigInteger('responsible_id')->nullable(); + $table->timestamps(); + + $table->foreign('tenant_id')->references('id')->on('tenants')->cascadeOnDelete(); + $table->foreign('responsible_id')->references('id')->on('users')->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::dropIfExists('frontdesk_stations'); + } +}; diff --git a/erp/database/migrations/2027_01_09_100009_create_visitor_logs_table.php b/erp/database/migrations/2027_01_09_100009_create_visitor_logs_table.php new file mode 100644 index 00000000000..4d2c590cf93 --- /dev/null +++ b/erp/database/migrations/2027_01_09_100009_create_visitor_logs_table.php @@ -0,0 +1,42 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->unsignedBigInteger('station_id')->nullable(); + $table->string('visitor_name'); + $table->string('visitor_email')->nullable(); + $table->string('visitor_phone')->nullable(); + $table->string('visitor_company')->nullable(); + $table->string('visit_purpose')->nullable(); + $table->unsignedBigInteger('host_employee_id')->nullable(); + $table->string('badge_number')->nullable(); + $table->enum('status', ['expected', 'checked_in', 'checked_out', 'no_show'])->default('expected'); + $table->timestamp('expected_at')->nullable(); + $table->timestamp('check_in_at')->nullable(); + $table->timestamp('check_out_at')->nullable(); + $table->string('visitor_photo_path')->nullable(); + $table->text('notes')->nullable(); + $table->timestamps(); + + $table->foreign('tenant_id')->references('id')->on('tenants')->cascadeOnDelete(); + $table->foreign('station_id')->references('id')->on('frontdesk_stations')->nullOnDelete(); + $table->foreign('host_employee_id')->references('id')->on('users')->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::dropIfExists('visitor_logs'); + } +}; diff --git a/erp/database/migrations/2027_01_10_100001_create_lunch_suppliers_table.php b/erp/database/migrations/2027_01_10_100001_create_lunch_suppliers_table.php new file mode 100644 index 00000000000..c4ead3c088f --- /dev/null +++ b/erp/database/migrations/2027_01_10_100001_create_lunch_suppliers_table.php @@ -0,0 +1,30 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->string('name'); + $table->text('address')->nullable(); + $table->string('phone')->nullable(); + $table->string('email')->nullable(); + $table->text('description')->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('lunch_suppliers'); + } +}; diff --git a/erp/database/migrations/2027_01_10_100002_create_lunch_products_table.php b/erp/database/migrations/2027_01_10_100002_create_lunch_products_table.php new file mode 100644 index 00000000000..a78c23cd4e7 --- /dev/null +++ b/erp/database/migrations/2027_01_10_100002_create_lunch_products_table.php @@ -0,0 +1,31 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->foreignId('lunch_supplier_id')->constrained('lunch_suppliers')->cascadeOnDelete(); + $table->string('name'); + $table->text('description')->nullable(); + $table->decimal('price', 10, 2); + $table->string('category')->nullable(); + $table->boolean('is_available')->default(true); + $table->string('image_url')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('lunch_products'); + } +}; diff --git a/erp/database/migrations/2027_01_10_100003_create_lunch_orders_table.php b/erp/database/migrations/2027_01_10_100003_create_lunch_orders_table.php new file mode 100644 index 00000000000..f82bce97aba --- /dev/null +++ b/erp/database/migrations/2027_01_10_100003_create_lunch_orders_table.php @@ -0,0 +1,31 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->unsignedBigInteger('employee_id')->nullable(); + $table->foreignId('lunch_product_id')->constrained('lunch_products')->cascadeOnDelete(); + $table->integer('quantity')->default(1); + $table->date('order_date'); + $table->enum('status', ['pending', 'confirmed', 'delivered', 'cancelled'])->default('pending'); + $table->text('notes')->nullable(); + $table->decimal('total_price', 10, 2); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('lunch_orders'); + } +}; diff --git a/erp/database/migrations/2027_01_10_100004_create_appointment_types_table.php b/erp/database/migrations/2027_01_10_100004_create_appointment_types_table.php new file mode 100644 index 00000000000..bfb709b6088 --- /dev/null +++ b/erp/database/migrations/2027_01_10_100004_create_appointment_types_table.php @@ -0,0 +1,33 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->string('name'); + $table->text('description')->nullable(); + $table->integer('duration_minutes')->default(60); + $table->string('location')->nullable(); + $table->integer('max_capacity')->default(1); + $table->boolean('is_active')->default(true); + $table->string('color')->nullable(); + $table->timestamps(); + + $table->foreign('tenant_id')->references('id')->on('tenants')->cascadeOnDelete(); + }); + } + + public function down(): void + { + Schema::dropIfExists('appointment_types'); + } +}; diff --git a/erp/database/migrations/2027_01_10_100005_create_appointment_slots_table.php b/erp/database/migrations/2027_01_10_100005_create_appointment_slots_table.php new file mode 100644 index 00000000000..83df165d41f --- /dev/null +++ b/erp/database/migrations/2027_01_10_100005_create_appointment_slots_table.php @@ -0,0 +1,35 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->unsignedBigInteger('appointment_type_id'); + $table->unsignedBigInteger('staff_user_id')->nullable(); + $table->datetime('start_at'); + $table->datetime('end_at'); + $table->integer('capacity')->default(1); + $table->integer('booked_count')->default(0); + $table->boolean('is_available')->default(true); + $table->timestamps(); + + $table->foreign('tenant_id')->references('id')->on('tenants')->cascadeOnDelete(); + $table->foreign('appointment_type_id')->references('id')->on('appointment_types')->cascadeOnDelete(); + $table->foreign('staff_user_id')->references('id')->on('users')->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::dropIfExists('appointment_slots'); + } +}; diff --git a/erp/database/migrations/2027_01_10_100006_create_appointments_table.php b/erp/database/migrations/2027_01_10_100006_create_appointments_table.php new file mode 100644 index 00000000000..b9d67a14137 --- /dev/null +++ b/erp/database/migrations/2027_01_10_100006_create_appointments_table.php @@ -0,0 +1,38 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->unsignedBigInteger('appointment_slot_id'); + $table->unsignedBigInteger('appointment_type_id'); + $table->string('customer_name'); + $table->string('customer_email'); + $table->string('customer_phone')->nullable(); + $table->text('notes')->nullable(); + $table->enum('status', ['pending', 'confirmed', 'cancelled', 'completed', 'no_show'])->default('pending'); + $table->timestamp('confirmed_at')->nullable(); + $table->timestamp('cancelled_at')->nullable(); + $table->text('cancellation_reason')->nullable(); + $table->timestamps(); + + $table->foreign('tenant_id')->references('id')->on('tenants')->cascadeOnDelete(); + $table->foreign('appointment_slot_id')->references('id')->on('appointment_slots')->cascadeOnDelete(); + $table->foreign('appointment_type_id')->references('id')->on('appointment_types')->cascadeOnDelete(); + }); + } + + public function down(): void + { + Schema::dropIfExists('appointments'); + } +}; diff --git a/erp/database/migrations/2027_01_10_100007_create_web_pages_table.php b/erp/database/migrations/2027_01_10_100007_create_web_pages_table.php new file mode 100644 index 00000000000..d6bf2ef3948 --- /dev/null +++ b/erp/database/migrations/2027_01_10_100007_create_web_pages_table.php @@ -0,0 +1,35 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->string('title'); + $table->string('slug'); + $table->longText('content')->nullable(); + $table->string('meta_title')->nullable(); + $table->text('meta_description')->nullable(); + $table->enum('status', ['draft', 'published', 'archived'])->default('draft'); + $table->timestamp('published_at')->nullable(); + $table->boolean('is_homepage')->default(false); + $table->string('layout')->default('default'); + $table->timestamps(); + + $table->unique(['tenant_id', 'slug']); + }); + } + + public function down(): void + { + Schema::dropIfExists('web_pages'); + } +}; diff --git a/erp/database/migrations/2027_01_10_100008_create_blog_posts_table.php b/erp/database/migrations/2027_01_10_100008_create_blog_posts_table.php new file mode 100644 index 00000000000..709ca53b95d --- /dev/null +++ b/erp/database/migrations/2027_01_10_100008_create_blog_posts_table.php @@ -0,0 +1,36 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->string('title'); + $table->string('slug'); + $table->text('excerpt')->nullable(); + $table->longText('content')->nullable(); + $table->unsignedBigInteger('author_id')->nullable(); + $table->string('featured_image')->nullable(); + $table->enum('status', ['draft', 'published', 'archived'])->default('draft'); + $table->timestamp('published_at')->nullable(); + $table->json('tags')->nullable(); + $table->integer('view_count')->default(0); + $table->timestamps(); + + $table->unique(['tenant_id', 'slug']); + }); + } + + public function down(): void + { + Schema::dropIfExists('blog_posts'); + } +}; diff --git a/erp/database/migrations/2027_01_10_100009_create_web_menus_table.php b/erp/database/migrations/2027_01_10_100009_create_web_menus_table.php new file mode 100644 index 00000000000..eb4d8f48232 --- /dev/null +++ b/erp/database/migrations/2027_01_10_100009_create_web_menus_table.php @@ -0,0 +1,28 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->string('name'); + $table->string('location'); + $table->json('items')->default('[]'); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('web_menus'); + } +}; diff --git a/erp/database/migrations/2027_01_10_100010_create_purchase_vendors_table.php b/erp/database/migrations/2027_01_10_100010_create_purchase_vendors_table.php new file mode 100644 index 00000000000..8fbc6b63312 --- /dev/null +++ b/erp/database/migrations/2027_01_10_100010_create_purchase_vendors_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete(); + $table->string('name'); + $table->string('email')->nullable(); + $table->string('phone')->nullable(); + $table->text('address')->nullable(); + $table->string('currency')->default('USD'); + $table->string('payment_terms')->nullable(); + $table->boolean('is_active')->default(true); + $table->unsignedTinyInteger('rating')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('po_vendors'); + } +}; diff --git a/erp/database/migrations/2027_01_10_100011_create_purchase_rfqs_table.php b/erp/database/migrations/2027_01_10_100011_create_purchase_rfqs_table.php new file mode 100644 index 00000000000..a962a01bf65 --- /dev/null +++ b/erp/database/migrations/2027_01_10_100011_create_purchase_rfqs_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete(); + $table->string('rfq_number'); + $table->foreignId('po_vendor_id')->constrained('po_vendors')->cascadeOnDelete(); + $table->enum('status', ['draft', 'sent', 'received', 'cancelled'])->default('draft'); + $table->date('expected_delivery')->nullable(); + $table->text('notes')->nullable(); + $table->string('currency')->default('USD'); + $table->timestamp('sent_at')->nullable(); + $table->timestamps(); + + $table->unique(['tenant_id', 'rfq_number']); + }); + } + + public function down(): void + { + Schema::dropIfExists('po_rfqs'); + } +}; diff --git a/erp/database/migrations/2027_01_10_100012_create_purchase_rfq_lines_table.php b/erp/database/migrations/2027_01_10_100012_create_purchase_rfq_lines_table.php new file mode 100644 index 00000000000..1b4bd5d7157 --- /dev/null +++ b/erp/database/migrations/2027_01_10_100012_create_purchase_rfq_lines_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete(); + $table->foreignId('po_rfq_id')->constrained('po_rfqs')->cascadeOnDelete(); + $table->string('product_name'); + $table->text('description')->nullable(); + $table->decimal('quantity', 10, 3)->default(1); + $table->decimal('unit_price', 10, 2)->default(0); + $table->string('uom')->default('unit'); + $table->decimal('subtotal', 10, 2)->default(0); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('po_rfq_lines'); + } +}; diff --git a/erp/database/migrations/2027_01_10_100013_create_pos_table.php b/erp/database/migrations/2027_01_10_100013_create_pos_table.php new file mode 100644 index 00000000000..af28c3a74b8 --- /dev/null +++ b/erp/database/migrations/2027_01_10_100013_create_pos_table.php @@ -0,0 +1,36 @@ +id(); + $table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete(); + $table->string('po_number'); + $table->foreignId('po_rfq_id')->nullable()->constrained('po_rfqs')->nullOnDelete(); + $table->foreignId('po_vendor_id')->constrained('po_vendors')->cascadeOnDelete(); + $table->enum('status', ['draft', 'confirmed', 'received', 'cancelled'])->default('draft'); + $table->date('order_date'); + $table->date('expected_delivery')->nullable(); + $table->text('notes')->nullable(); + $table->string('currency')->default('USD'); + $table->decimal('total_amount', 12, 2)->default(0); + $table->timestamp('confirmed_at')->nullable(); + $table->timestamp('received_at')->nullable(); + $table->timestamps(); + + $table->unique(['tenant_id', 'po_number']); + }); + } + + public function down(): void + { + Schema::dropIfExists('pos'); + } +}; diff --git a/erp/database/migrations/2027_01_10_100014_create_po_lines_table.php b/erp/database/migrations/2027_01_10_100014_create_po_lines_table.php new file mode 100644 index 00000000000..b7371a937b4 --- /dev/null +++ b/erp/database/migrations/2027_01_10_100014_create_po_lines_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete(); + $table->foreignId('po_id')->constrained('pos')->cascadeOnDelete(); + $table->string('product_name'); + $table->text('description')->nullable(); + $table->decimal('quantity', 10, 3)->default(1); + $table->decimal('unit_price', 10, 2)->default(0); + $table->string('uom')->default('unit'); + $table->decimal('subtotal', 10, 2)->default(0); + $table->decimal('received_qty', 10, 3)->default(0); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('po_lines'); + } +}; diff --git a/erp/database/migrations/2027_06_19_000001_create_report_schedules_table.php b/erp/database/migrations/2027_06_19_000001_create_report_schedules_table.php new file mode 100644 index 00000000000..2847a522e6d --- /dev/null +++ b/erp/database/migrations/2027_06_19_000001_create_report_schedules_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->string('name'); + $table->string('report_type'); // financial, inventory, hr + $table->string('frequency'); // daily, weekly, monthly + $table->json('recipients'); // array of email addresses + $table->json('filters')->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamp('last_sent_at')->nullable(); + $table->timestamp('next_run_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('report_schedules'); + } +}; diff --git a/erp/database/migrations/2027_06_19_000002_create_dashboard_widgets_table.php b/erp/database/migrations/2027_06_19_000002_create_dashboard_widgets_table.php new file mode 100644 index 00000000000..046bdbc6d9a --- /dev/null +++ b/erp/database/migrations/2027_06_19_000002_create_dashboard_widgets_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->string('widget_type'); // kpi, chart, table, activity + $table->string('title'); + $table->json('config')->nullable(); // widget-specific settings + $table->integer('position')->default(0); + $table->string('size')->default('md'); // sm, md, lg, xl + $table->boolean('is_visible')->default(true); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('dashboard_widgets'); + } +}; diff --git a/erp/database/migrations/2027_06_19_000003_create_email_templates_table.php b/erp/database/migrations/2027_06_19_000003_create_email_templates_table.php new file mode 100644 index 00000000000..dc8b2eac2ae --- /dev/null +++ b/erp/database/migrations/2027_06_19_000003_create_email_templates_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete(); + $table->string('key'); // invoice_created, low_stock_alert, etc. + $table->string('name'); + $table->string('subject'); + $table->text('body_html'); + $table->json('variables')->nullable(); // available variables list + $table->boolean('is_active')->default(true); + $table->timestamps(); + + $table->unique(['tenant_id', 'key']); + }); + } + + public function down(): void + { + Schema::dropIfExists('email_templates'); + } +}; diff --git a/erp/database/migrations/2027_06_19_000004_create_tenant_features_table.php b/erp/database/migrations/2027_06_19_000004_create_tenant_features_table.php new file mode 100644 index 00000000000..9c2c2a94af6 --- /dev/null +++ b/erp/database/migrations/2027_06_19_000004_create_tenant_features_table.php @@ -0,0 +1,26 @@ +id(); + $table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete(); + $table->string('feature'); + $table->boolean('is_enabled')->default(true); + $table->json('config')->nullable(); + $table->timestamps(); + + $table->unique(['tenant_id', 'feature']); + }); + } + + public function down(): void + { + Schema::dropIfExists('tenant_features'); + } +}; diff --git a/erp/database/migrations/2027_06_19_000005_create_user_preferences_table.php b/erp/database/migrations/2027_06_19_000005_create_user_preferences_table.php new file mode 100644 index 00000000000..65bd48965be --- /dev/null +++ b/erp/database/migrations/2027_06_19_000005_create_user_preferences_table.php @@ -0,0 +1,25 @@ +id(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->string('key'); + $table->text('value')->nullable(); + $table->timestamps(); + + $table->unique(['user_id', 'key']); + }); + } + + public function down(): void + { + Schema::dropIfExists('user_preferences'); + } +}; diff --git a/erp/database/migrations/2027_06_19_000006_create_alert_rules_table.php b/erp/database/migrations/2027_06_19_000006_create_alert_rules_table.php new file mode 100644 index 00000000000..7d417afbbae --- /dev/null +++ b/erp/database/migrations/2027_06_19_000006_create_alert_rules_table.php @@ -0,0 +1,36 @@ +id(); + $table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete(); + $table->string('name'); + $table->string('type'); // overdue_invoice, low_stock, budget_exceeded, high_churn, etc. + $table->json('conditions'); // threshold values and operators + $table->json('notification_targets'); // user IDs or email addresses + $table->boolean('is_active')->default(true); + $table->timestamp('last_triggered_at')->nullable(); + $table->timestamps(); + }); + + Schema::create('alert_events', function (Blueprint $table) { + $table->id(); + $table->foreignId('alert_rule_id')->constrained('alert_rules')->cascadeOnDelete(); + $table->string('message'); + $table->json('context')->nullable(); + $table->timestamp('triggered_at'); + }); + } + + public function down(): void + { + Schema::dropIfExists('alert_events'); + Schema::dropIfExists('alert_rules'); + } +}; diff --git a/erp/database/migrations/2027_06_19_000007_add_credit_limit_to_contacts_table.php b/erp/database/migrations/2027_06_19_000007_add_credit_limit_to_contacts_table.php new file mode 100644 index 00000000000..50a9321ecd3 --- /dev/null +++ b/erp/database/migrations/2027_06_19_000007_add_credit_limit_to_contacts_table.php @@ -0,0 +1,24 @@ +decimal('credit_limit', 15, 2)->default(0)->after('is_active'); + $table->integer('credit_terms_days')->default(30)->after('credit_limit'); + $table->boolean('credit_hold')->default(false)->after('credit_terms_days'); + }); + } + + public function down(): void + { + Schema::table('contacts', function (Blueprint $table) { + $table->dropColumn(['credit_limit', 'credit_terms_days', 'credit_hold']); + }); + } +}; diff --git a/erp/database/migrations/2027_06_19_000008_create_custom_fields_tables.php b/erp/database/migrations/2027_06_19_000008_create_custom_fields_tables.php new file mode 100644 index 00000000000..b8e7d7ff41c --- /dev/null +++ b/erp/database/migrations/2027_06_19_000008_create_custom_fields_tables.php @@ -0,0 +1,48 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->string('model_type', 50); // contact, product, employee, invoice, lead + $table->string('field_name', 100); + $table->string('field_key', 100); + $table->string('field_type', 20)->default('text'); // text, number, date, boolean, select, textarea + $table->json('options')->nullable(); // for select type: ["Option A","Option B"] + $table->boolean('required')->default(false); + $table->boolean('is_active')->default(true); + $table->unsignedInteger('sort_order')->default(0); + $table->timestamps(); + + $table->unique(['tenant_id', 'model_type', 'field_key']); + }); + + Schema::create('custom_field_values', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('tenant_id'); + $table->foreignId('definition_id')->constrained('custom_field_definitions')->cascadeOnDelete(); + $table->string('model_type', 50); + $table->unsignedBigInteger('model_id'); + $table->text('value')->nullable(); + $table->timestamps(); + + $table->unique(['definition_id', 'model_type', 'model_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('custom_field_values'); + Schema::dropIfExists('custom_field_definitions'); + } +}; diff --git a/erp/database/seeders/AccountingSeeder.php b/erp/database/seeders/AccountingSeeder.php new file mode 100644 index 00000000000..d988920da72 --- /dev/null +++ b/erp/database/seeders/AccountingSeeder.php @@ -0,0 +1,83 @@ +id); + + $cash = Account::where('tenant_id', $tenant->id)->where('code', '1000')->first(); + $revenue = Account::where('tenant_id', $tenant->id)->where('code', '4000')->first(); + $expense = Account::where('tenant_id', $tenant->id)->where('code', '5000')->first(); + $ap = Account::where('tenant_id', $tenant->id)->where('code', '2000')->first(); + + // Journal Entry 1 — Sales revenue received in cash + $entry1 = JournalEntry::create([ + 'tenant_id' => $tenant->id, + 'entry_number' => 'JE-2026-00001', + 'reference' => 'INV-2026-001', + 'description' => 'Cash received for sales revenue', + 'entry_date' => '2026-01-15', + 'status' => 'posted', + 'posted_at' => now(), + ]); + + JournalEntryLine::create([ + 'journal_entry_id' => $entry1->id, + 'account_id' => $cash->id, + 'description' => 'Cash receipt', + 'debit' => 5000.00, + 'credit' => 0.00, + ]); + + JournalEntryLine::create([ + 'journal_entry_id' => $entry1->id, + 'account_id' => $revenue->id, + 'description' => 'Sales revenue', + 'debit' => 0.00, + 'credit' => 5000.00, + ]); + + // Journal Entry 2 — Operating expense paid in cash + $entry2 = JournalEntry::create([ + 'tenant_id' => $tenant->id, + 'entry_number' => 'JE-2026-00002', + 'reference' => 'EXP-2026-001', + 'description' => 'Operating expenses paid', + 'entry_date' => '2026-01-20', + 'status' => 'posted', + 'posted_at' => now(), + ]); + + JournalEntryLine::create([ + 'journal_entry_id' => $entry2->id, + 'account_id' => $expense->id, + 'description' => 'Office supplies expense', + 'debit' => 1200.00, + 'credit' => 0.00, + ]); + + JournalEntryLine::create([ + 'journal_entry_id' => $entry2->id, + 'account_id' => $cash->id, + 'description' => 'Cash payment', + 'debit' => 0.00, + 'credit' => 1200.00, + ]); + } +} diff --git a/erp/database/seeders/AppointmentsSeeder.php b/erp/database/seeders/AppointmentsSeeder.php new file mode 100644 index 00000000000..3a94a8944aa --- /dev/null +++ b/erp/database/seeders/AppointmentsSeeder.php @@ -0,0 +1,99 @@ + $tenant->id, + 'name' => 'Initial Consultation', + 'description' => 'First meeting with a new client to assess needs', + 'duration_minutes' => 60, + 'location' => 'Meeting Room A', + 'max_capacity' => 1, + 'is_active' => true, + 'color' => '#3B82F6', + ]); + + $followUp = AppointmentType::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Follow-Up Session', + 'description' => 'Ongoing support session for existing clients', + 'duration_minutes' => 30, + 'location' => 'Meeting Room B', + 'max_capacity' => 1, + 'is_active' => true, + 'color' => '#10B981', + ]); + + // Appointment Slots + $slot1 = AppointmentSlot::create([ + 'tenant_id' => $tenant->id, + 'appointment_type_id' => $consultation->id, + 'start_at' => '2026-07-01 09:00:00', + 'end_at' => '2026-07-01 10:00:00', + 'capacity' => 1, + 'booked_count' => 1, + 'is_available' => false, + ]); + + $slot2 = AppointmentSlot::create([ + 'tenant_id' => $tenant->id, + 'appointment_type_id' => $consultation->id, + 'start_at' => '2026-07-02 14:00:00', + 'end_at' => '2026-07-02 15:00:00', + 'capacity' => 1, + 'booked_count' => 0, + 'is_available' => true, + ]); + + $slot3 = AppointmentSlot::create([ + 'tenant_id' => $tenant->id, + 'appointment_type_id' => $followUp->id, + 'start_at' => '2026-07-03 11:00:00', + 'end_at' => '2026-07-03 11:30:00', + 'capacity' => 2, + 'booked_count' => 1, + 'is_available' => true, + ]); + + // Appointments + Appointment::create([ + 'tenant_id' => $tenant->id, + 'appointment_slot_id' => $slot1->id, + 'appointment_type_id' => $consultation->id, + 'customer_name' => 'Sarah Johnson', + 'customer_email' => 'sarah.johnson@example.com', + 'customer_phone' => '+1-555-0101', + 'notes' => 'Referred by existing client. Interested in premium tier.', + 'status' => 'confirmed', + 'confirmed_at' => '2026-06-28 10:00:00', + ]); + + Appointment::create([ + 'tenant_id' => $tenant->id, + 'appointment_slot_id' => $slot3->id, + 'appointment_type_id' => $followUp->id, + 'customer_name' => 'Michael Torres', + 'customer_email' => 'michael.torres@example.com', + 'customer_phone' => '+1-555-0202', + 'notes' => 'Monthly check-in.', + 'status' => 'pending', + ]); + } +} diff --git a/erp/database/seeders/ApprovalsSeeder.php b/erp/database/seeders/ApprovalsSeeder.php new file mode 100644 index 00000000000..8c560949b62 --- /dev/null +++ b/erp/database/seeders/ApprovalsSeeder.php @@ -0,0 +1,55 @@ + $tenant->id, + 'entity_type' => 'purchase_order', + 'entity_id' => 1, + 'entity_title' => 'PO-2026-001 — Office Equipment ($3,500)', + 'status' => 'pending', + 'current_step' => 1, + 'total_steps' => 2, + ]); + + // Approved expense claim + ApprovalRequest::create([ + 'tenant_id' => $tenant->id, + 'entity_type' => 'expense_claim', + 'entity_id' => 2, + 'entity_title' => 'EXP-2026-015 — Travel & Accommodation ($850)', + 'status' => 'approved', + 'current_step' => 1, + 'total_steps' => 1, + 'approved_at' => now()->subDays(3), + ]); + + // Rejected leave request + ApprovalRequest::create([ + 'tenant_id' => $tenant->id, + 'entity_type' => 'leave_request', + 'entity_id' => 5, + 'entity_title' => 'Annual Leave — Jane Smith (5 days)', + 'status' => 'rejected', + 'current_step' => 1, + 'total_steps' => 1, + 'rejected_at' => now()->subDays(1), + 'rejection_reason' => 'Insufficient leave balance for the requested period.', + ]); + } +} diff --git a/erp/database/seeders/CrmSeeder.php b/erp/database/seeders/CrmSeeder.php new file mode 100644 index 00000000000..b40b876e029 --- /dev/null +++ b/erp/database/seeders/CrmSeeder.php @@ -0,0 +1,72 @@ + $tenant->id, + 'title' => 'Enterprise Software Suite Inquiry', + 'type' => 'lead', + 'contact_name' => 'Alice Mercer', + 'company_name' => 'BlueSky Technologies', + 'email' => 'alice.mercer@bluesky.example', + 'phone' => '+1-555-0301', + 'source' => 'website', + 'expected_revenue' => 24000.00, + 'probability' => 25, + 'expected_close_date' => '2026-09-30', + 'priority' => 'high', + 'status' => 'open', + 'description' => 'Prospective client interested in the full ERP suite for a 50-person team.', + ]); + + // Opportunity — won + CrmLead::create([ + 'tenant_id' => $tenant->id, + 'title' => 'Annual Support Contract Renewal', + 'type' => 'opportunity', + 'contact_name' => 'David Okafor', + 'company_name' => 'Pinnacle Logistics', + 'email' => 'david.okafor@pinnacle.example', + 'phone' => '+1-555-0402', + 'source' => 'referral', + 'expected_revenue' => 8500.00, + 'probability' => 100, + 'expected_close_date' => '2026-06-30', + 'priority' => 'normal', + 'status' => 'won', + 'won_at' => now()->subDays(5), + ]); + + // Lead — lost + CrmLead::create([ + 'tenant_id' => $tenant->id, + 'title' => 'Payroll Module Standalone License', + 'type' => 'lead', + 'contact_name' => 'Rachel Kim', + 'company_name' => 'Sunrise Retail Group', + 'email' => 'rachel.kim@sunrise.example', + 'source' => 'trade_show', + 'expected_revenue' => 3200.00, + 'probability' => 0, + 'priority' => 'low', + 'status' => 'lost', + 'lost_reason' => 'Prospect chose a competitor with a lower price point.', + 'lost_at' => now()->subWeeks(2), + ]); + } +} diff --git a/erp/database/seeders/DatabaseSeeder.php b/erp/database/seeders/DatabaseSeeder.php new file mode 100644 index 00000000000..78b88b10f7a --- /dev/null +++ b/erp/database/seeders/DatabaseSeeder.php @@ -0,0 +1,65 @@ +call(RolePermissionSeeder::class); + + $tenant = Tenant::create([ + 'name' => 'Demo Company', + 'slug' => 'demo', + 'domain' => null, + 'is_active' => true, + ]); + + $admin = User::factory()->create([ + 'name' => 'Admin User', + 'email' => 'admin@example.com', + 'tenant_id' => $tenant->id, + ]); + + $admin->assignRole('super-admin'); + + $this->call(InventorySeeder::class); + $this->call(FinanceSeeder::class); + $this->call(HRSeeder::class); + $this->call(AccountingSeeder::class); + $this->call(PurchaseSeeder::class); + $this->call(CrmSeeder::class); + $this->call(ManufacturingSeeder::class); + $this->call(PmSeeder::class); + $this->call(MaintenanceSeeder::class); + $this->call(FleetSeeder::class); + $this->call(RentalSeeder::class); + $this->call(RepairsSeeder::class); + $this->call(QualityControlSeeder::class); + $this->call(SubcontractingSeeder::class); + $this->call(MarketingSeeder::class); + $this->call(EcommerceSeeder::class); + $this->call(SubscriptionsSeeder::class); + $this->call(AppointmentsSeeder::class); + $this->call(ApprovalsSeeder::class); + $this->call(DiscussSeeder::class); + $this->call(DocumentsSeeder::class); + $this->call(EventsSeeder::class); + $this->call(FieldServiceSeeder::class); + $this->call(FrontdeskSeeder::class); + $this->call(HelpdeskSeeder::class); + $this->call(KnowledgeBaseSeeder::class); + $this->call(LiveChatSeeder::class); + $this->call(LunchSeeder::class); + $this->call(PlanningSeeder::class); + $this->call(PosSeeder::class); + $this->call(SignSeeder::class); + $this->call(SocialMarketingSeeder::class); + $this->call(SurveySeeder::class); + $this->call(WebsiteSeeder::class); + } +} diff --git a/erp/database/seeders/DiscussSeeder.php b/erp/database/seeders/DiscussSeeder.php new file mode 100644 index 00000000000..8cb9d3f0143 --- /dev/null +++ b/erp/database/seeders/DiscussSeeder.php @@ -0,0 +1,70 @@ +id)->first(); + + if (! $user) { + return; + } + + // Channels + $general = DiscussChannel::create([ + 'tenant_id' => $tenant->id, + 'name' => 'General', + 'slug' => 'general', + 'description' => 'Company-wide announcements and discussions', + 'type' => 'public', + 'is_archived' => false, + 'created_by' => $user->id, + ]); + + $sales = DiscussChannel::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Sales Team', + 'slug' => 'sales-team', + 'description' => 'Internal channel for the sales department', + 'type' => 'private', + 'is_archived' => false, + 'created_by' => $user->id, + ]); + + // Messages in General channel + DiscussMessage::create([ + 'tenant_id' => $tenant->id, + 'channel_id' => $general->id, + 'user_id' => $user->id, + 'body' => 'Welcome to the General channel! Use this space for company-wide updates.', + ]); + + DiscussMessage::create([ + 'tenant_id' => $tenant->id, + 'channel_id' => $general->id, + 'user_id' => $user->id, + 'body' => 'Reminder: the Q3 planning meeting is scheduled for next Monday at 10:00 AM.', + ]); + + DiscussMessage::create([ + 'tenant_id' => $tenant->id, + 'channel_id' => $general->id, + 'user_id' => $user->id, + 'body' => 'Great work everyone on hitting the June targets! Keep up the momentum.', + ]); + } +} diff --git a/erp/database/seeders/DocumentsSeeder.php b/erp/database/seeders/DocumentsSeeder.php new file mode 100644 index 00000000000..39708ca1b8e --- /dev/null +++ b/erp/database/seeders/DocumentsSeeder.php @@ -0,0 +1,55 @@ + $tenant->id, + 'title' => 'Employee Handbook 2026', + 'description' => 'Company policies, procedures, and employee guidelines', + 'file_path' => 'documents/employee-handbook-2026.pdf', + 'file_name' => 'employee-handbook-2026.pdf', + 'file_size' => 2048000, + 'mime_type' => 'application/pdf', + 'version' => 1, + 'tags' => ['hr', 'policy', 'onboarding'], + ]); + + Document::create([ + 'tenant_id' => $tenant->id, + 'title' => 'Q1 2026 Financial Report', + 'description' => 'Quarterly financial statements and analysis for Q1 2026', + 'file_path' => 'documents/q1-2026-financial-report.xlsx', + 'file_name' => 'q1-2026-financial-report.xlsx', + 'file_size' => 512000, + 'mime_type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'version' => 2, + 'tags' => ['finance', 'report', 'q1-2026'], + ]); + + Document::create([ + 'tenant_id' => $tenant->id, + 'title' => 'Sales Contract Template', + 'description' => 'Standard sales agreement template for new clients', + 'file_path' => 'documents/sales-contract-template.docx', + 'file_name' => 'sales-contract-template.docx', + 'file_size' => 128000, + 'mime_type' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'version' => 1, + 'tags' => ['legal', 'sales', 'template'], + ]); + } +} diff --git a/erp/database/seeders/EcommerceSeeder.php b/erp/database/seeders/EcommerceSeeder.php new file mode 100644 index 00000000000..52fab911faa --- /dev/null +++ b/erp/database/seeders/EcommerceSeeder.php @@ -0,0 +1,107 @@ +id)->where('sku', 'LAP-001')->first(); + $monitor = Product::where('tenant_id', $tenant->id)->where('sku', 'MON-001')->first(); + $keyboard = Product::where('tenant_id', $tenant->id)->where('sku', 'KBD-001')->first(); + + // Fall back to any available products if InventorySeeder hasn't run yet. + if (! $laptop || ! $monitor || ! $keyboard) { + $products = Product::where('tenant_id', $tenant->id)->take(3)->get(); + if ($products->count() < 3) { + return; + } + [$laptop, $monitor, $keyboard] = [$products[0], $products[1], $products[2]]; + } + + // Store Products + $sp1 = StoreProduct::create([ + 'tenant_id' => $tenant->id, + 'product_id' => $laptop->id, + 'store_price' => 1299.99, + 'compare_price' => 1499.99, + 'is_featured' => true, + 'is_visible' => true, + 'sort_order' => 1, + 'short_description' => 'Powerful 15-inch laptop for professionals.', + 'meta_title' => 'Laptop Pro 15" | Best Performance Laptop', + ]); + + $sp2 = StoreProduct::create([ + 'tenant_id' => $tenant->id, + 'product_id' => $monitor->id, + 'store_price' => 449.99, + 'is_featured' => false, + 'is_visible' => true, + 'sort_order' => 2, + 'short_description' => 'Crystal-clear 27-inch 4K display for home and office.', + 'meta_title' => '27" 4K Monitor | Ultra HD Display', + ]); + + $sp3 = StoreProduct::create([ + 'tenant_id' => $tenant->id, + 'product_id' => $keyboard->id, + 'store_price' => 119.99, + 'compare_price' => 139.99, + 'is_featured' => false, + 'is_visible' => true, + 'sort_order' => 3, + 'short_description' => 'Tactile mechanical keyboard for fast typists.', + 'meta_title' => 'Mechanical Keyboard | Tactile Typing', + ]); + + // Store Orders + StoreOrder::create([ + 'tenant_id' => $tenant->id, + 'order_number' => 'SO-2026-00001', + 'status' => 'delivered', + 'customer_name' => 'Emma Clarke', + 'customer_email' => 'emma.clarke@example.com', + 'customer_phone' => '+1-555-0501', + 'shipping_address' => '42 Elm Street, Springfield, IL 62701', + 'billing_address' => '42 Elm Street, Springfield, IL 62701', + 'subtotal' => 1299.99, + 'discount_amount' => 0.00, + 'shipping_amount' => 9.99, + 'tax_amount' => 104.00, + 'total' => 1413.98, + 'payment_method' => 'credit_card', + 'payment_status' => 'paid', + ]); + + StoreOrder::create([ + 'tenant_id' => $tenant->id, + 'order_number' => 'SO-2026-00002', + 'status' => 'processing', + 'customer_name' => 'James Patel', + 'customer_email' => 'james.patel@example.com', + 'shipping_address' => '18 Oak Avenue, Austin, TX 78701', + 'billing_address' => '18 Oak Avenue, Austin, TX 78701', + 'subtotal' => 569.98, + 'discount_amount' => 50.00, + 'shipping_amount' => 0.00, + 'tax_amount' => 41.60, + 'total' => 561.58, + 'payment_method' => 'bank_transfer', + 'payment_status' => 'paid', + ]); + } +} diff --git a/erp/database/seeders/EventsSeeder.php b/erp/database/seeders/EventsSeeder.php new file mode 100644 index 00000000000..4ade0396823 --- /dev/null +++ b/erp/database/seeders/EventsSeeder.php @@ -0,0 +1,55 @@ + $tenant->id, + 'title' => 'Q3 Product Launch Webinar', + 'description' => 'Online webinar showcasing new product features and roadmap updates for Q3 2026.', + 'location' => 'Online (Zoom)', + 'starts_at' => '2026-07-15 14:00:00', + 'ends_at' => '2026-07-15 15:30:00', + 'capacity' => 200, + 'status' => 'draft', + ]); + + // Published event — team workshop + Event::create([ + 'tenant_id' => $tenant->id, + 'title' => 'Annual Team Building Workshop', + 'description' => 'A full-day workshop focused on collaboration, communication, and innovation.', + 'location' => 'Grand Conference Centre, 101 Main St', + 'starts_at' => '2026-08-05 09:00:00', + 'ends_at' => '2026-08-05 17:00:00', + 'capacity' => 50, + 'status' => 'published', + ]); + + // Cancelled event + Event::create([ + 'tenant_id' => $tenant->id, + 'title' => 'Partner Networking Evening', + 'description' => 'An evening event for clients and partners to connect and network.', + 'location' => 'The Rooftop Bar, 55 Central Avenue', + 'starts_at' => '2026-06-10 18:00:00', + 'ends_at' => '2026-06-10 21:00:00', + 'capacity' => 80, + 'status' => 'cancelled', + ]); + } +} diff --git a/erp/database/seeders/FieldServiceSeeder.php b/erp/database/seeders/FieldServiceSeeder.php new file mode 100644 index 00000000000..069bd7fb4b4 --- /dev/null +++ b/erp/database/seeders/FieldServiceSeeder.php @@ -0,0 +1,73 @@ +id ?? 1; + + ServiceOrder::create([ + 'tenant_id' => $tenant->id, + 'title' => 'HVAC System Installation', + 'type' => 'installation', + 'priority' => 'high', + 'status' => 'pending', + 'customer_name' => 'Riverside Office Park', + 'customer_email' => 'facilities@riverside.example', + 'customer_phone' => '+1-555-0101', + 'address' => '450 Riverside Blvd, Suite 200', + 'scheduled_at' => now()->addDays(3), + 'estimated_duration' => 240, + 'created_by' => $userId, + ]); + + ServiceOrder::create([ + 'tenant_id' => $tenant->id, + 'title' => 'Elevator Quarterly Inspection', + 'type' => 'inspection', + 'priority' => 'medium', + 'status' => 'in_progress', + 'customer_name' => 'Apex Tower Management', + 'customer_email' => 'maintenance@apextower.example', + 'customer_phone' => '+1-555-0202', + 'address' => '1 Apex Tower, Floor 1', + 'scheduled_at' => now()->subHours(2), + 'started_at' => now()->subHours(1), + 'estimated_duration' => 120, + 'assigned_to' => $userId, + 'created_by' => $userId, + ]); + + ServiceOrder::create([ + 'tenant_id' => $tenant->id, + 'title' => 'Network Equipment Repair', + 'type' => 'repair', + 'priority' => 'urgent', + 'status' => 'completed', + 'customer_name' => 'Metro Data Center', + 'customer_email' => 'ops@metrodc.example', + 'customer_phone' => '+1-555-0303', + 'address' => '800 Data Center Dr', + 'scheduled_at' => now()->subDays(2), + 'started_at' => now()->subDays(2)->addHour(), + 'completed_at' => now()->subDays(2)->addHours(3), + 'estimated_duration' => 90, + 'actual_duration' => 105, + 'assigned_to' => $userId, + 'created_by' => $userId, + 'notes' => 'Replaced faulty switch module. Tested all ports.', + ]); + } +} diff --git a/erp/database/seeders/FinanceSeeder.php b/erp/database/seeders/FinanceSeeder.php new file mode 100644 index 00000000000..44c5ad21f15 --- /dev/null +++ b/erp/database/seeders/FinanceSeeder.php @@ -0,0 +1,77 @@ + '1000', 'name' => 'Assets', 'type' => 'asset'], + ['code' => '1100', 'name' => 'Cash and Bank', 'type' => 'asset', 'parent' => '1000'], + ['code' => '1101', 'name' => 'Main Bank Account', 'type' => 'asset', 'parent' => '1100'], + ['code' => '1102', 'name' => 'Petty Cash', 'type' => 'asset', 'parent' => '1100'], + ['code' => '1200', 'name' => 'Accounts Receivable', 'type' => 'asset', 'parent' => '1000'], + ['code' => '1300', 'name' => 'Inventory', 'type' => 'asset', 'parent' => '1000'], + ['code' => '1400', 'name' => 'Prepaid Expenses', 'type' => 'asset', 'parent' => '1000'], + + // Liabilities + ['code' => '2000', 'name' => 'Liabilities', 'type' => 'liability'], + ['code' => '2100', 'name' => 'Accounts Payable', 'type' => 'liability', 'parent' => '2000'], + ['code' => '2200', 'name' => 'Accrued Liabilities', 'type' => 'liability', 'parent' => '2000'], + ['code' => '2300', 'name' => 'Tax Payable', 'type' => 'liability', 'parent' => '2000'], + + // Equity + ['code' => '3000', 'name' => 'Equity', 'type' => 'equity'], + ['code' => '3100', 'name' => "Owner's Capital", 'type' => 'equity', 'parent' => '3000'], + ['code' => '3200', 'name' => 'Retained Earnings', 'type' => 'equity', 'parent' => '3000'], + + // Income + ['code' => '4000', 'name' => 'Revenue', 'type' => 'income'], + ['code' => '4100', 'name' => 'Sales Revenue', 'type' => 'income', 'parent' => '4000'], + ['code' => '4200', 'name' => 'Service Revenue', 'type' => 'income', 'parent' => '4000'], + ['code' => '4900', 'name' => 'Other Income', 'type' => 'income', 'parent' => '4000'], + + // Expenses + ['code' => '5000', 'name' => 'Expenses', 'type' => 'expense'], + ['code' => '5100', 'name' => 'Cost of Goods Sold', 'type' => 'expense', 'parent' => '5000'], + ['code' => '5200', 'name' => 'Salaries & Wages', 'type' => 'expense', 'parent' => '5000'], + ['code' => '5300', 'name' => 'Rent & Occupancy', 'type' => 'expense', 'parent' => '5000'], + ['code' => '5400', 'name' => 'Utilities', 'type' => 'expense', 'parent' => '5000'], + ['code' => '5500', 'name' => 'Marketing & Advertising', 'type' => 'expense', 'parent' => '5000'], + ['code' => '5900', 'name' => 'Miscellaneous Expenses', 'type' => 'expense', 'parent' => '5000'], + ]; + + $created = []; + foreach ($accounts as $data) { + $parentId = isset($data['parent']) ? ($created[$data['parent']] ?? null) : null; + $account = Account::create([ + 'tenant_id' => $tenant->id, + 'code' => $data['code'], + 'name' => $data['name'], + 'type' => $data['type'], + 'parent_id' => $parentId, + ]); + $created[$data['code']] = $account->id; + } + + // Sample contacts + Contact::create(['tenant_id' => $tenant->id, 'name' => 'Acme Corp', 'email' => 'billing@acme.example', 'type' => 'customer']); + Contact::create(['tenant_id' => $tenant->id, 'name' => 'Globex LLC', 'email' => 'accounts@globex.example', 'type' => 'customer']); + Contact::create(['tenant_id' => $tenant->id, 'name' => 'Office Supplies Co', 'email' => 'ap@officesup.example','type' => 'vendor']); + Contact::create(['tenant_id' => $tenant->id, 'name' => 'Cloud Services Ltd', 'email' => 'invoices@cloud.example','type' => 'both']); + } +} diff --git a/erp/database/seeders/FleetSeeder.php b/erp/database/seeders/FleetSeeder.php new file mode 100644 index 00000000000..ce89632d858 --- /dev/null +++ b/erp/database/seeders/FleetSeeder.php @@ -0,0 +1,91 @@ +id ?? 1; + + $van = Vehicle::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Delivery Van 01', + 'plate_number' => 'DLV-1001', + 'make' => 'Ford', + 'model' => 'Transit', + 'year' => 2021, + 'color' => 'White', + 'type' => 'van', + 'status' => 'active', + 'fuel_type' => 'diesel', + 'odometer_km' => 34200.0, + 'insurance_expiry' => now()->addMonths(8)->toDateString(), + 'registration_expiry' => now()->addMonths(5)->toDateString(), + ]); + + $car = Vehicle::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Executive Sedan 01', + 'plate_number' => 'EXC-2201', + 'make' => 'Toyota', + 'model' => 'Camry', + 'year' => 2022, + 'color' => 'Silver', + 'type' => 'car', + 'status' => 'active', + 'fuel_type' => 'petrol', + 'odometer_km' => 18500.0, + 'insurance_expiry' => now()->addYear()->toDateString(), + 'registration_expiry' => now()->addMonths(10)->toDateString(), + 'assigned_to' => $userId, + ]); + + VehicleAssignment::create([ + 'tenant_id' => $tenant->id, + 'vehicle_id' => $van->id, + 'driver_id' => $userId, + 'purpose' => 'Client deliveries – North District', + 'assigned_at' => now()->subDays(1), + 'start_odometer' => 34000.0, + ]); + + FuelLog::create([ + 'tenant_id' => $tenant->id, + 'vehicle_id' => $van->id, + 'log_date' => now()->subDays(3)->toDateString(), + 'odometer_km' => 33850.0, + 'liters' => 55.00, + 'cost_per_liter' => 1.620, + 'total_cost' => 89.10, + 'fuel_type' => 'diesel', + 'station' => 'Shell – Highway North', + 'driver_id' => $userId, + ]); + + FuelLog::create([ + 'tenant_id' => $tenant->id, + 'vehicle_id' => $car->id, + 'log_date' => now()->subDays(5)->toDateString(), + 'odometer_km' => 18350.0, + 'liters' => 42.50, + 'cost_per_liter' => 1.750, + 'total_cost' => 74.38, + 'fuel_type' => 'petrol', + 'station' => 'BP City Centre', + 'driver_id' => $userId, + ]); + } +} diff --git a/erp/database/seeders/FrontdeskSeeder.php b/erp/database/seeders/FrontdeskSeeder.php new file mode 100644 index 00000000000..c276c0c321a --- /dev/null +++ b/erp/database/seeders/FrontdeskSeeder.php @@ -0,0 +1,69 @@ +id ?? 1; + + $station = FrontdeskStation::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Main Lobby Reception', + 'location' => 'Ground Floor, Building A', + 'is_active' => true, + 'responsible_id' => $userId, + ]); + + VisitorLog::create([ + 'tenant_id' => $tenant->id, + 'station_id' => $station->id, + 'visitor_name' => 'James Hartley', + 'visitor_email' => 'j.hartley@partner.example', + 'visitor_phone' => '+1-555-0411', + 'visitor_company' => 'Hartley & Associates', + 'visit_purpose' => 'Partnership meeting', + 'host_employee_id' => $userId, + 'badge_number' => 'VIS-JHA-0001', + 'status' => 'checked_in', + 'expected_at' => now()->subHours(1), + 'check_in_at' => now()->subMinutes(50), + ]); + + VisitorLog::create([ + 'tenant_id' => $tenant->id, + 'station_id' => $station->id, + 'visitor_name' => 'Priya Menon', + 'visitor_email' => 'priya.menon@audit.example', + 'visitor_company' => 'External Audit Firm', + 'visit_purpose' => 'Annual compliance audit', + 'badge_number' => 'VIS-PRI-0002', + 'status' => 'checked_out', + 'expected_at' => now()->subHours(4), + 'check_in_at' => now()->subHours(4)->addMinutes(5), + 'check_out_at' => now()->subHour(), + ]); + + VisitorLog::create([ + 'tenant_id' => $tenant->id, + 'station_id' => $station->id, + 'visitor_name' => 'Carlos Rivera', + 'visitor_company' => 'TechSupply Co.', + 'visit_purpose' => 'Equipment delivery', + 'status' => 'expected', + 'expected_at' => now()->addHours(2), + ]); + } +} diff --git a/erp/database/seeders/HRSeeder.php b/erp/database/seeders/HRSeeder.php new file mode 100644 index 00000000000..c877d95c36d --- /dev/null +++ b/erp/database/seeders/HRSeeder.php @@ -0,0 +1,67 @@ + $tenant->id, 'name' => 'Engineering', 'description' => 'Software and hardware development']); + $operations = Department::create(['tenant_id' => $tenant->id, 'name' => 'Operations', 'description' => 'Business operations and logistics']); + $finance = Department::create(['tenant_id' => $tenant->id, 'name' => 'Finance', 'description' => 'Financial management and accounting']); + $hr = Department::create(['tenant_id' => $tenant->id, 'name' => 'Human Resources', 'description' => 'People operations']); + $sales = Department::create(['tenant_id' => $tenant->id, 'name' => 'Sales & Marketing', 'description' => 'Revenue and growth']); + + // Leave Types + $annual = LeaveType::create(['tenant_id' => $tenant->id, 'name' => 'Annual Leave', 'days_per_year' => 20, 'is_paid' => true]); + $sick = LeaveType::create(['tenant_id' => $tenant->id, 'name' => 'Sick Leave', 'days_per_year' => 10, 'is_paid' => true]); + $unpaid = LeaveType::create(['tenant_id' => $tenant->id, 'name' => 'Unpaid Leave', 'days_per_year' => 0, 'is_paid' => false]); + + // Employees + $employees = [ + ['first_name' => 'Alice', 'last_name' => 'Johnson', 'employee_number' => 'EMP-001', 'position' => 'Engineering Manager', 'department_id' => $engineering->id, 'salary_amount' => 9500], + ['first_name' => 'Bob', 'last_name' => 'Williams', 'employee_number' => 'EMP-002', 'position' => 'Senior Developer', 'department_id' => $engineering->id, 'salary_amount' => 7500], + ['first_name' => 'Carol', 'last_name' => 'Davis', 'employee_number' => 'EMP-003', 'position' => 'Financial Controller', 'department_id' => $finance->id, 'salary_amount' => 8000], + ['first_name' => 'David', 'last_name' => 'Brown', 'employee_number' => 'EMP-004', 'position' => 'Operations Coordinator', 'department_id' => $operations->id, 'salary_amount' => 5500], + ['first_name' => 'Emma', 'last_name' => 'Wilson', 'employee_number' => 'EMP-005', 'position' => 'HR Specialist', 'department_id' => $hr->id, 'salary_amount' => 5000], + ['first_name' => 'Frank', 'last_name' => 'Moore', 'employee_number' => 'EMP-006', 'position' => 'Sales Representative', 'department_id' => $sales->id, 'salary_amount' => 4500], + ['first_name' => 'Grace', 'last_name' => 'Taylor', 'employee_number' => 'EMP-007', 'position' => 'Junior Developer', 'department_id' => $engineering->id, 'salary_amount' => 5500], + ]; + + $created = []; + foreach ($employees as $data) { + $created[] = Employee::create([ + 'tenant_id' => $tenant->id, + 'start_date' => now()->subYears(rand(1, 3))->toDateString(), + 'employment_type' => 'full_time', + 'salary_type' => 'monthly', + ...$data, + ]); + } + + // A sample leave request + LeaveRequest::create([ + 'tenant_id' => $tenant->id, + 'employee_id' => $created[1]->id, + 'leave_type_id' => $annual->id, + 'start_date' => now()->addDays(7)->toDateString(), + 'end_date' => now()->addDays(11)->toDateString(), + 'days' => 5, + 'notes' => 'Family vacation', + ]); + } +} diff --git a/erp/database/seeders/HelpdeskSeeder.php b/erp/database/seeders/HelpdeskSeeder.php new file mode 100644 index 00000000000..0a615f9e8c0 --- /dev/null +++ b/erp/database/seeders/HelpdeskSeeder.php @@ -0,0 +1,66 @@ +id ?? 1; + + HelpdeskTicket::create([ + 'tenant_id' => $tenant->id, + 'ticket_number' => 'HD-2026-00001', + 'subject' => 'Cannot log in to the customer portal', + 'description' => 'After the latest update, the customer portal login page throws a 500 error. Multiple users are affected.', + 'type' => 'issue', + 'priority' => 'urgent', + 'status' => 'in_progress', + 'customer_name' => 'Sandra Wells', + 'customer_email' => 's.wells@customer.example', + 'assigned_to' => $userId, + 'created_by' => $userId, + 'sla_deadline' => now()->addHours(4), + ]); + + HelpdeskTicket::create([ + 'tenant_id' => $tenant->id, + 'ticket_number' => 'HD-2026-00002', + 'subject' => 'Request for bulk export of invoices', + 'description' => 'We need to export all invoices from 2024 in PDF format for our year-end audit.', + 'type' => 'feature_request', + 'priority' => 'medium', + 'status' => 'open', + 'customer_name' => 'Finance Team – Acme Corp', + 'customer_email' => 'finance@acme.example', + 'created_by' => $userId, + 'sla_deadline' => now()->addDays(3), + ]); + + HelpdeskTicket::create([ + 'tenant_id' => $tenant->id, + 'ticket_number' => 'HD-2026-00003', + 'subject' => 'How to add a secondary approver in purchase orders?', + 'description' => 'Looking for guidance on configuring a two-level approval workflow for POs above $10,000.', + 'type' => 'question', + 'priority' => 'low', + 'status' => 'resolved', + 'customer_name' => 'David Kim', + 'customer_email' => 'd.kim@globex.example', + 'assigned_to' => $userId, + 'created_by' => $userId, + 'first_response_at' => now()->subDays(1)->subHours(3), + 'resolved_at' => now()->subHours(5), + ]); + } +} diff --git a/erp/database/seeders/InventorySeeder.php b/erp/database/seeders/InventorySeeder.php new file mode 100644 index 00000000000..1a9c5902c6a --- /dev/null +++ b/erp/database/seeders/InventorySeeder.php @@ -0,0 +1,68 @@ + $tenant->id, 'name' => 'Main Warehouse', 'location' => 'Building A, Floor 1']); + $secondary = Warehouse::create(['tenant_id' => $tenant->id, 'name' => 'Secondary Store', 'location' => 'Building B']); + $returns = Warehouse::create(['tenant_id' => $tenant->id, 'name' => 'Returns Depot', 'location' => 'Dock C', 'is_active' => false]); + + // Units of Measure + $pcs = UnitOfMeasure::create(['tenant_id' => $tenant->id, 'name' => 'Pieces', 'abbreviation' => 'pcs']); + $kg = UnitOfMeasure::create(['tenant_id' => $tenant->id, 'name' => 'Kilograms', 'abbreviation' => 'kg']); + $ltr = UnitOfMeasure::create(['tenant_id' => $tenant->id, 'name' => 'Litres', 'abbreviation' => 'ltr']); + + // Categories + $electronics = ProductCategory::create(['tenant_id' => $tenant->id, 'name' => 'Electronics', 'slug' => 'electronics']); + $computers = ProductCategory::create(['tenant_id' => $tenant->id, 'name' => 'Computers', 'slug' => 'computers']); + $peripherals = ProductCategory::create(['tenant_id' => $tenant->id, 'name' => 'Peripherals', 'slug' => 'peripherals']); + $office = ProductCategory::create(['tenant_id' => $tenant->id, 'name' => 'Office Supplies', 'slug' => 'office-supplies']); + $consumables = ProductCategory::create(['tenant_id' => $tenant->id, 'name' => 'Consumables', 'slug' => 'consumables']); + + // Suppliers + $supplier1 = Supplier::create(['tenant_id' => $tenant->id, 'name' => 'TechWorld Distributors', 'contact_person' => 'Alice Nguyen', 'email' => 'alice@techworld.example', 'phone' => '+1-555-0100']); + $supplier2 = Supplier::create(['tenant_id' => $tenant->id, 'name' => 'Office Direct', 'contact_person' => 'Bob Martinez', 'email' => 'bob@officedirect.example','phone' => '+1-555-0200']); + + // Products & Stock + $products = [ + ['sku' => 'LAP-001', 'name' => 'Laptop Pro 15"', 'category_id' => $computers->id, 'uom_id' => $pcs->id, 'cost_price' => 850.00, 'sale_price' => 1299.99, 'reorder_point' => 5], + ['sku' => 'LAP-002', 'name' => 'Laptop Air 13"', 'category_id' => $computers->id, 'uom_id' => $pcs->id, 'cost_price' => 650.00, 'sale_price' => 999.99, 'reorder_point' => 5], + ['sku' => 'MON-001', 'name' => '27" Monitor 4K', 'category_id' => $peripherals->id, 'uom_id' => $pcs->id, 'cost_price' => 280.00, 'sale_price' => 449.99, 'reorder_point' => 3], + ['sku' => 'KBD-001', 'name' => 'Mechanical Keyboard','category_id' => $peripherals->id,'uom_id' => $pcs->id, 'cost_price' => 60.00, 'sale_price' => 119.99, 'reorder_point' => 10], + ['sku' => 'MSE-001', 'name' => 'Wireless Mouse', 'category_id' => $peripherals->id, 'uom_id' => $pcs->id, 'cost_price' => 25.00, 'sale_price' => 49.99, 'reorder_point' => 10], + ['sku' => 'PPR-A4', 'name' => 'A4 Paper Ream', 'category_id' => $office->id, 'uom_id' => $pcs->id, 'cost_price' => 4.50, 'sale_price' => 8.99, 'reorder_point' => 50], + ['sku' => 'PEN-BLK', 'name' => 'Ballpoint Pen Box', 'category_id' => $office->id, 'uom_id' => $pcs->id, 'cost_price' => 3.00, 'sale_price' => 6.50, 'reorder_point' => 20], + ]; + + foreach ($products as $data) { + $product = Product::create([...$data, 'tenant_id' => $tenant->id]); + + StockLevel::create([ + 'tenant_id' => $tenant->id, + 'product_id' => $product->id, + 'warehouse_id' => $main->id, + 'quantity' => rand(10, 100), + ]); + } + } +} diff --git a/erp/database/seeders/KnowledgeBaseSeeder.php b/erp/database/seeders/KnowledgeBaseSeeder.php new file mode 100644 index 00000000000..92ffd19d4a0 --- /dev/null +++ b/erp/database/seeders/KnowledgeBaseSeeder.php @@ -0,0 +1,77 @@ +id ?? 1; + + $gettingStarted = KbCategory::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Getting Started', + 'slug' => 'getting-started', + 'description' => 'Introductory guides for new users.', + 'sequence' => 1, + ]); + + $billing = KbCategory::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Billing & Invoicing', + 'slug' => 'billing-invoicing', + 'description' => 'Everything related to invoices, payments, and billing settings.', + 'sequence' => 2, + ]); + + KbArticle::create([ + 'tenant_id' => $tenant->id, + 'category_id' => $gettingStarted->id, + 'title' => 'How to Set Up Your Account', + 'slug' => 'how-to-set-up-your-account', + 'content' => "## Welcome!\n\nFollow these steps to get started:\n\n1. Complete your company profile.\n2. Invite team members via the Users section.\n3. Configure your fiscal year in Settings.\n4. Import or create your chart of accounts.\n\nFor further assistance, contact our support team.", + 'excerpt' => 'A step-by-step guide to completing your initial account setup.', + 'status' => 'published', + 'author_id' => $userId, + 'published_at' => now()->subDays(30), + 'tags' => ['setup', 'onboarding', 'account'], + ]); + + KbArticle::create([ + 'tenant_id' => $tenant->id, + 'category_id' => $gettingStarted->id, + 'title' => 'Understanding User Roles and Permissions', + 'slug' => 'understanding-user-roles-and-permissions', + 'content' => "## Roles Overview\n\nThe system supports the following built-in roles:\n\n- **Admin** – Full access to all modules and settings.\n- **Manager** – Can approve transactions and view reports.\n- **Staff** – Day-to-day data entry and task management.\n- **Viewer** – Read-only access to assigned modules.\n\nCustom roles can be created under Settings → Roles.", + 'excerpt' => 'Learn about the different user roles and what each one can access.', + 'status' => 'published', + 'author_id' => $userId, + 'published_at' => now()->subDays(25), + 'tags' => ['roles', 'permissions', 'users'], + ]); + + KbArticle::create([ + 'tenant_id' => $tenant->id, + 'category_id' => $billing->id, + 'title' => 'How to Create and Send an Invoice', + 'slug' => 'how-to-create-and-send-an-invoice', + 'content' => "## Creating an Invoice\n\n1. Navigate to **Finance → Invoices** and click **New Invoice**.\n2. Select the customer from the dropdown or enter details manually.\n3. Add line items with descriptions, quantities, and unit prices.\n4. Apply any applicable taxes or discounts.\n5. Click **Save & Send** to email the invoice directly to the customer.\n\n> Tip: Use recurring invoices for subscription-based billing.", + 'excerpt' => 'Step-by-step instructions for creating, customising, and sending invoices to customers.', + 'status' => 'published', + 'author_id' => $userId, + 'published_at' => now()->subDays(20), + 'tags' => ['invoice', 'billing', 'finance'], + ]); + } +} diff --git a/erp/database/seeders/LiveChatSeeder.php b/erp/database/seeders/LiveChatSeeder.php new file mode 100644 index 00000000000..99bf7b37829 --- /dev/null +++ b/erp/database/seeders/LiveChatSeeder.php @@ -0,0 +1,58 @@ +id ?? 1; + + $channel = ChatChannel::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Website Support Chat', + 'widget_color' => '#4F46E5', + 'welcome_message' => 'Hi there! How can we help you today?', + 'offline_message' => 'We\'re currently offline. Please leave a message and we\'ll get back to you shortly.', + 'is_active' => true, + 'assigned_agents' => [$userId], + ]); + + ChatSession::create([ + 'tenant_id' => $tenant->id, + 'channel_id' => $channel->id, + 'visitor_name' => 'Emily Clarke', + 'visitor_email' => 'emily.clarke@prospect.example', + 'source_url' => 'https://www.example.com/pricing', + 'status' => 'resolved', + 'assigned_agent_id' => $userId, + 'rating' => 5, + 'rating_note' => 'Very helpful and quick response!', + 'started_at' => now()->subHours(3), + 'ended_at' => now()->subHours(2)->subMinutes(30), + 'last_message_at' => now()->subHours(2)->subMinutes(32), + ]); + + ChatSession::create([ + 'tenant_id' => $tenant->id, + 'channel_id' => $channel->id, + 'visitor_name' => 'Marcus Tan', + 'visitor_email' => 'marcus.tan@company.example', + 'source_url' => 'https://www.example.com/contact', + 'status' => 'open', + 'started_at' => now()->subMinutes(10), + 'last_message_at' => now()->subMinutes(8), + ]); + } +} diff --git a/erp/database/seeders/LunchSeeder.php b/erp/database/seeders/LunchSeeder.php new file mode 100644 index 00000000000..c7169af5529 --- /dev/null +++ b/erp/database/seeders/LunchSeeder.php @@ -0,0 +1,84 @@ +id ?? 1; + + $supplier = LunchSupplier::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Fresh Bites Catering', + 'address' => '22 Market Street, Downtown', + 'phone' => '+1-555-0760', + 'email' => 'orders@freshbites.example', + 'description' => 'Daily hot meals and healthy lunch boxes delivered to your office.', + 'is_active' => true, + ]); + + $sandwich = LunchProduct::create([ + 'tenant_id' => $tenant->id, + 'lunch_supplier_id' => $supplier->id, + 'name' => 'Club Sandwich Meal', + 'description' => 'Turkey club sandwich with fries and a soft drink.', + 'price' => 12.50, + 'category' => 'sandwiches', + 'is_available' => true, + ]); + + $pasta = LunchProduct::create([ + 'tenant_id' => $tenant->id, + 'lunch_supplier_id' => $supplier->id, + 'name' => 'Grilled Chicken Pasta', + 'description' => 'Penne pasta with grilled chicken, cherry tomatoes, and pesto sauce.', + 'price' => 14.00, + 'category' => 'hot meals', + 'is_available' => true, + ]); + + LunchProduct::create([ + 'tenant_id' => $tenant->id, + 'lunch_supplier_id' => $supplier->id, + 'name' => 'Caesar Salad Bowl', + 'description' => 'Fresh romaine lettuce, croutons, parmesan, and Caesar dressing.', + 'price' => 10.00, + 'category' => 'salads', + 'is_available' => true, + ]); + + LunchOrder::create([ + 'tenant_id' => $tenant->id, + 'employee_id' => $userId, + 'lunch_product_id' => $sandwich->id, + 'quantity' => 1, + 'order_date' => now()->toDateString(), + 'status' => 'confirmed', + 'total_price' => 12.50, + ]); + + LunchOrder::create([ + 'tenant_id' => $tenant->id, + 'employee_id' => $userId, + 'lunch_product_id' => $pasta->id, + 'quantity' => 2, + 'order_date' => now()->toDateString(), + 'status' => 'pending', + 'notes' => 'Extra sauce on the side please.', + 'total_price' => 28.00, + ]); + } +} diff --git a/erp/database/seeders/MaintenanceSeeder.php b/erp/database/seeders/MaintenanceSeeder.php new file mode 100644 index 00000000000..4d0a3531353 --- /dev/null +++ b/erp/database/seeders/MaintenanceSeeder.php @@ -0,0 +1,100 @@ +id ?? 1; + + $compressor = Equipment::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Air Compressor Unit A', + 'code' => 'EQ-COMP-001', + 'category' => 'machinery', + 'location' => 'Production Hall – Bay 3', + 'serial_number' => 'AC-20219-XR', + 'manufacturer' => 'Atlas Copco', + 'model' => 'GA37+', + 'purchase_date' => '2021-06-15', + 'warranty_expiry' => '2024-06-15', + 'status' => 'operational', + ]); + + $hvac = Equipment::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Rooftop HVAC Unit', + 'code' => 'EQ-HVAC-001', + 'category' => 'hvac', + 'location' => 'Rooftop – Zone B', + 'serial_number' => 'HV-88234-ZT', + 'manufacturer' => 'Carrier', + 'model' => '50XC060', + 'purchase_date' => '2020-03-10', + 'warranty_expiry' => '2023-03-10', + 'status' => 'operational', + 'assigned_to' => $userId, + ]); + + $plan = MaintenancePlan::create([ + 'tenant_id' => $tenant->id, + 'equipment_id' => $compressor->id, + 'name' => 'Monthly Compressor Service', + 'frequency' => 'monthly', + 'estimated_duration_hours' => 2.50, + 'description' => 'Check oil levels, replace filters, inspect belts and safety valves.', + 'is_active' => true, + 'last_performed_at' => now()->subMonth(), + 'next_due_at' => now()->addDays(5), + ]); + + MaintenanceOrder::create([ + 'tenant_id' => $tenant->id, + 'equipment_id' => $compressor->id, + 'plan_id' => $plan->id, + 'order_number' => 'MO-00001', + 'type' => 'preventive', + 'priority' => 'medium', + 'status' => 'open', + 'title' => 'Monthly Service – Air Compressor Unit A', + 'description' => 'Perform scheduled monthly service including filter replacement and oil check.', + 'scheduled_date' => now()->addDays(5)->toDateString(), + 'estimated_hours' => 2.50, + 'assigned_to' => $userId, + 'reported_by' => $userId, + ]); + + MaintenanceOrder::create([ + 'tenant_id' => $tenant->id, + 'equipment_id' => $hvac->id, + 'order_number' => 'MO-00002', + 'type' => 'corrective', + 'priority' => 'high', + 'status' => 'completed', + 'title' => 'HVAC Refrigerant Leak Repair', + 'description' => 'Unit reported insufficient cooling. Diagnosed refrigerant leak at evaporator coil joint.', + 'scheduled_date' => now()->subDays(4)->toDateString(), + 'started_at' => now()->subDays(4)->setHour(9), + 'completed_at' => now()->subDays(4)->setHour(13), + 'estimated_hours' => 3.00, + 'actual_hours' => 4.00, + 'assigned_to' => $userId, + 'reported_by' => $userId, + 'cost' => 320.00, + 'resolution' => 'Replaced evaporator coil joint seals and recharged refrigerant to spec. Tested for 30 minutes — no further leaks detected.', + ]); + } +} diff --git a/erp/database/seeders/ManufacturingSeeder.php b/erp/database/seeders/ManufacturingSeeder.php new file mode 100644 index 00000000000..aaa8ed0a402 --- /dev/null +++ b/erp/database/seeders/ManufacturingSeeder.php @@ -0,0 +1,49 @@ +id)->first(); + + if (! $product) { + return; + } + + ManufacturingOrder::create([ + 'tenant_id' => $tenant->id, + 'mo_number' => 'MO-2026-00001', + 'product_id' => $product->id, + 'qty_to_produce' => 100, + 'qty_produced' => 0, + 'status' => 'confirmed', + 'scheduled_date' => '2026-07-01', + 'notes' => 'First production run for Q3.', + ]); + + ManufacturingOrder::create([ + 'tenant_id' => $tenant->id, + 'mo_number' => 'MO-2026-00002', + 'product_id' => $product->id, + 'qty_to_produce' => 250, + 'qty_produced' => 120, + 'status' => 'in_progress', + 'scheduled_date' => '2026-07-15', + 'start_date' => '2026-07-15', + 'notes' => 'Mid-quarter replenishment batch.', + ]); + } +} diff --git a/erp/database/seeders/MarketingSeeder.php b/erp/database/seeders/MarketingSeeder.php new file mode 100644 index 00000000000..0e90f85d1f3 --- /dev/null +++ b/erp/database/seeders/MarketingSeeder.php @@ -0,0 +1,71 @@ + $tenant->id, + 'name' => 'Summer Sale 2026', + 'subject' => 'Exclusive Summer Deals Just for You!', + 'body_html' => '

Summer Sale

Up to 40% off on selected items. Shop now and save big this summer.

', + 'from_name' => 'Acme Store', + 'from_email' => 'noreply@acme.example', + 'status' => 'sent', + ]); + + EmailCampaign::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Product Launch — Widget Pro', + 'subject' => 'Introducing Widget Pro — Available Now', + 'preview_text' => 'The most powerful widget yet is here.', + 'body_html' => '

Widget Pro is Live

Discover our brand-new Widget Pro with advanced features and sleek design.

', + 'from_name' => 'Acme Product Team', + 'from_email' => 'products@acme.example', + 'status' => 'draft', + ]); + + // Mailing list + $list = MailingList::create([ + 'tenant_id' => $tenant->id, + 'name' => 'General Newsletter', + 'description' => 'All subscribers who opted in to receive company updates.', + 'is_active' => true, + ]); + + // Subscribers + $subscribers = [ + ['email' => 'alice@example.com', 'name' => 'Alice Johnson'], + ['email' => 'bob@example.com', 'name' => 'Bob Martinez'], + ['email' => 'carol@example.com', 'name' => 'Carol Smith'], + ]; + + foreach ($subscribers as $data) { + $subscriber = Subscriber::create([ + 'tenant_id' => $tenant->id, + 'email' => $data['email'], + 'name' => $data['name'], + 'status' => 'subscribed', + 'subscribed_at' => now(), + 'source' => 'website', + ]); + + $list->subscribers()->attach($subscriber->id); + } + } +} diff --git a/erp/database/seeders/PlanningSeeder.php b/erp/database/seeders/PlanningSeeder.php new file mode 100644 index 00000000000..01f0435fc0f --- /dev/null +++ b/erp/database/seeders/PlanningSeeder.php @@ -0,0 +1,56 @@ +id)->first(); + + if (! $user) { + return; + } + + Shift::create([ + 'tenant_id' => $tenant->id, + 'employee_id' => $user->id, + 'title' => 'Morning Shift — Warehouse', + 'starts_at' => '2026-07-07 06:00:00', + 'ends_at' => '2026-07-07 14:00:00', + 'break_minutes'=> 30, + 'status' => 'confirmed', + ]); + + Shift::create([ + 'tenant_id' => $tenant->id, + 'employee_id' => $user->id, + 'title' => 'Afternoon Shift — Sales Floor', + 'starts_at' => '2026-07-07 14:00:00', + 'ends_at' => '2026-07-07 22:00:00', + 'break_minutes'=> 30, + 'status' => 'scheduled', + ]); + + Shift::create([ + 'tenant_id' => $tenant->id, + 'employee_id' => $user->id, + 'title' => 'Night Shift — Security', + 'starts_at' => '2026-07-07 22:00:00', + 'ends_at' => '2026-07-08 06:00:00', + 'break_minutes'=> 60, + 'status' => 'scheduled', + ]); + } +} diff --git a/erp/database/seeders/PmSeeder.php b/erp/database/seeders/PmSeeder.php new file mode 100644 index 00000000000..6703b0ca575 --- /dev/null +++ b/erp/database/seeders/PmSeeder.php @@ -0,0 +1,102 @@ + $tenant->id, + 'name' => 'Website Redesign', + 'code' => 'PM-2026-00001', + 'description' => 'Redesign the corporate website with a modern look and improved UX.', + 'status' => 'active', + 'priority' => 'high', + 'budget' => 15000.00, + 'start_date' => '2026-06-01', + 'end_date' => '2026-09-30', + 'client_name' => 'Acme Corp', + ]); + + Task::create([ + 'tenant_id' => $tenant->id, + 'project_id' => $project1->id, + 'title' => 'Gather requirements and create wireframes', + 'status' => 'done', + 'priority' => 'high', + 'due_date' => '2026-06-15', + ]); + + Task::create([ + 'tenant_id' => $tenant->id, + 'project_id' => $project1->id, + 'title' => 'Design mockups for homepage and product pages', + 'status' => 'in_progress', + 'priority' => 'high', + 'due_date' => '2026-07-10', + 'estimated_hours' => 24, + ]); + + Task::create([ + 'tenant_id' => $tenant->id, + 'project_id' => $project1->id, + 'title' => 'Develop and test frontend components', + 'status' => 'todo', + 'priority' => 'medium', + 'due_date' => '2026-08-31', + ]); + + $project2 = Project::create([ + 'tenant_id' => $tenant->id, + 'name' => 'ERP Integration Phase 2', + 'code' => 'PM-2026-00002', + 'description' => 'Integrate the ERP system with third-party logistics and accounting providers.', + 'status' => 'draft', + 'priority' => 'medium', + 'budget' => 30000.00, + 'start_date' => '2026-09-01', + 'end_date' => '2026-12-31', + 'client_name' => 'Internal', + ]); + + Task::create([ + 'tenant_id' => $tenant->id, + 'project_id' => $project2->id, + 'title' => 'Define API contracts with logistics provider', + 'status' => 'todo', + 'priority' => 'high', + 'due_date' => '2026-09-15', + ]); + + Task::create([ + 'tenant_id' => $tenant->id, + 'project_id' => $project2->id, + 'title' => 'Set up staging environment', + 'status' => 'todo', + 'priority' => 'medium', + 'due_date' => '2026-09-20', + ]); + + Task::create([ + 'tenant_id' => $tenant->id, + 'project_id' => $project2->id, + 'title' => 'Build accounting sync module', + 'status' => 'todo', + 'priority' => 'high', + 'due_date' => '2026-11-30', + 'estimated_hours' => 80, + ]); + } +} diff --git a/erp/database/seeders/PosSeeder.php b/erp/database/seeders/PosSeeder.php new file mode 100644 index 00000000000..4911e7d5235 --- /dev/null +++ b/erp/database/seeders/PosSeeder.php @@ -0,0 +1,61 @@ + $tenant->id, + 'name' => 'POS-2026-00001', + 'status' => 'open', + 'opened_at' => now()->startOfDay()->addHours(8), + 'opening_cash' => 200.00, + 'total_sales' => 0, + 'total_refunds'=> 0, + ]); + + PosOrder::create([ + 'tenant_id' => $tenant->id, + 'session_id' => $session->id, + 'receipt_number' => 'REC-2026-00001', + 'customer_name' => 'Walk-in Customer', + 'subtotal' => 85.00, + 'discount_amount'=> 0.00, + 'tax_amount' => 8.50, + 'total' => 93.50, + 'amount_paid' => 100.00, + 'change_given' => 6.50, + 'payment_method' => 'cash', + 'status' => 'completed', + ]); + + PosOrder::create([ + 'tenant_id' => $tenant->id, + 'session_id' => $session->id, + 'receipt_number' => 'REC-2026-00002', + 'customer_name' => 'Jane Doe', + 'customer_email' => 'jane@example.com', + 'subtotal' => 142.00, + 'discount_amount'=> 10.00, + 'tax_amount' => 13.20, + 'total' => 145.20, + 'amount_paid' => 145.20, + 'change_given' => 0.00, + 'payment_method' => 'card', + 'status' => 'completed', + ]); + } +} diff --git a/erp/database/seeders/PurchaseSeeder.php b/erp/database/seeders/PurchaseSeeder.php new file mode 100644 index 00000000000..428b3bc774b --- /dev/null +++ b/erp/database/seeders/PurchaseSeeder.php @@ -0,0 +1,70 @@ + $tenant->id, + 'name' => 'Global Parts Supply Co.', + 'email' => 'orders@globalparts.example', + 'phone' => '+1-800-555-0101', + 'address' => '123 Industrial Way, Chicago, IL 60601', + 'currency' => 'USD', + 'payment_terms' => 'Net 30', + 'is_active' => true, + 'rating' => 4, + ]); + + $vendor2 = PurchaseVendor::create([ + 'tenant_id' => $tenant->id, + 'name' => 'FastShip Logistics Ltd.', + 'email' => 'procurement@fastship.example', + 'phone' => '+1-800-555-0202', + 'address' => '456 Commerce Blvd, Dallas, TX 75201', + 'currency' => 'USD', + 'payment_terms' => 'Net 15', + 'is_active' => true, + 'rating' => 5, + ]); + + $rfq = PurchaseRfq::create([ + 'tenant_id' => $tenant->id, + 'rfq_number' => 'RFQ-2026-00001', + 'po_vendor_id' => $vendor1->id, + 'status' => 'sent', + 'expected_delivery' => '2026-07-20', + 'currency' => 'USD', + 'notes' => 'Requesting quote for Q3 raw material stock.', + 'sent_at' => now(), + ]); + + Po::create([ + 'tenant_id' => $tenant->id, + 'po_number' => 'PO-20260619-0001', + 'po_rfq_id' => $rfq->id, + 'po_vendor_id' => $vendor1->id, + 'status' => 'confirmed', + 'order_date' => '2026-06-19', + 'expected_delivery' => '2026-07-20', + 'currency' => 'USD', + 'total_amount' => 4750.00, + 'notes' => 'Standard Q3 raw material purchase order.', + 'confirmed_at' => now(), + ]); + } +} diff --git a/erp/database/seeders/QualityControlSeeder.php b/erp/database/seeders/QualityControlSeeder.php new file mode 100644 index 00000000000..3fe3419f001 --- /dev/null +++ b/erp/database/seeders/QualityControlSeeder.php @@ -0,0 +1,48 @@ + $tenant->id, + 'name' => 'Incoming Goods Inspection', + 'description' => 'Standard checklist applied to all incoming shipments from vendors.', + 'category' => 'incoming', + 'is_active' => true, + ]); + + QcInspection::create([ + 'tenant_id' => $tenant->id, + 'checklist_id' => $checklist->id, + 'reference_type'=> 'purchase_orders', + 'reference_id' => 1, + 'status' => 'passed', + 'notes' => 'All units within tolerance. Approved for stocking.', + 'started_at' => now()->subDays(5), + 'completed_at' => now()->subDays(5)->addHours(2), + ]); + + QcInspection::create([ + 'tenant_id' => $tenant->id, + 'checklist_id' => $checklist->id, + 'reference_type'=> 'manufacturing_orders', + 'reference_id' => 1, + 'status' => 'pending', + 'notes' => 'Scheduled for end-of-run quality gate.', + ]); + } +} diff --git a/erp/database/seeders/RentalSeeder.php b/erp/database/seeders/RentalSeeder.php new file mode 100644 index 00000000000..9181bed52ee --- /dev/null +++ b/erp/database/seeders/RentalSeeder.php @@ -0,0 +1,67 @@ + $tenant->id, + 'name' => 'Forklift — Model FX-200', + 'description' => 'Electric forklift, 2-ton capacity, suitable for warehouse use.', + 'category' => 'Heavy Equipment', + 'daily_rate' => 120.00, + 'status' => 'rented', + 'serial_number'=> 'FX200-2024-001', + ]); + + $item2 = RentalItem::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Portable Generator — 10kW', + 'description' => 'Diesel generator for temporary power needs on job sites.', + 'category' => 'Power Equipment', + 'daily_rate' => 75.00, + 'status' => 'available', + 'serial_number'=> 'GEN10K-2023-047', + ]); + + RentalAgreement::create([ + 'tenant_id' => $tenant->id, + 'rental_item_id'=> $item1->id, + 'customer_name' => 'BuildRight Construction', + 'customer_email'=> 'rentals@buildright.example', + 'start_date' => '2026-06-10', + 'end_date' => '2026-06-30', + 'daily_rate' => 120.00, + 'deposit' => 500.00, + 'status' => 'active', + 'notes' => 'Forklift needed for warehouse fit-out project.', + ]); + + RentalAgreement::create([ + 'tenant_id' => $tenant->id, + 'rental_item_id'=> $item2->id, + 'customer_name' => 'EventPro Ltd.', + 'customer_email'=> 'ops@eventpro.example', + 'start_date' => '2026-05-20', + 'end_date' => '2026-05-25', + 'daily_rate' => 75.00, + 'deposit' => 200.00, + 'status' => 'returned', + 'notes' => 'Generator for outdoor festival. Returned in good condition.', + 'returned_at' => '2026-05-25 18:00:00', + ]); + } +} diff --git a/erp/database/seeders/RepairsSeeder.php b/erp/database/seeders/RepairsSeeder.php new file mode 100644 index 00000000000..a92bb0f11c7 --- /dev/null +++ b/erp/database/seeders/RepairsSeeder.php @@ -0,0 +1,71 @@ + $tenant->id, + 'order_number' => 'RO-00001', + 'product_name' => 'Dell XPS 15 Laptop', + 'serial_number' => 'DXPS15-2023-00112', + 'status' => 'draft', + 'priority' => 'medium', + 'diagnosis' => null, + 'warranty_claim' => false, + 'scheduled_date' => '2026-07-05', + 'estimated_hours' => 2.50, + 'estimated_cost' => 150.00, + ]); + + // Repair order — confirmed and in progress + RepairOrder::create([ + 'tenant_id' => $tenant->id, + 'order_number' => 'RO-00002', + 'product_name' => 'HP LaserJet Pro Printer', + 'serial_number' => 'HPLJ-MFP-0078', + 'status' => 'in_progress', + 'priority' => 'high', + 'diagnosis' => 'Paper feed roller worn out; toner cartridge leaking.', + 'internal_notes' => 'Customer needs unit back by Friday.', + 'warranty_claim' => true, + 'scheduled_date' => '2026-07-03', + 'started_at' => '2026-07-03 09:00:00', + 'estimated_hours' => 1.50, + 'estimated_cost' => 80.00, + ]); + + // Repair order — completed + RepairOrder::create([ + 'tenant_id' => $tenant->id, + 'order_number' => 'RO-00003', + 'product_name' => 'iPhone 14 Pro', + 'serial_number' => 'IPHN14P-XR-4421', + 'status' => 'done', + 'priority' => 'urgent', + 'diagnosis' => 'Cracked screen and non-functional front camera.', + 'internal_notes' => 'Replaced OLED display assembly and camera module.', + 'warranty_claim' => false, + 'scheduled_date' => '2026-06-28', + 'started_at' => '2026-06-28 10:30:00', + 'completed_at' => '2026-06-28 14:00:00', + 'estimated_hours' => 3.00, + 'actual_hours' => 3.50, + 'estimated_cost' => 250.00, + 'final_cost' => 275.00, + ]); + } +} diff --git a/erp/database/seeders/RolePermissionSeeder.php b/erp/database/seeders/RolePermissionSeeder.php new file mode 100644 index 00000000000..d3bbe7bd589 --- /dev/null +++ b/erp/database/seeders/RolePermissionSeeder.php @@ -0,0 +1,57 @@ + */ + private array $permissions = [ + // Core + 'users.view' => ['super-admin', 'admin'], + 'users.create' => ['super-admin', 'admin'], + 'users.update' => ['super-admin', 'admin'], + 'users.delete' => ['super-admin'], + 'roles.manage' => ['super-admin'], + 'tenants.manage' => ['super-admin'], + 'audit.view' => ['super-admin'], + + // Inventory + 'inventory.view' => ['super-admin', 'admin', 'manager', 'staff'], + 'inventory.create' => ['super-admin', 'admin', 'manager'], + 'inventory.update' => ['super-admin', 'admin', 'manager'], + 'inventory.delete' => ['super-admin', 'admin'], + + // Finance + 'finance.view' => ['super-admin', 'admin', 'manager'], + 'finance.create' => ['super-admin', 'admin'], + 'finance.update' => ['super-admin', 'admin'], + 'finance.delete' => ['super-admin'], + + // HR + 'hr.view' => ['super-admin', 'admin', 'manager', 'staff'], + 'hr.create' => ['super-admin', 'admin', 'manager'], + 'hr.update' => ['super-admin', 'admin', 'manager'], + 'hr.delete' => ['super-admin', 'admin'], + ]; + + public function run(): void + { + $roles = ['super-admin', 'admin', 'manager', 'staff']; + + foreach ($roles as $roleName) { + Role::firstOrCreate(['name' => $roleName, 'guard_name' => 'web']); + } + + foreach ($this->permissions as $permName => $assignedRoles) { + $permission = Permission::firstOrCreate(['name' => $permName, 'guard_name' => 'web']); + + foreach ($assignedRoles as $roleName) { + Role::findByName($roleName, 'web')->givePermissionTo($permission); + } + } + } +} diff --git a/erp/database/seeders/SignSeeder.php b/erp/database/seeders/SignSeeder.php new file mode 100644 index 00000000000..90f1250d8fe --- /dev/null +++ b/erp/database/seeders/SignSeeder.php @@ -0,0 +1,40 @@ + $tenant->id, + 'title' => 'Non-Disclosure Agreement — Acme Corp Partnership', + 'document_path' => 'sign-documents/nda-acme-corp-2026.pdf', + 'document_name' => 'NDA_Acme_Corp_2026.pdf', + 'status' => 'sent', + 'message' => 'Please review and sign the attached NDA before our onboarding call on 10 July.', + ]); + + // Service contract — completed + SignRequest::create([ + 'tenant_id' => $tenant->id, + 'title' => 'Annual Maintenance Service Contract', + 'document_path' => 'sign-documents/maintenance-contract-2026.pdf', + 'document_name' => 'Maintenance_Contract_2026.pdf', + 'status' => 'completed', + 'message' => 'Kindly sign the annual maintenance agreement to activate support coverage.', + 'completed_at' => '2026-06-15 11:45:00', + ]); + } +} diff --git a/erp/database/seeders/SocialMarketingSeeder.php b/erp/database/seeders/SocialMarketingSeeder.php new file mode 100644 index 00000000000..a1cde6b3e02 --- /dev/null +++ b/erp/database/seeders/SocialMarketingSeeder.php @@ -0,0 +1,75 @@ + $tenant->id, + 'platform' => 'linkedin', + 'account_name' => 'Acme ERP Solutions', + 'account_handle' => 'acme-erp-solutions', + 'is_connected' => true, + 'is_active' => true, + 'followers_count' => 3420, + 'following_count' => 180, + 'last_synced_at' => '2026-06-19 08:00:00', + ]); + + $instagram = SocialAccount::create([ + 'tenant_id' => $tenant->id, + 'platform' => 'instagram', + 'account_name' => 'Acme ERP', + 'account_handle' => '@acmeerp', + 'is_connected' => true, + 'is_active' => true, + 'followers_count' => 1850, + 'following_count' => 310, + 'last_synced_at' => '2026-06-19 08:00:00', + ]); + + // Social posts + SocialPost::create([ + 'tenant_id' => $tenant->id, + 'content' => "Excited to announce our new Repairs module — track every repair order from diagnosis to delivery, all in one place. #ERP #RepairManagement", + 'platforms' => ['linkedin'], + 'social_account_ids' => [$linkedin->id], + 'status' => 'published', + 'published_at' => '2026-06-10 10:00:00', + 'metrics' => ['likes' => 94, 'shares' => 22, 'comments' => 11, 'reach' => 1800], + ]); + + SocialPost::create([ + 'tenant_id' => $tenant->id, + 'content' => "Did you know our Subscriptions module lets you manage billing cycles, trial periods, and renewals automatically? DM us for a demo! 🚀 #SaaS #Subscriptions", + 'platforms' => ['instagram'], + 'social_account_ids' => [$instagram->id], + 'status' => 'published', + 'published_at' => '2026-06-14 14:30:00', + 'metrics' => ['likes' => 210, 'shares' => 35, 'comments' => 18, 'reach' => 3200], + ]); + + SocialPost::create([ + 'tenant_id' => $tenant->id, + 'content' => "Summer update dropping soon — new Survey builder, Website CMS, and Sign module all in one release. Stay tuned! #ProductUpdate", + 'platforms' => ['linkedin', 'instagram'], + 'social_account_ids' => [$linkedin->id, $instagram->id], + 'status' => 'scheduled', + 'scheduled_at' => '2026-07-01 09:00:00', + ]); + } +} diff --git a/erp/database/seeders/SubcontractingSeeder.php b/erp/database/seeders/SubcontractingSeeder.php new file mode 100644 index 00000000000..5dd572ee547 --- /dev/null +++ b/erp/database/seeders/SubcontractingSeeder.php @@ -0,0 +1,43 @@ + $tenant->id, + 'reference' => 'SUB-2026-0001', + 'status' => 'sent', + 'finished_product' => 'Custom Steel Bracket Assembly', + 'finished_qty' => 200.00, + 'unit_price' => 12.50, + 'notes' => 'ISO 9001-compliant finish required. Delivery by 15 July 2026.', + 'sent_at' => '2026-06-18 09:30:00', + ]); + + // Subcontract order — in production + SubcontractOrder::create([ + 'tenant_id' => $tenant->id, + 'reference' => 'SUB-2026-0002', + 'status' => 'in_progress', + 'finished_product' => 'Injection-Moulded Casing (Type B)', + 'finished_qty' => 500.00, + 'unit_price' => 4.75, + 'notes' => 'Black ABS plastic, wall thickness 2 mm. Packaging: 50 units per box.', + 'sent_at' => '2026-06-10 11:00:00', + ]); + } +} diff --git a/erp/database/seeders/SubscriptionsSeeder.php b/erp/database/seeders/SubscriptionsSeeder.php new file mode 100644 index 00000000000..11f7e632bad --- /dev/null +++ b/erp/database/seeders/SubscriptionsSeeder.php @@ -0,0 +1,64 @@ + $tenant->id, + 'name' => 'Starter', + 'description' => 'Perfect for small teams — core ERP modules with up to 5 users.', + 'billing_cycle' => 'monthly', + 'price' => 49.00, + 'trial_days' => 14, + 'is_active' => true, + ]); + + $professional = SubscriptionPlan::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Professional', + 'description' => 'Full-featured ERP suite for growing businesses — unlimited users, priority support.', + 'billing_cycle' => 'annual', + 'price' => 499.00, + 'trial_days' => 30, + 'is_active' => true, + ]); + + // Subscriptions + Subscription::create([ + 'tenant_id' => $tenant->id, + 'plan_id' => $starter->id, + 'customer_name' => 'Bright Horizons Ltd', + 'customer_email' => 'billing@brighthorizons.example', + 'status' => 'active', + 'current_period_start' => '2026-06-01', + 'current_period_end' => '2026-06-30', + ]); + + Subscription::create([ + 'tenant_id' => $tenant->id, + 'plan_id' => $professional->id, + 'customer_name' => 'Nexus Manufacturing Inc', + 'customer_email' => 'accounts@nexusmfg.example', + 'status' => 'trial', + 'trial_ends_at' => '2026-07-19 23:59:59', + 'current_period_start' => '2026-06-19', + 'current_period_end' => '2027-06-18', + 'notes' => 'Migrating from legacy ERP — trial extended by request.', + ]); + } +} diff --git a/erp/database/seeders/SurveySeeder.php b/erp/database/seeders/SurveySeeder.php new file mode 100644 index 00000000000..34574094c9b --- /dev/null +++ b/erp/database/seeders/SurveySeeder.php @@ -0,0 +1,69 @@ + $tenant->id, + 'title' => 'Customer Satisfaction Survey — Q2 2026', + 'description' => 'Help us improve by sharing your experience with our products and support team.', + 'status' => 'published', + 'starts_at' => '2026-06-01 00:00:00', + 'ends_at' => '2026-06-30 23:59:59', + ]); + + // 3 questions for survey 1 + SurveyQuestion::create([ + 'survey_id' => $satisfaction->id, + 'tenant_id' => $tenant->id, + 'question_text' => 'How satisfied are you with our product overall?', + 'question_type' => 'rating', + 'is_required' => true, + 'sequence' => 1, + 'options' => null, + ]); + + SurveyQuestion::create([ + 'survey_id' => $satisfaction->id, + 'tenant_id' => $tenant->id, + 'question_text' => 'Which features do you use most frequently?', + 'question_type' => 'multiple_choice', + 'is_required' => false, + 'sequence' => 2, + 'options' => ['Inventory', 'Finance', 'CRM', 'HR', 'Projects', 'Repairs', 'Subscriptions'], + ]); + + SurveyQuestion::create([ + 'survey_id' => $satisfaction->id, + 'tenant_id' => $tenant->id, + 'question_text' => 'Would you recommend our ERP to a colleague or business partner?', + 'question_type' => 'yes_no', + 'is_required' => true, + 'sequence' => 3, + 'options' => null, + ]); + + // Survey 2 — draft onboarding feedback survey + Survey::create([ + 'tenant_id' => $tenant->id, + 'title' => 'Onboarding Experience Feedback', + 'description' => 'We would love to know how your onboarding went and where we can do better.', + 'status' => 'draft', + ]); + } +} diff --git a/erp/database/seeders/WebsiteSeeder.php b/erp/database/seeders/WebsiteSeeder.php new file mode 100644 index 00000000000..4b6e9a5e55c --- /dev/null +++ b/erp/database/seeders/WebsiteSeeder.php @@ -0,0 +1,84 @@ + $tenant->id, + 'title' => 'Home', + 'slug' => 'home', + 'content' => '

Welcome to Acme ERP

The all-in-one business management platform built for growing companies.

', + 'meta_title' => 'Acme ERP — Business Management Software', + 'meta_description' => 'Manage inventory, finance, HR, projects, and more from a single platform.', + 'status' => 'published', + 'published_at' => '2026-01-01 00:00:00', + 'is_homepage' => true, + 'layout' => 'default', + ]); + + WebPage::create([ + 'tenant_id' => $tenant->id, + 'title' => 'Features', + 'slug' => 'features', + 'content' => '

Everything your business needs

From finance and inventory to HR, repairs, and subscriptions — Acme ERP covers every department.

', + 'meta_title' => 'Features — Acme ERP', + 'meta_description' => 'Explore the full feature set of Acme ERP: finance, inventory, CRM, HR, projects, repairs, subscriptions, and more.', + 'status' => 'published', + 'published_at' => '2026-01-15 00:00:00', + 'is_homepage' => false, + 'layout' => 'default', + ]); + + WebPage::create([ + 'tenant_id' => $tenant->id, + 'title' => 'Contact Us', + 'slug' => 'contact', + 'content' => '

Get in touch

Our team is available Monday–Friday, 9 am–6 pm. Fill in the form and we will get back to you within 24 hours.

', + 'meta_title' => 'Contact — Acme ERP', + 'meta_description' => 'Contact the Acme ERP support team for demos, pricing, or general enquiries.', + 'status' => 'draft', + 'is_homepage' => false, + 'layout' => 'default', + ]); + + // Blog posts + BlogPost::create([ + 'tenant_id' => $tenant->id, + 'title' => '5 Ways an ERP System Saves Your Finance Team Hours Every Week', + 'slug' => '5-ways-erp-saves-finance-team-hours', + 'excerpt' => 'Discover how automating accounts payable, bank reconciliation, and financial reporting frees your finance team to focus on what matters.', + 'content' => '

Finance teams in growing businesses often spend an enormous amount of time on manual data entry, chasing approvals, and reconciling accounts. An integrated ERP system eliminates these bottlenecks.

Here are five concrete ways the right ERP saves your team hours every single week...

', + 'status' => 'published', + 'published_at' => '2026-05-20 09:00:00', + 'tags' => ['finance', 'productivity', 'automation', 'erp'], + 'view_count' => 348, + ]); + + BlogPost::create([ + 'tenant_id' => $tenant->id, + 'title' => 'How to Manage Subcontracting Without Losing Visibility', + 'slug' => 'manage-subcontracting-without-losing-visibility', + 'excerpt' => 'Subcontracting adds complexity to your supply chain. Learn how a dedicated subcontracting module keeps you in control from order to receipt.', + 'content' => '

When you outsource part of your production to a third-party manufacturer, it can feel like you are flying blind. Components go out and finished goods come back — but what happens in between?

A subcontracting module built into your ERP changes that completely...

', + 'status' => 'published', + 'published_at' => '2026-06-05 10:00:00', + 'tags' => ['subcontracting', 'supply-chain', 'manufacturing', 'erp'], + 'view_count' => 175, + ]); + } +} diff --git a/erp/package-lock.json b/erp/package-lock.json new file mode 100644 index 00000000000..2b17777630d --- /dev/null +++ b/erp/package-lock.json @@ -0,0 +1,4937 @@ +{ + "name": "erp", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "@tiptap/extension-character-count": "^3.27.0", + "@tiptap/extension-placeholder": "^3.27.0", + "@tiptap/react": "^3.27.0", + "@tiptap/starter-kit": "^3.27.0", + "recharts": "^3.8.1" + }, + "devDependencies": { + "@headlessui/react": "^2.0.0", + "@inertiajs/react": "^2.0.0", + "@tailwindcss/forms": "^0.5.3", + "@types/node": "^22.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.2.0", + "autoprefixer": "^10.4.12", + "axios": "^1.6.4", + "concurrently": "^9.0.1", + "laravel-vite-plugin": "^1.0", + "postcss": "^8.4.31", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "tailwindcss": "^3.2.1", + "typescript": "^5.0.2", + "vite": "^6.0.0", + "ziggy-js": "^2.3.0" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", + "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/react": { + "version": "0.26.28", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.28.tgz", + "integrity": "sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.1.2", + "@floating-ui/utils": "^0.2.8", + "tabbable": "^6.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@headlessui/react": { + "version": "2.2.10", + "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-2.2.10.tgz", + "integrity": "sha512-5pVLNK9wlpxTUTy9GpgbX/SdcRh+HBnPktjM2wbiLTH4p+2EPHBO1aoSryUCuKUIItdDWO9ITlhUL8UnUN/oIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/react": "^0.26.16", + "@react-aria/focus": "^3.20.2", + "@react-aria/interactions": "^3.25.0", + "@tanstack/react-virtual": "^3.13.9", + "use-sync-external-store": "^1.5.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + } + }, + "node_modules/@inertiajs/core": { + "version": "2.3.24", + "resolved": "https://registry.npmjs.org/@inertiajs/core/-/core-2.3.24.tgz", + "integrity": "sha512-xAlUl5+RKtdbutEgsmdWa6HmnvjIGcWTrvfLj/3Icy3/7bSH3aiI+kuYPs17LBq/SMaXnqBZXXo094rEXUv2aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/lodash-es": "^4.17.12", + "axios": "^1.13.5", + "laravel-precognition": "^1.0.2", + "lodash-es": "^4.18.1", + "qs": "^6.15.0" + } + }, + "node_modules/@inertiajs/react": { + "version": "2.3.24", + "resolved": "https://registry.npmjs.org/@inertiajs/react/-/react-2.3.24.tgz", + "integrity": "sha512-PtzuoL66QlxpLgV8j8l9a7SIkVtFVFetrIHQjfemNk8Ey9C7IhacE7b4PKgx5cI77ftJBwEfNrj2+AhOvFpgpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inertiajs/core": "2.3.24", + "@types/lodash-es": "^4.17.12", + "laravel-precognition": "^1.0.2", + "lodash-es": "^4.18.1" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.9.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@internationalized/date": { + "version": "3.12.1", + "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.1.tgz", + "integrity": "sha512-6IedsVWXyq4P9Tj+TxuU8WGWM70hYLl12nbYU8jkikVpa6WXapFazPUcHUMDMoWftIDE2ILDkFFte6W2nFCkRQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, + "node_modules/@internationalized/number": { + "version": "3.6.6", + "resolved": "https://registry.npmjs.org/@internationalized/number/-/number-3.6.6.tgz", + "integrity": "sha512-iFgmQaXHE0vytNfpLZWOC2mEJCBRzcUxt53Xf/yCXG93lRvqas237i3r7X4RKMwO3txiyZD4mQjKAByFv6UGSQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, + "node_modules/@internationalized/string": { + "version": "3.2.8", + "resolved": "https://registry.npmjs.org/@internationalized/string/-/string-3.2.8.tgz", + "integrity": "sha512-NdbMQUSfXLYIQol5VyMtinm9pZDciiMfN7RtmSuSB78io1hqwJ0naYfxyW6vgxWBkzWymQa/3uLDlbfmshtCaA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@react-aria/focus": { + "version": "3.22.0", + "resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.22.0.tgz", + "integrity": "sha512-ZfDOVuVhqDsM9mkNji3QUZ/d40JhlVgXrDkrfXylM1035QCrcTHN7m2DpbE95sU2A8EQb4wikvt5jM6K/73BPg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0", + "react-aria": "3.48.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/interactions": { + "version": "3.28.0", + "resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.28.0.tgz", + "integrity": "sha512-OXwdU1EWFdMxmr/K1CXNGJzmNlCClByb+PuCaqUyzBymHPCGVhawirLIon/CrIN5psh3AiWpHSh4H0WeJdVpng==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@react-types/shared": "^3.34.0", + "@swc/helpers": "^0.5.0", + "react-aria": "3.48.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-types/shared": { + "version": "3.34.0", + "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.34.0.tgz", + "integrity": "sha512-gp6xo/s2lX54AlTjOiqwDnxA7UW79BNvI9dB9pr3LZTzRKCd1ZA+ZbgKw/ReIiWuvvVw/8QFJpnqeeFyLocMcQ==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@reduxjs/toolkit": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", + "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.8", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz", + "integrity": "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, + "node_modules/@swc/helpers": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.21.tgz", + "integrity": "sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tailwindcss/forms": { + "version": "0.5.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.11.tgz", + "integrity": "sha512-h9wegbZDPurxG22xZSoWtdzc41/OlNEUQERNqI/0fOwa2aVlWGu7C35E/x6LDyD3lgtztFSSjKZyuVM0hxhbgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mini-svg-data-uri": "^1.2.3" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1" + } + }, + "node_modules/@tanstack/react-virtual": { + "version": "3.13.25", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.25.tgz", + "integrity": "sha512-bmNoqMu6gcAW9JGrKVB0Q1tN1i5RONZF8r1fW0bbE4Oyf3DwEGnzzQJ2OW+Ozg1P4s8PyugkHg2ULZoFQN+cqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.15.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.15.0.tgz", + "integrity": "sha512-0AwPGx0I8QxPYjAxShT/+z+ZOe9u8mW5rsXvivCTjRfRmz9a43+3mRyi4wwlyoUqOC56q/jatKa0Bh9M99BEHQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tiptap/core": { + "version": "3.27.0", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.27.0.tgz", + "integrity": "sha512-X53TQUq2xYn21FOC526GlVIycnDkiN9XPYO/NEsg3hXS/SUs1Q6ZLtaM8y3Ox7d+bnTW6CpWKlfyvUWp3scKEw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/pm": "3.27.0" + } + }, + "node_modules/@tiptap/extension-blockquote": { + "version": "3.27.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.27.0.tgz", + "integrity": "sha512-xQvy+n1m4oxv/QLjWdg+FrrKpkyQqU1qbLjVQTdQP/kczqvaJhjzbJFBc38K0vvzMUvBKH0x6sr3GQVl4D84wg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.0" + } + }, + "node_modules/@tiptap/extension-bold": { + "version": "3.27.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.27.0.tgz", + "integrity": "sha512-GbnnDVbChgp9KWv+6EBuNNYEvNVbtLPYXPm809DQPi7rvb2SI/LOzLgWgVKByQqhoh498WWVBhsg/pvQ9Va9tw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.0" + } + }, + "node_modules/@tiptap/extension-bubble-menu": { + "version": "3.27.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.27.0.tgz", + "integrity": "sha512-cy52iePYfzaUUBg3S+00KN+18KMZH+UIlE4wRTEBW/c7OA6M/nkrVkxHYfKeJ3OEbZsRYv+GiRzASchDJVVTZg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@floating-ui/dom": "^1.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.0", + "@tiptap/pm": "3.27.0" + } + }, + "node_modules/@tiptap/extension-bullet-list": { + "version": "3.27.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.27.0.tgz", + "integrity": "sha512-86x8y0IzhOnRLnq5P9eTE/kgs6j383fTME+h0uNfOFfkQmzR4GLph8nkdGraormzI3+GKQUQaO4k/6bE+PSVpg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.27.0" + } + }, + "node_modules/@tiptap/extension-character-count": { + "version": "3.27.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-character-count/-/extension-character-count-3.27.0.tgz", + "integrity": "sha512-42SsBVYum5ZaejmHsIwbJxrPC+E3HHJ1JOein73ncjGrvAlG6Yb7tzLB/qlL8NMg0BPde2DySBy7sI6zoUNnUA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "3.27.0" + } + }, + "node_modules/@tiptap/extension-code": { + "version": "3.27.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.27.0.tgz", + "integrity": "sha512-lKJqy75GRxiNncaaBs9lutsEuVQR7AKZGFfy9Sg2nRTweWLpD6eNhSHI4N04EJvTIjxZjHAe9fGU50ZhS2ONiA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.0" + } + }, + "node_modules/@tiptap/extension-code-block": { + "version": "3.27.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.27.0.tgz", + "integrity": "sha512-t4cQ093ZRumHG954WKwAVYoZC6bkDrZCP9uqjHWUe1wBMzhqAWQ1OuHfaHpMnzsrdtm/svDO9rB26zBTQ0yZ/Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.0", + "@tiptap/pm": "3.27.0" + } + }, + "node_modules/@tiptap/extension-document": { + "version": "3.27.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.27.0.tgz", + "integrity": "sha512-xE+rUAPAA+65Usxbn5OoPVh0I0FSPz5dYprj+uo1mogPgqpcPLGVNMMoRLZ4WdiZ3I451d5+U7CUynIjD/iikw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.0" + } + }, + "node_modules/@tiptap/extension-dropcursor": { + "version": "3.27.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.27.0.tgz", + "integrity": "sha512-unWa4Fs2p081Dh/VC9rShby6MqVuom5FdOuLa3JKio6PufsT+Arb87yP6bdsiyUHmWb8ZXXGtxzlCTEt/bm/TA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "3.27.0" + } + }, + "node_modules/@tiptap/extension-floating-menu": { + "version": "3.27.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.27.0.tgz", + "integrity": "sha512-S9NV/3f1IFkPtIUClLUsJj98AOL3PrYMhPAH/b7SFtsvvfp2QXfL+I12ykPqOcPmj4KXw0Zc40E0QKg67M3pjw==", + "license": "MIT", + "optional": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@floating-ui/dom": "^1.0.0", + "@tiptap/core": "3.27.0", + "@tiptap/pm": "3.27.0" + } + }, + "node_modules/@tiptap/extension-gapcursor": { + "version": "3.27.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.27.0.tgz", + "integrity": "sha512-tHIUQmtebBytVpd2f5oCUMAivdN5Yj8zRDpkA5uT3x38s9OdXLjLLYZHDD7b0ANQnN0r1vkxPXi9YF6+2XWZvA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "3.27.0" + } + }, + "node_modules/@tiptap/extension-hard-break": { + "version": "3.27.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.27.0.tgz", + "integrity": "sha512-/DDTnvuW1/3qGfX5VKEFwTDHV1/ks5jMFXgMPlHXb/ASo+kb4HEohzuUYcO74Y3vtU2zENWJ2UBqsJ96TkjgOA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.0" + } + }, + "node_modules/@tiptap/extension-heading": { + "version": "3.27.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.27.0.tgz", + "integrity": "sha512-KDN/8RyL3/VoXpSzRtNQ3hsreBUbHgMGmpqJpRvbykQ6wJ/W421uHfZR/hCsB3RNRB0kHZJdw4hDLfP73Ff6Ig==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.0" + } + }, + "node_modules/@tiptap/extension-horizontal-rule": { + "version": "3.27.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.27.0.tgz", + "integrity": "sha512-04Xga9CqIqzKb1cqDk9AV9pTbtleqF+o8X3bb3n7HDgplYHxLjHId6RCzhfSZU6U8VZZF/RJQ1jbTebeYqGSBw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.0", + "@tiptap/pm": "3.27.0" + } + }, + "node_modules/@tiptap/extension-italic": { + "version": "3.27.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.27.0.tgz", + "integrity": "sha512-z/EomYhfaf9oxtYz4+yxaIO6cw9Sa2CVw7jsnGR0jf0CZk6B+5iTbNeJDMxpSeYg+dAPOWYiO8vWOeKM0gYPFQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.0" + } + }, + "node_modules/@tiptap/extension-link": { + "version": "3.27.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.27.0.tgz", + "integrity": "sha512-c0fxb1cccM/1JNk7HOoJytKJ9kcpKPeGq86FNHQkqf+Bn+IzPgLb8Q3W89lOp33NlCOx2xiSrUFMBWcPX34z8A==", + "license": "MIT", + "dependencies": { + "linkifyjs": "^4.3.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.0", + "@tiptap/pm": "3.27.0" + } + }, + "node_modules/@tiptap/extension-list": { + "version": "3.27.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.27.0.tgz", + "integrity": "sha512-27L9zP3SeKxW8DBfXcJtVnlfW6lJr6xm8C/WJ/iSlts/JHv1GwWwE7FWErmpkGeX6FwLnuaZbTMbfyyTlm4Dtg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.0", + "@tiptap/pm": "3.27.0" + } + }, + "node_modules/@tiptap/extension-list-item": { + "version": "3.27.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.27.0.tgz", + "integrity": "sha512-e2yb7Vjh5YV2ELEwxAu3EQr4fKJPwfAWAgbZbUuEOh8FX8R+bnJyBW9oWwxnivwPsxVPtYseWuKNEJb7wW+c8w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.27.0" + } + }, + "node_modules/@tiptap/extension-list-keymap": { + "version": "3.27.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.27.0.tgz", + "integrity": "sha512-GV8miiDOTjSduesrO4arzC7F5T78vCy0YeVmtegtA2u5w8RBjKx3sRbS45IBF08bf4GRMRGbpGeLnqe/O0rZHQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.27.0" + } + }, + "node_modules/@tiptap/extension-ordered-list": { + "version": "3.27.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.27.0.tgz", + "integrity": "sha512-hiI8xcAPGOgCny8cg2md708adIdjIe2C2nNh/6TROVGfpm/tAWK13jwE1sgY+eLZTsILqpBLsEUsdF9zoS0XqQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.27.0" + } + }, + "node_modules/@tiptap/extension-paragraph": { + "version": "3.27.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.27.0.tgz", + "integrity": "sha512-miy096NqJJwD3ulGnOleXEIYP+pOICd5A9M3Zqy1Jpc7mw0+/YMZeR5JAXqdsYV3eq/pgYpykKlF4k+Gi84RYw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.0" + } + }, + "node_modules/@tiptap/extension-placeholder": { + "version": "3.27.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-3.27.0.tgz", + "integrity": "sha512-lT/wUype2OLoTPuSrWTZQJxcfJZfnBYhPmMAPk0NkGp9V5xml5m4dQ6W0+Cz1CW5lglshvn8c8C9rWs70XCHvA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "3.27.0" + } + }, + "node_modules/@tiptap/extension-strike": { + "version": "3.27.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.27.0.tgz", + "integrity": "sha512-srDgYCxu2OdYGB8rtJraYzrXCvj5qRmyrSG4NObVfDYNoMuVdkbNlxA8/LNJeKKZ9H8PZlrspvmxM4F91Lvb9g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.0" + } + }, + "node_modules/@tiptap/extension-text": { + "version": "3.27.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.27.0.tgz", + "integrity": "sha512-3ZiEeDDbvhvXwqSre62yxhe0Rc7t2+BGSNoVCCUFPsNUNvBL9s9bOwwnOG4fPZNBCyF38qBnTxblDwJ92E4AGw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.0" + } + }, + "node_modules/@tiptap/extension-underline": { + "version": "3.27.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.27.0.tgz", + "integrity": "sha512-0w3GDF5rC0oZXYh6jBt8jPS+SQ5mtJsyDjP94A0Qjh/7tsfeDzCzZMAadE4i+9g6DI1Hce4GqYDAAfPkoj4y5Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.0" + } + }, + "node_modules/@tiptap/extensions": { + "version": "3.27.0", + "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.27.0.tgz", + "integrity": "sha512-G4ufNa/F4upDI/2YWFBCVBs4skcCeQ2jvRQiFrLJCitUFcl03evP0znAvx5JKj9+nfm4ILYpPpncEFyMq4mFEg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.27.0", + "@tiptap/pm": "3.27.0" + } + }, + "node_modules/@tiptap/pm": { + "version": "3.27.0", + "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.27.0.tgz", + "integrity": "sha512-cWuyaY19SoTOGwdPzhNrUuFMMILuR7mq/Ec02FO+y5jUnQiJYOy9pVx4RUIjBT24LYXrvA9YRhxL1v+KQKTz3A==", + "license": "MIT", + "dependencies": { + "prosemirror-changeset": "^2.3.0", + "prosemirror-commands": "^1.6.2", + "prosemirror-dropcursor": "^1.8.1", + "prosemirror-gapcursor": "^1.3.2", + "prosemirror-history": "^1.4.1", + "prosemirror-inputrules": "^1.4.0", + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.7", + "prosemirror-schema-list": "^1.5.0", + "prosemirror-state": "^1.4.4", + "prosemirror-tables": "^1.8.0", + "prosemirror-transform": "^1.12.0", + "prosemirror-view": "^1.41.8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@tiptap/react": { + "version": "3.27.0", + "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.27.0.tgz", + "integrity": "sha512-C7FSa5yNPKaCll/Bvm0GmlJh2VLVJXNpKxC9I/L5BJdeVWXV3Prbt56U9FFYd8hjydFZ32GF3jUYQlVYHxnwHw==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "fast-equals": "^5.3.3", + "use-sync-external-store": "^1.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "optionalDependencies": { + "@tiptap/extension-bubble-menu": "^3.27.0", + "@tiptap/extension-floating-menu": "^3.27.0" + }, + "peerDependencies": { + "@tiptap/core": "3.27.0", + "@tiptap/pm": "3.27.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tiptap/starter-kit": { + "version": "3.27.0", + "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.27.0.tgz", + "integrity": "sha512-AFrNuIpeyr2FJsWP5sjD0uhJfPBHdEKBByrqLu4O9h/OI8qJYKaPo9PxMXA2jQlWgDq6z0r8GPrr3PbJCMe1JA==", + "license": "MIT", + "dependencies": { + "@tiptap/core": "^3.27.0", + "@tiptap/extension-blockquote": "^3.27.0", + "@tiptap/extension-bold": "^3.27.0", + "@tiptap/extension-bullet-list": "^3.27.0", + "@tiptap/extension-code": "^3.27.0", + "@tiptap/extension-code-block": "^3.27.0", + "@tiptap/extension-document": "^3.27.0", + "@tiptap/extension-dropcursor": "^3.27.0", + "@tiptap/extension-gapcursor": "^3.27.0", + "@tiptap/extension-hard-break": "^3.27.0", + "@tiptap/extension-heading": "^3.27.0", + "@tiptap/extension-horizontal-rule": "^3.27.0", + "@tiptap/extension-italic": "^3.27.0", + "@tiptap/extension-link": "^3.27.0", + "@tiptap/extension-list": "^3.27.0", + "@tiptap/extension-list-item": "^3.27.0", + "@tiptap/extension-list-keymap": "^3.27.0", + "@tiptap/extension-ordered-list": "^3.27.0", + "@tiptap/extension-paragraph": "^3.27.0", + "@tiptap/extension-strike": "^3.27.0", + "@tiptap/extension-text": "^3.27.0", + "@tiptap/extension-underline": "^3.27.0", + "@tiptap/extensions": "^3.27.0", + "@tiptap/pm": "^3.27.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/node": { + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.15", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", + "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axios": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", + "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.32", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz", + "integrity": "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/concurrently": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", + "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "4.1.2", + "rxjs": "7.8.2", + "shell-quote": "1.8.3", + "supports-color": "8.1.1", + "tree-kill": "1.2.2", + "yargs": "17.7.2" + }, + "bin": { + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.361", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.361.tgz", + "integrity": "sha512-Q6Hts7N9FnJc5LeGRINFvLhCI9xZmNtTDe5ZbcVezQz7cU4a8Aua3GH1b8J2XY8Al9PF+OCwYqhgsOOheMdvkA==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-toolkit": { + "version": "1.47.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.47.0.tgz", + "integrity": "sha512-n1GuoD0WEQZMBk5tttoZSqwgyLx01oqa5XsBmCHwPyNe1S9jPBEmtR2pSgp2kJuWE3ciFZ6yRHmY4pM4C3OOkw==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/fast-equals": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", + "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/laravel-precognition": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/laravel-precognition/-/laravel-precognition-1.0.2.tgz", + "integrity": "sha512-0H08JDdMWONrL/N314fvsO3FATJwGGlFKGkMF3nNmizVFJaWs17816iM+sX7Rp8d5hUjYCx6WLfsehSKfaTxjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "axios": "^1.4.0", + "lodash-es": "^4.17.21" + } + }, + "node_modules/laravel-vite-plugin": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-1.3.0.tgz", + "integrity": "sha512-P5qyG56YbYxM8OuYmK2OkhcKe0AksNVJUjq9LUZ5tOekU9fBn9LujYyctI4t9XoLjuMvHJXXpCoPntY1oKltuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picocolors": "^1.0.0", + "vite-plugin-full-reload": "^1.1.0" + }, + "bin": { + "clean-orphaned-assets": "bin/clean.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/linkifyjs": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz", + "integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mini-svg-data-uri": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz", + "integrity": "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==", + "dev": true, + "license": "MIT", + "bin": { + "mini-svg-data-uri": "cli.js" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.46", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz", + "integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/orderedmap": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", + "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", + "license": "MIT" + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/prosemirror-changeset": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz", + "integrity": "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==", + "license": "MIT", + "dependencies": { + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-commands": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz", + "integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.10.2" + } + }, + "node_modules/prosemirror-dropcursor": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz", + "integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0", + "prosemirror-view": "^1.1.0" + } + }, + "node_modules/prosemirror-gapcursor": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz", + "integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.0.0", + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-view": "^1.0.0" + } + }, + "node_modules/prosemirror-history": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz", + "integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.2.2", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.31.0", + "rope-sequence": "^1.3.0" + } + }, + "node_modules/prosemirror-inputrules": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz", + "integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-keymap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz", + "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "w3c-keyname": "^2.2.0" + } + }, + "node_modules/prosemirror-model": { + "version": "1.25.9", + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.9.tgz", + "integrity": "sha512-pRTklkDDMMRopyoAcrr9wV/8g/RYgrLHBuJAb5hlEuYZRdm5yqmPjWId83fpBwPpSFqEdja0H7Dfd7z1X/npcA==", + "license": "MIT", + "dependencies": { + "orderedmap": "^2.0.0" + } + }, + "node_modules/prosemirror-schema-list": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz", + "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.7.3" + } + }, + "node_modules/prosemirror-state": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", + "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.27.0" + } + }, + "node_modules/prosemirror-tables": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz", + "integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.4", + "prosemirror-state": "^1.4.4", + "prosemirror-transform": "^1.10.5", + "prosemirror-view": "^1.41.4" + } + }, + "node_modules/prosemirror-transform": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz", + "integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.21.0" + } + }, + "node_modules/prosemirror-view": { + "version": "1.41.9", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.9.tgz", + "integrity": "sha512-clTunTX+eaLbr87L1V1QPheRlEQJyTlL3gXe9x3jQIk3rL0RVWxviDGz8tFaydwIVm+hKhYCyr+R/zBtWr9s6A==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.25.8", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/qs-esm": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/qs-esm/-/qs-esm-7.0.3.tgz", + "integrity": "sha512-8jbjCR0PPbqoQcv83C2K/zvVeytRPwPpt3WPDbq51qyLAxcWGtXVRjSe6GHtLCoVbg9+NEFkv7GyUxqjcDIJzw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", + "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-aria": { + "version": "3.48.0", + "resolved": "https://registry.npmjs.org/react-aria/-/react-aria-3.48.0.tgz", + "integrity": "sha512-jQjd4rBEIMqecBaAKYJbVGK6EqIHLa5znVQ7jwFyK5vCyljoj6KhgtiahmcIPsG5vG5vEDLw+ba+bEWn6A2P4w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@internationalized/date": "^3.12.1", + "@internationalized/number": "^3.6.6", + "@internationalized/string": "^3.2.8", + "@react-types/shared": "^3.34.0", + "@swc/helpers": "^0.5.0", + "aria-hidden": "^1.2.3", + "clsx": "^2.0.0", + "react-stately": "3.46.0", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/react-dom": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", + "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.6" + } + }, + "node_modules/react-is": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.6.tgz", + "integrity": "sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw==", + "license": "MIT", + "peer": true + }, + "node_modules/react-redux": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", + "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-stately": { + "version": "3.46.0", + "resolved": "https://registry.npmjs.org/react-stately/-/react-stately-3.46.0.tgz", + "integrity": "sha512-OdxhWvHgs2L4OJGIs7hnuTr5WjjMM6enhNEAMRqiekhF8+ITvA2LRwNftOZwcogaoCslGYq5S2VQTQwnm0GbCA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@internationalized/date": "^3.12.1", + "@internationalized/number": "^3.6.6", + "@internationalized/string": "^3.2.8", + "@react-types/shared": "^3.34.0", + "@swc/helpers": "^0.5.0", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/readdirp/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/recharts": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz", + "integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/rope-sequence": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz", + "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", + "license": "MIT" + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shell-quote": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tabbable": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", + "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-plugin-full-reload": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/vite-plugin-full-reload/-/vite-plugin-full-reload-1.2.0.tgz", + "integrity": "sha512-kz18NW79x0IHbxRSHm0jttP4zoO9P9gXh+n6UTwlNKnviTTEpOlum6oS9SmecrTtSr+muHEn5TUuC75UovQzcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picocolors": "^1.0.0", + "picomatch": "^2.3.1" + } + }, + "node_modules/vite-plugin-full-reload/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/ziggy-js": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/ziggy-js/-/ziggy-js-2.6.2.tgz", + "integrity": "sha512-41xc9wRvv5Olh8pZjKSaL5vDAjw4BfTDMFoeLwRpDGc2B+uqcdwIKd81EHs6uwpqahdRrU7uMab0xWj0hBSDjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "qs-esm": "^7.0.3" + } + } + } +} diff --git a/erp/package.json b/erp/package.json new file mode 100644 index 00000000000..96022ad9468 --- /dev/null +++ b/erp/package.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://www.schemastore.org/package.json", + "private": true, + "type": "module", + "scripts": { + "build": "tsc && vite build", + "dev": "vite" + }, + "devDependencies": { + "@headlessui/react": "^2.0.0", + "@inertiajs/react": "^2.0.0", + "@tailwindcss/forms": "^0.5.3", + "@types/node": "^22.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.2.0", + "autoprefixer": "^10.4.12", + "axios": "^1.6.4", + "concurrently": "^9.0.1", + "laravel-vite-plugin": "^1.0", + "postcss": "^8.4.31", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "tailwindcss": "^3.2.1", + "typescript": "^5.0.2", + "vite": "^6.0.0", + "ziggy-js": "^2.3.0" + }, + "dependencies": { + "@tiptap/extension-character-count": "^3.27.0", + "@tiptap/extension-placeholder": "^3.27.0", + "@tiptap/react": "^3.27.0", + "@tiptap/starter-kit": "^3.27.0", + "recharts": "^3.8.1" + } +} diff --git a/erp/phpunit.xml b/erp/phpunit.xml new file mode 100644 index 00000000000..e7f0a48df61 --- /dev/null +++ b/erp/phpunit.xml @@ -0,0 +1,36 @@ + + + + + tests/Unit + + + tests/Feature + + + + + app + + + + + + + + + + + + + + + + + + + diff --git a/erp/postcss.config.js b/erp/postcss.config.js new file mode 100644 index 00000000000..49c0612d5c2 --- /dev/null +++ b/erp/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/erp/public/.htaccess b/erp/public/.htaccess new file mode 100644 index 00000000000..b574a597daf --- /dev/null +++ b/erp/public/.htaccess @@ -0,0 +1,25 @@ + + + Options -MultiViews -Indexes + + + RewriteEngine On + + # Handle Authorization Header + RewriteCond %{HTTP:Authorization} . + RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] + + # Handle X-XSRF-Token Header + RewriteCond %{HTTP:x-xsrf-token} . + RewriteRule .* - [E=HTTP_X_XSRF_TOKEN:%{HTTP:X-XSRF-Token}] + + # Redirect Trailing Slashes If Not A Folder... + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_URI} (.+)/$ + RewriteRule ^ %1 [L,R=301] + + # Send Requests To Front Controller... + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_FILENAME} !-f + RewriteRule ^ index.php [L] + diff --git a/erp/public/api-docs/openapi.yaml b/erp/public/api-docs/openapi.yaml new file mode 100644 index 00000000000..4f2b9def398 --- /dev/null +++ b/erp/public/api-docs/openapi.yaml @@ -0,0 +1,2930 @@ +openapi: 3.0.3 +info: + title: ERP API + version: 1.0.0 + description: > + REST API for the multi-tenant ERP system. + Authenticate via POST /api/v1/auth/login to get a Bearer token, + then pass it as `Authorization: Bearer ` on every subsequent request. + contact: + name: ERP Support + email: support@erp.local + license: + name: Proprietary + +servers: + - url: /api/v1 + description: Production (v1) + +security: + - bearerAuth: [] + +tags: + - name: Auth + description: Authentication — login, logout, current user + - name: Dashboard + description: Aggregated KPI metrics + - name: Products + description: Product catalogue management + - name: Invoices + description: Invoice lifecycle management + - name: Customers + description: Customer / contact records + - name: Inventory + description: Stock levels, movements, and adjustments + - name: CRM + description: CRM leads and opportunities pipeline + - name: Helpdesk + description: Support ticket management + - name: HR + description: Human resources — employees, departments, leave + - name: Manufacturing + description: Manufacturing orders and bills of materials + - name: POS + description: Point-of-sale sessions and orders + - name: Currencies + description: Currency list and conversion + +# ───────────────────────────────────────────────────────────── +# Components +# ───────────────────────────────────────────────────────────── +components: + + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + + # ── Reusable parameters ────────────────────────────────── + parameters: + PageParam: + name: page + in: query + schema: + type: integer + minimum: 1 + default: 1 + description: Page number (default 1) + PerPageParam: + name: per_page + in: query + schema: + type: integer + minimum: 1 + maximum: 100 + default: 20 + description: Items per page (default 20, max 100) + IdPath: + name: id + in: path + required: true + schema: + type: integer + example: 1 + + # ── Schemas ─────────────────────────────────────────────── + schemas: + + # ── Shared envelope wrappers ────────────────────────── + SuccessResponse: + type: object + properties: + success: + type: boolean + example: true + data: + description: Response payload — shape varies per endpoint + required: + - success + - data + + ErrorResponse: + type: object + properties: + success: + type: boolean + example: false + message: + type: string + example: Invalid credentials + required: + - success + - message + + ValidationErrorResponse: + type: object + properties: + success: + type: boolean + example: false + message: + type: string + example: The given data was invalid. + errors: + type: object + additionalProperties: + type: array + items: + type: string + example: + email: + - The email field is required. + + PaginationMeta: + type: object + properties: + total: + type: integer + example: 150 + per_page: + type: integer + example: 20 + current_page: + type: integer + example: 1 + last_page: + type: integer + example: 8 + required: + - total + - per_page + - current_page + - last_page + + PaginatedResponse: + type: object + properties: + success: + type: boolean + example: true + data: + type: array + items: {} + description: Array of resource objects + meta: + $ref: '#/components/schemas/PaginationMeta' + required: + - success + - data + - meta + + # ── Auth ───────────────────────────────────────────── + LoginRequest: + type: object + required: + - email + - password + properties: + email: + type: string + format: email + example: admin@demo.erp + password: + type: string + format: password + example: secret123 + + LoginResponse: + type: object + properties: + success: + type: boolean + example: true + data: + type: object + properties: + token: + type: string + example: "1|aBcDeFgHiJ..." + user: + $ref: '#/components/schemas/AuthUser' + + AuthUser: + type: object + properties: + id: + type: integer + example: 1 + name: + type: string + example: Jane Smith + email: + type: string + format: email + example: jane@demo.erp + tenant_id: + type: integer + example: 42 + + MeResponse: + type: object + properties: + id: + type: integer + example: 1 + name: + type: string + example: Jane Smith + email: + type: string + format: email + example: jane@demo.erp + tenant_id: + type: integer + example: 42 + tenant: + type: object + nullable: true + properties: + id: + type: integer + example: 42 + name: + type: string + example: Acme Corp + slug: + type: string + example: acme-corp + + # ── Product ────────────────────────────────────────── + Product: + type: object + properties: + id: + type: integer + example: 10 + name: + type: string + example: Wireless Keyboard + sku: + type: string + example: WK-1001 + sale_price: + type: number + format: float + example: 49.99 + cost_price: + type: number + format: float + example: 22.50 + stock_quantity: + type: number + format: float + example: 120 + is_active: + type: boolean + example: true + category_id: + type: integer + nullable: true + example: 3 + required: + - id + - name + - sku + - sale_price + - cost_price + + ProductCreateRequest: + type: object + required: + - name + - sku + - sale_price + - cost_price + properties: + name: + type: string + maxLength: 255 + example: Wireless Mouse + sku: + type: string + maxLength: 100 + example: WM-2002 + sale_price: + type: number + minimum: 0 + example: 29.99 + cost_price: + type: number + minimum: 0 + example: 12.00 + description: + type: string + nullable: true + example: Ergonomic wireless mouse with 2-year battery life + category_id: + type: integer + nullable: true + example: 3 + is_active: + type: boolean + example: true + reorder_point: + type: number + minimum: 0 + nullable: true + example: 10 + + ProductUpdateRequest: + type: object + properties: + name: + type: string + maxLength: 255 + example: Wireless Mouse Pro + sku: + type: string + maxLength: 100 + example: WM-2002-PRO + sale_price: + type: number + minimum: 0 + example: 34.99 + cost_price: + type: number + minimum: 0 + example: 14.00 + description: + type: string + nullable: true + category_id: + type: integer + nullable: true + is_active: + type: boolean + reorder_point: + type: number + minimum: 0 + nullable: true + + # ── Invoice ────────────────────────────────────────── + Invoice: + type: object + properties: + id: + type: integer + example: 55 + invoice_number: + type: string + example: INV-00055 + customer_name: + type: string + nullable: true + example: Acme Corp + status: + type: string + enum: [draft, sent, partial, paid, cancelled] + example: sent + issue_date: + type: string + format: date + example: "2025-04-01" + due_date: + type: string + format: date + nullable: true + example: "2025-04-30" + total: + type: number + format: float + example: 1250.00 + currency_code: + type: string + example: USD + required: + - id + - invoice_number + - status + - total + + InvoiceDetail: + allOf: + - $ref: '#/components/schemas/Invoice' + - type: object + properties: + contact: + type: object + nullable: true + properties: + id: + type: integer + example: 7 + name: + type: string + example: Acme Corp + email: + type: string + format: email + example: billing@acme.com + items: + type: array + items: + $ref: '#/components/schemas/InvoiceItem' + notes: + type: string + nullable: true + example: Net 30 payment terms + + InvoiceItem: + type: object + properties: + id: + type: integer + example: 201 + description: + type: string + example: Development services — April 2025 + quantity: + type: number + example: 10 + unit_price: + type: number + example: 125.00 + tax_rate: + type: number + example: 10 + subtotal: + type: number + example: 1250.00 + + InvoiceCreateRequest: + type: object + required: + - contact_id + - issue_date + properties: + contact_id: + type: integer + example: 7 + issue_date: + type: string + format: date + example: "2025-04-01" + due_date: + type: string + format: date + nullable: true + example: "2025-04-30" + status: + type: string + enum: [draft, sent] + example: draft + notes: + type: string + nullable: true + items: + type: array + items: + type: object + required: + - description + - quantity + - unit_price + properties: + description: + type: string + example: Consulting — April 2025 + quantity: + type: number + example: 5 + unit_price: + type: number + example: 200.00 + tax_rate: + type: number + example: 10 + + InvoiceStatusRequest: + type: object + required: + - status + properties: + status: + type: string + enum: [draft, sent, partial, paid, cancelled] + example: paid + + # ── Customer ───────────────────────────────────────── + Customer: + type: object + properties: + id: + type: integer + example: 7 + name: + type: string + example: Acme Corp + email: + type: string + format: email + nullable: true + example: contact@acme.com + phone: + type: string + nullable: true + example: "+1-555-0100" + company: + type: string + nullable: true + example: Acme Corp Ltd. + type: + type: string + example: customer + created_at: + type: string + format: date-time + example: "2025-01-15T09:00:00Z" + required: + - id + - name + + CustomerCreateRequest: + type: object + required: + - name + properties: + name: + type: string + maxLength: 255 + example: Beta Industries + email: + type: string + format: email + nullable: true + example: info@beta.io + phone: + type: string + nullable: true + example: "+1-555-0200" + company: + type: string + nullable: true + example: Beta Industries Ltd. + type: + type: string + example: customer + notes: + type: string + nullable: true + + CustomerUpdateRequest: + type: object + properties: + name: + type: string + maxLength: 255 + email: + type: string + format: email + nullable: true + phone: + type: string + nullable: true + company: + type: string + nullable: true + notes: + type: string + nullable: true + + # ── Inventory ──────────────────────────────────────── + StockItem: + type: object + properties: + product_id: + type: integer + example: 10 + product_name: + type: string + example: Wireless Keyboard + sku: + type: string + example: WK-1001 + stock_quantity: + type: number + example: 120 + reorder_point: + type: number + nullable: true + example: 10 + warehouse: + type: string + nullable: true + example: Main Warehouse + + StockMovement: + type: object + properties: + id: + type: integer + example: 301 + product_id: + type: integer + example: 10 + product_name: + type: string + example: Wireless Keyboard + type: + type: string + enum: [in, out, adjustment, transfer] + example: in + quantity: + type: number + example: 50 + reference: + type: string + nullable: true + example: PO-0012 + notes: + type: string + nullable: true + created_at: + type: string + format: date-time + example: "2025-04-05T14:00:00Z" + + InventoryAdjustRequest: + type: object + required: + - product_id + - quantity + - type + properties: + product_id: + type: integer + example: 10 + quantity: + type: number + example: 25 + type: + type: string + enum: [in, out, adjustment] + example: adjustment + notes: + type: string + nullable: true + example: Stocktake correction + + # ── CRM Lead ───────────────────────────────────────── + CrmLead: + type: object + properties: + id: + type: integer + example: 88 + title: + type: string + example: Enterprise SaaS deal — Acme + type: + type: string + enum: [lead, opportunity] + example: opportunity + contact_name: + type: string + nullable: true + example: John Doe + company_name: + type: string + nullable: true + example: Acme Corp + email: + type: string + format: email + nullable: true + example: john.doe@acme.com + phone: + type: string + nullable: true + example: "+1-555-0199" + source: + type: string + nullable: true + example: Website + stage: + type: object + nullable: true + properties: + id: + type: integer + example: 2 + name: + type: string + example: Qualified + expected_revenue: + type: number + nullable: true + example: 50000.00 + probability: + type: number + nullable: true + example: 60 + priority: + type: string + nullable: true + example: high + status: + type: string + enum: [open, won, lost] + example: open + score: + type: integer + nullable: true + example: 72 + assigned_to: + type: integer + nullable: true + example: 3 + created_at: + type: string + format: date-time + example: "2025-03-10T10:30:00Z" + required: + - id + - title + - type + - status + + CrmLeadCreateRequest: + type: object + required: + - title + properties: + title: + type: string + maxLength: 255 + example: New SaaS deal — Beta Inc + type: + type: string + enum: [lead, opportunity] + example: lead + contact_name: + type: string + nullable: true + example: Alice Brown + company_name: + type: string + nullable: true + example: Beta Inc + email: + type: string + format: email + nullable: true + example: alice@beta.io + phone: + type: string + nullable: true + example: "+1-555-0201" + source: + type: string + nullable: true + example: Trade Show + expected_revenue: + type: number + minimum: 0 + nullable: true + example: 20000.00 + probability: + type: number + minimum: 0 + maximum: 100 + nullable: true + example: 30 + priority: + type: string + nullable: true + example: medium + stage_id: + type: integer + nullable: true + example: 1 + assigned_to: + type: integer + nullable: true + example: 3 + description: + type: string + nullable: true + + CrmLeadMarkLostRequest: + type: object + properties: + reason: + type: string + nullable: true + example: Budget constraints + + # ── Helpdesk Ticket ────────────────────────────────── + Ticket: + type: object + properties: + id: + type: integer + example: 500 + ticket_number: + type: string + example: TK-000500 + subject: + type: string + example: Cannot export invoices to PDF + status: + type: string + enum: [open, pending, resolved, closed] + example: open + priority: + type: string + enum: [low, medium, high, urgent] + example: high + type: + type: string + nullable: true + example: bug + customer_name: + type: string + nullable: true + example: Jane Customer + customer_email: + type: string + format: email + nullable: true + example: jane@client.com + sla_deadline: + type: string + format: date-time + nullable: true + example: "2025-04-10T17:00:00Z" + first_response_at: + type: string + format: date-time + nullable: true + example: "2025-04-08T09:15:00Z" + resolved_at: + type: string + format: date-time + nullable: true + assignee: + type: object + nullable: true + properties: + id: + type: integer + example: 4 + name: + type: string + example: Support Agent + created_at: + type: string + format: date-time + example: "2025-04-08T08:00:00Z" + required: + - id + - ticket_number + - subject + - status + - priority + + TicketCreateRequest: + type: object + required: + - subject + properties: + subject: + type: string + maxLength: 255 + example: Login page throws 500 error + description: + type: string + nullable: true + example: Steps to reproduce — go to /login and click submit with no password + type: + type: string + nullable: true + example: bug + priority: + type: string + enum: [low, medium, high, urgent] + example: urgent + team_id: + type: integer + nullable: true + example: 1 + assigned_to: + type: integer + nullable: true + example: 4 + customer_name: + type: string + nullable: true + example: Bob Customer + customer_email: + type: string + format: email + nullable: true + example: bob@client.com + + TicketReplyRequest: + type: object + required: + - body + properties: + body: + type: string + example: We have reproduced the issue and a fix will be deployed shortly. + is_internal: + type: boolean + example: false + + # ── HR ─────────────────────────────────────────────── + Employee: + type: object + properties: + id: + type: integer + example: 12 + name: + type: string + example: Jane Smith + first_name: + type: string + example: Jane + last_name: + type: string + example: Smith + employee_number: + type: string + example: EMP-0012 + email: + type: string + format: email + nullable: true + example: jane.smith@erp.local + phone: + type: string + nullable: true + example: "+1-555-0312" + position: + type: string + nullable: true + example: Senior Developer + status: + type: string + enum: [active, on_leave, terminated] + example: active + department: + type: object + nullable: true + properties: + id: + type: integer + example: 2 + name: + type: string + example: Engineering + start_date: + type: string + format: date + nullable: true + example: "2022-06-01" + required: + - id + - name + - employee_number + + EmployeeListItem: + type: object + properties: + id: + type: integer + example: 12 + name: + type: string + example: Jane Smith + employee_number: + type: string + example: EMP-0012 + department: + type: string + nullable: true + example: Engineering + position: + type: string + nullable: true + example: Senior Developer + status: + type: string + example: active + + Department: + type: object + properties: + id: + type: integer + example: 2 + name: + type: string + example: Engineering + employees_count: + type: integer + example: 14 + + LeaveRequest: + type: object + properties: + id: + type: integer + example: 77 + employee: + type: object + nullable: true + properties: + id: + type: integer + example: 12 + first_name: + type: string + example: Jane + last_name: + type: string + example: Smith + employee_number: + type: string + example: EMP-0012 + leave_type: + type: string + example: annual + start_date: + type: string + format: date + example: "2025-05-01" + end_date: + type: string + format: date + example: "2025-05-05" + status: + type: string + enum: [pending, approved, rejected, cancelled] + example: pending + reason: + type: string + nullable: true + example: Family vacation + + # ── Manufacturing ───────────────────────────────────── + ManufacturingOrder: + type: object + properties: + id: + type: integer + example: 33 + mo_number: + type: string + example: MO-000033 + product: + type: string + nullable: true + example: Widget A + qty_to_produce: + type: number + example: 500 + qty_produced: + type: number + example: 200 + status: + type: string + enum: [draft, confirmed, in_progress, done, cancelled] + example: in_progress + scheduled_date: + type: string + format: date + nullable: true + example: "2025-05-15" + required: + - id + - mo_number + - status + + ManufacturingOrderDetail: + allOf: + - $ref: '#/components/schemas/ManufacturingOrder' + - type: object + properties: + product: + type: object + nullable: true + properties: + id: + type: integer + example: 10 + name: + type: string + example: Widget A + sku: + type: string + example: WGT-A + bom: + type: object + nullable: true + description: Bill of Materials attached to this order + components: + type: array + items: + type: object + properties: + product: + type: object + properties: + id: + type: integer + name: + type: string + sku: + type: string + qty_required: + type: number + example: 10 + + ManufacturingStatusRequest: + type: object + required: + - status + properties: + status: + type: string + enum: [draft, confirmed, in_progress, done, cancelled] + example: done + + BillOfMaterials: + type: object + properties: + id: + type: integer + example: 5 + product: + type: object + nullable: true + properties: + id: + type: integer + example: 10 + name: + type: string + example: Widget A + sku: + type: string + example: WGT-A + quantity: + type: number + example: 1 + is_active: + type: boolean + example: true + created_at: + type: string + format: date-time + example: "2025-01-10T08:00:00Z" + + # ── POS ────────────────────────────────────────────── + PosSession: + type: object + properties: + id: + type: integer + example: 15 + session_number: + type: string + example: POS-SES-0015 + cashier: + type: object + nullable: true + properties: + id: + type: integer + example: 5 + name: + type: string + example: Cashier One + opened_at: + type: string + format: date-time + example: "2025-04-10T09:00:00Z" + closed_at: + type: string + format: date-time + nullable: true + status: + type: string + enum: [open, closed] + example: open + total_sales: + type: number + nullable: true + example: 1450.00 + + PosOrder: + type: object + properties: + id: + type: integer + example: 99 + order_number: + type: string + example: POS-ORD-0099 + session_id: + type: integer + example: 15 + total: + type: number + example: 89.97 + payment_method: + type: string + example: cash + status: + type: string + enum: [completed, voided, refunded] + example: completed + created_at: + type: string + format: date-time + example: "2025-04-10T10:45:00Z" + items: + type: array + items: + type: object + properties: + product_id: + type: integer + example: 10 + product_name: + type: string + example: Wireless Keyboard + quantity: + type: number + example: 1 + unit_price: + type: number + example: 49.99 + discount: + type: number + example: 0 + line_total: + type: number + example: 49.99 + + PosOrderCreateRequest: + type: object + required: + - session_id + - items + - payment_method + properties: + session_id: + type: integer + example: 15 + payment_method: + type: string + example: cash + customer_id: + type: integer + nullable: true + example: 7 + items: + type: array + minItems: 1 + items: + type: object + required: + - product_id + - quantity + - unit_price + properties: + product_id: + type: integer + example: 10 + quantity: + type: number + minimum: 0.001 + example: 2 + unit_price: + type: number + minimum: 0 + example: 49.99 + discount: + type: number + minimum: 0 + example: 0 + + # ── Currency ───────────────────────────────────────── + Currency: + type: object + properties: + id: + type: integer + example: 1 + code: + type: string + example: USD + name: + type: string + example: US Dollar + symbol: + type: string + example: "$" + exchange_rate: + type: number + example: 1.0 + is_base: + type: boolean + example: true + + CurrencyConvertResponse: + type: object + properties: + success: + type: boolean + example: true + data: + type: object + properties: + from: + type: string + example: USD + to: + type: string + example: EUR + amount: + type: number + example: 100 + converted: + type: number + example: 92.50 + rate: + type: number + example: 0.925 + + # ── Dashboard ──────────────────────────────────────── + DashboardData: + type: object + properties: + revenue_this_month: + type: number + example: 125000.00 + outstanding_invoices: + type: number + example: 38500.00 + open_tickets: + type: integer + example: 12 + active_leads: + type: integer + example: 47 + products_low_stock: + type: integer + example: 5 + employees_active: + type: integer + example: 82 + recent_invoices: + type: array + items: + $ref: '#/components/schemas/Invoice' + recent_tickets: + type: array + items: + $ref: '#/components/schemas/Ticket' + +# ───────────────────────────────────────────────────────────── +# Paths +# ───────────────────────────────────────────────────────────── +paths: + + # ── Auth ────────────────────────────────────────────────── + /auth/login: + post: + tags: [Auth] + summary: Log in and obtain an API token + security: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/LoginRequest' + example: + email: admin@demo.erp + password: secret123 + responses: + "200": + description: Successful login + content: + application/json: + schema: + $ref: '#/components/schemas/LoginResponse' + "401": + description: Invalid credentials + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + "422": + description: Validation error + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationErrorResponse' + + /auth/logout: + post: + tags: [Auth] + summary: Revoke the current Bearer token + responses: + "200": + description: Logged out + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessResponse' + example: + success: true + data: + message: Logged out successfully + "401": + description: Unauthenticated + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /auth/me: + get: + tags: [Auth] + summary: Return the authenticated user with tenant info + responses: + "200": + description: Current user + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + data: + $ref: '#/components/schemas/MeResponse' + "401": + description: Unauthenticated + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + # ── Dashboard ───────────────────────────────────────────── + /dashboard: + get: + tags: [Dashboard] + summary: Aggregated KPI metrics for the authenticated tenant + responses: + "200": + description: Dashboard data + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + data: + $ref: '#/components/schemas/DashboardData' + "401": + description: Unauthenticated + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + # ── Products ────────────────────────────────────────────── + /products: + get: + tags: [Products] + summary: List products (paginated) + parameters: + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PerPageParam' + - name: search + in: query + description: Filter by name or SKU + schema: + type: string + example: keyboard + - name: category_id + in: query + schema: + type: integer + example: 3 + - name: is_active + in: query + schema: + type: boolean + example: true + responses: + "200": + description: Paginated product list + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/PaginatedResponse' + - type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/Product' + "401": + description: Unauthenticated + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + post: + tags: [Products] + summary: Create a new product + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ProductCreateRequest' + responses: + "201": + description: Product created + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + data: + $ref: '#/components/schemas/Product' + "422": + description: Validation error + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationErrorResponse' + + /products/{id}: + get: + tags: [Products] + summary: Retrieve a single product + parameters: + - $ref: '#/components/parameters/IdPath' + responses: + "200": + description: Product detail + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + data: + $ref: '#/components/schemas/Product' + "404": + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + put: + tags: [Products] + summary: Update a product + parameters: + - $ref: '#/components/parameters/IdPath' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ProductUpdateRequest' + responses: + "200": + description: Updated product + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + data: + $ref: '#/components/schemas/Product' + "404": + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + "422": + description: Validation error + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationErrorResponse' + + delete: + tags: [Products] + summary: Delete a product + parameters: + - $ref: '#/components/parameters/IdPath' + responses: + "200": + description: Deleted + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessResponse' + "404": + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + # ── Invoices ────────────────────────────────────────────── + /invoices: + get: + tags: [Invoices] + summary: List invoices (paginated) + parameters: + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PerPageParam' + - name: status + in: query + schema: + type: string + enum: [draft, sent, partial, paid, cancelled] + - name: customer_id + in: query + schema: + type: integer + example: 7 + responses: + "200": + description: Paginated invoice list + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/PaginatedResponse' + - type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/Invoice' + "401": + description: Unauthenticated + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + post: + tags: [Invoices] + summary: Create a new invoice + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/InvoiceCreateRequest' + responses: + "201": + description: Invoice created + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + data: + $ref: '#/components/schemas/InvoiceDetail' + "422": + description: Validation error + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationErrorResponse' + + /invoices/{id}: + get: + tags: [Invoices] + summary: Retrieve a single invoice with line items + parameters: + - $ref: '#/components/parameters/IdPath' + responses: + "200": + description: Invoice detail + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + data: + $ref: '#/components/schemas/InvoiceDetail' + "404": + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + delete: + tags: [Invoices] + summary: Delete an invoice + parameters: + - $ref: '#/components/parameters/IdPath' + responses: + "200": + description: Deleted + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessResponse' + "404": + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /invoices/{id}/status: + put: + tags: [Invoices] + summary: Update invoice status + parameters: + - $ref: '#/components/parameters/IdPath' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/InvoiceStatusRequest' + responses: + "200": + description: Status updated + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + data: + $ref: '#/components/schemas/Invoice' + "404": + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + "422": + description: Validation error + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationErrorResponse' + + # ── Customers ───────────────────────────────────────────── + /customers: + get: + tags: [Customers] + summary: List customers (paginated) + parameters: + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PerPageParam' + - name: search + in: query + schema: + type: string + example: Acme + responses: + "200": + description: Paginated customer list + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/PaginatedResponse' + - type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/Customer' + "401": + description: Unauthenticated + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + post: + tags: [Customers] + summary: Create a new customer + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CustomerCreateRequest' + responses: + "201": + description: Customer created + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + data: + $ref: '#/components/schemas/Customer' + "422": + description: Validation error + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationErrorResponse' + + /customers/{id}: + get: + tags: [Customers] + summary: Retrieve a single customer + parameters: + - $ref: '#/components/parameters/IdPath' + responses: + "200": + description: Customer detail + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + data: + $ref: '#/components/schemas/Customer' + "404": + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + put: + tags: [Customers] + summary: Update a customer + parameters: + - $ref: '#/components/parameters/IdPath' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CustomerUpdateRequest' + responses: + "200": + description: Updated customer + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + data: + $ref: '#/components/schemas/Customer' + "404": + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + delete: + tags: [Customers] + summary: Delete a customer + parameters: + - $ref: '#/components/parameters/IdPath' + responses: + "200": + description: Deleted + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessResponse' + "404": + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + # ── Inventory ───────────────────────────────────────────── + /inventory/stock: + get: + tags: [Inventory] + summary: Current stock levels for all products + parameters: + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PerPageParam' + - name: low_stock + in: query + description: Return only products at or below reorder point + schema: + type: boolean + example: true + responses: + "200": + description: Stock list + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/PaginatedResponse' + - type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/StockItem' + "401": + description: Unauthenticated + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /inventory/movements: + get: + tags: [Inventory] + summary: Stock movement history + parameters: + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PerPageParam' + - name: product_id + in: query + schema: + type: integer + example: 10 + - name: type + in: query + schema: + type: string + enum: [in, out, adjustment, transfer] + responses: + "200": + description: Paginated movement list + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/PaginatedResponse' + - type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/StockMovement' + "401": + description: Unauthenticated + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /inventory/adjust: + post: + tags: [Inventory] + summary: Record a manual stock adjustment + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/InventoryAdjustRequest' + responses: + "200": + description: Adjustment recorded + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessResponse' + "422": + description: Validation error + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationErrorResponse' + + # ── CRM ─────────────────────────────────────────────────── + /crm/leads: + get: + tags: [CRM] + summary: List CRM leads / opportunities (paginated) + parameters: + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PerPageParam' + - name: type + in: query + schema: + type: string + enum: [lead, opportunity] + - name: status + in: query + schema: + type: string + enum: [open, won, lost] + - name: assigned_to + in: query + schema: + type: integer + example: 3 + responses: + "200": + description: Paginated lead list + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/PaginatedResponse' + - type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/CrmLead' + "401": + description: Unauthenticated + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + post: + tags: [CRM] + summary: Create a new lead or opportunity + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CrmLeadCreateRequest' + responses: + "201": + description: Lead created + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + data: + $ref: '#/components/schemas/CrmLead' + "422": + description: Validation error + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationErrorResponse' + + /crm/leads/{id}: + get: + tags: [CRM] + summary: Retrieve a single lead with activities + parameters: + - $ref: '#/components/parameters/IdPath' + responses: + "200": + description: Lead detail + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + data: + $ref: '#/components/schemas/CrmLead' + "404": + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + put: + tags: [CRM] + summary: Update a lead + parameters: + - $ref: '#/components/parameters/IdPath' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CrmLeadCreateRequest' + responses: + "200": + description: Updated lead + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + data: + $ref: '#/components/schemas/CrmLead' + "404": + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + delete: + tags: [CRM] + summary: Delete a lead + parameters: + - $ref: '#/components/parameters/IdPath' + responses: + "200": + description: Deleted + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessResponse' + "404": + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /crm/leads/{id}/won: + post: + tags: [CRM] + summary: Mark a lead as won + parameters: + - $ref: '#/components/parameters/IdPath' + responses: + "200": + description: Lead marked won + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + data: + $ref: '#/components/schemas/CrmLead' + "404": + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /crm/leads/{id}/lost: + post: + tags: [CRM] + summary: Mark a lead as lost + parameters: + - $ref: '#/components/parameters/IdPath' + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CrmLeadMarkLostRequest' + responses: + "200": + description: Lead marked lost + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + data: + $ref: '#/components/schemas/CrmLead' + "404": + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + # ── Helpdesk ────────────────────────────────────────────── + /helpdesk/tickets: + get: + tags: [Helpdesk] + summary: List helpdesk tickets (paginated) + parameters: + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PerPageParam' + - name: status + in: query + schema: + type: string + enum: [open, pending, resolved, closed] + - name: priority + in: query + schema: + type: string + enum: [low, medium, high, urgent] + - name: assigned_to + in: query + schema: + type: integer + example: 4 + responses: + "200": + description: Paginated ticket list + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/PaginatedResponse' + - type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/Ticket' + "401": + description: Unauthenticated + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + post: + tags: [Helpdesk] + summary: Create a new support ticket + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TicketCreateRequest' + responses: + "201": + description: Ticket created + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + data: + $ref: '#/components/schemas/Ticket' + "422": + description: Validation error + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationErrorResponse' + + /helpdesk/tickets/{id}: + get: + tags: [Helpdesk] + summary: Retrieve a ticket with all messages + parameters: + - $ref: '#/components/parameters/IdPath' + responses: + "200": + description: Ticket detail + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + data: + allOf: + - $ref: '#/components/schemas/Ticket' + - type: object + properties: + messages: + type: array + items: + type: object + properties: + id: + type: integer + example: 801 + body: + type: string + example: We reproduced the issue. + is_internal: + type: boolean + example: false + author: + type: object + properties: + id: + type: integer + example: 4 + name: + type: string + example: Support Agent + created_at: + type: string + format: date-time + "404": + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + put: + tags: [Helpdesk] + summary: Update ticket fields + parameters: + - $ref: '#/components/parameters/IdPath' + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + subject: + type: string + priority: + type: string + enum: [low, medium, high, urgent] + status: + type: string + enum: [open, pending, resolved, closed] + assigned_to: + type: integer + nullable: true + responses: + "200": + description: Updated ticket + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + data: + $ref: '#/components/schemas/Ticket' + "404": + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + delete: + tags: [Helpdesk] + summary: Delete a ticket + parameters: + - $ref: '#/components/parameters/IdPath' + responses: + "200": + description: Deleted + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessResponse' + "404": + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /helpdesk/tickets/{id}/reply: + post: + tags: [Helpdesk] + summary: Post a reply (or internal note) on a ticket + parameters: + - $ref: '#/components/parameters/IdPath' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TicketReplyRequest' + responses: + "201": + description: Reply created + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + data: + type: object + properties: + id: + type: integer + example: 802 + body: + type: string + example: The fix has been deployed. + is_internal: + type: boolean + example: false + author_id: + type: integer + example: 4 + created_at: + type: string + format: date-time + "404": + description: Ticket not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /helpdesk/tickets/{id}/resolve: + post: + tags: [Helpdesk] + summary: Mark a ticket as resolved + parameters: + - $ref: '#/components/parameters/IdPath' + responses: + "200": + description: Ticket resolved + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + data: + $ref: '#/components/schemas/Ticket' + "404": + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + # ── HR ──────────────────────────────────────────────────── + /hr/employees: + get: + tags: [HR] + summary: List employees (paginated) + parameters: + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PerPageParam' + - name: department_id + in: query + schema: + type: integer + example: 2 + - name: status + in: query + schema: + type: string + enum: [active, on_leave, terminated] + responses: + "200": + description: Paginated employee list + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/PaginatedResponse' + - type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/EmployeeListItem' + "401": + description: Unauthenticated + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /hr/employees/{id}: + get: + tags: [HR] + summary: Retrieve a single employee with department info + parameters: + - $ref: '#/components/parameters/IdPath' + responses: + "200": + description: Employee detail + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + data: + $ref: '#/components/schemas/Employee' + "404": + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /hr/departments: + get: + tags: [HR] + summary: List all departments with employee count + responses: + "200": + description: Department list + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + data: + type: array + items: + $ref: '#/components/schemas/Department' + "401": + description: Unauthenticated + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /hr/leave-requests: + get: + tags: [HR] + summary: List leave requests (paginated) + parameters: + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PerPageParam' + - name: status + in: query + schema: + type: string + enum: [pending, approved, rejected, cancelled] + - name: employee_id + in: query + schema: + type: integer + example: 12 + responses: + "200": + description: Paginated leave request list + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/PaginatedResponse' + - type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/LeaveRequest' + "401": + description: Unauthenticated + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + # ── Manufacturing ───────────────────────────────────────── + /manufacturing/orders: + get: + tags: [Manufacturing] + summary: List manufacturing orders (paginated) + parameters: + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PerPageParam' + - name: status + in: query + schema: + type: string + enum: [draft, confirmed, in_progress, done, cancelled] + responses: + "200": + description: Paginated manufacturing order list + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/PaginatedResponse' + - type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/ManufacturingOrder' + "401": + description: Unauthenticated + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /manufacturing/orders/{id}: + get: + tags: [Manufacturing] + summary: Retrieve a manufacturing order with BOM and components + parameters: + - $ref: '#/components/parameters/IdPath' + responses: + "200": + description: Manufacturing order detail + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + data: + $ref: '#/components/schemas/ManufacturingOrderDetail' + "404": + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /manufacturing/orders/{id}/status: + put: + tags: [Manufacturing] + summary: Update a manufacturing order status + parameters: + - $ref: '#/components/parameters/IdPath' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ManufacturingStatusRequest' + responses: + "200": + description: Status updated + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + data: + $ref: '#/components/schemas/ManufacturingOrder' + "404": + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + "422": + description: Validation error + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationErrorResponse' + + /manufacturing/boms: + get: + tags: [Manufacturing] + summary: List bills of materials (paginated) + parameters: + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PerPageParam' + responses: + "200": + description: Paginated BOM list + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/PaginatedResponse' + - type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/BillOfMaterials' + "401": + description: Unauthenticated + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + # ── POS ─────────────────────────────────────────────────── + /pos/sessions: + get: + tags: [POS] + summary: List POS sessions (paginated) + parameters: + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PerPageParam' + - name: status + in: query + schema: + type: string + enum: [open, closed] + responses: + "200": + description: Paginated session list + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/PaginatedResponse' + - type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/PosSession' + "401": + description: Unauthenticated + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /pos/orders: + post: + tags: [POS] + summary: Create a new POS sale order + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PosOrderCreateRequest' + responses: + "201": + description: POS order created + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + data: + $ref: '#/components/schemas/PosOrder' + "422": + description: Validation error + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationErrorResponse' + + /pos/orders/{id}: + get: + tags: [POS] + summary: Retrieve a POS order with line items + parameters: + - $ref: '#/components/parameters/IdPath' + responses: + "200": + description: POS order detail + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + data: + $ref: '#/components/schemas/PosOrder' + "404": + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + # ── Currencies ──────────────────────────────────────────── + /currencies: + get: + tags: [Currencies] + summary: List all configured currencies + responses: + "200": + description: Currency list + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + data: + type: array + items: + $ref: '#/components/schemas/Currency' + "401": + description: Unauthenticated + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /currencies/convert: + get: + tags: [Currencies] + summary: Convert an amount between two currencies + parameters: + - name: from + in: query + required: true + schema: + type: string + example: USD + description: Source currency code + - name: to + in: query + required: true + schema: + type: string + example: EUR + description: Target currency code + - name: amount + in: query + required: true + schema: + type: number + example: 100 + description: Amount to convert + responses: + "200": + description: Conversion result + content: + application/json: + schema: + $ref: '#/components/schemas/CurrencyConvertResponse' + "422": + description: Validation error + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationErrorResponse' diff --git a/erp/public/favicon.ico b/erp/public/favicon.ico new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erp/public/index.php b/erp/public/index.php new file mode 100644 index 00000000000..ee8f07e9938 --- /dev/null +++ b/erp/public/index.php @@ -0,0 +1,20 @@ +handleRequest(Request::capture()); diff --git a/erp/public/robots.txt b/erp/public/robots.txt new file mode 100644 index 00000000000..eb0536286f3 --- /dev/null +++ b/erp/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: diff --git a/erp/resources/css/app.css b/erp/resources/css/app.css new file mode 100644 index 00000000000..b5c61c95671 --- /dev/null +++ b/erp/resources/css/app.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/erp/resources/js/Components/ApplicationLogo.tsx b/erp/resources/js/Components/ApplicationLogo.tsx new file mode 100644 index 00000000000..ccd928517bc --- /dev/null +++ b/erp/resources/js/Components/ApplicationLogo.tsx @@ -0,0 +1,13 @@ +import { SVGAttributes } from 'react'; + +export default function ApplicationLogo(props: SVGAttributes) { + return ( + + + + ); +} diff --git a/erp/resources/js/Components/Charts/BarChart.tsx b/erp/resources/js/Components/Charts/BarChart.tsx new file mode 100644 index 00000000000..8b20ee291fa --- /dev/null +++ b/erp/resources/js/Components/Charts/BarChart.tsx @@ -0,0 +1,60 @@ +interface DataPoint { + label: string; + value: number; +} + +interface BarChartProps { + data: DataPoint[]; + valueFormatter?: (n: number) => string; + color?: string; + horizontal?: boolean; +} + +export function BarChart({ data, valueFormatter, color = 'bg-indigo-500', horizontal = false }: BarChartProps) { + const max = Math.max(...data.map((d) => d.value), 1); + const fmt = valueFormatter ?? ((n) => String(n)); + + if (horizontal) { + return ( +
+ {data.map((d) => ( +
+ {d.label} +
+
+
+ + {fmt(d.value)} + +
+ ))} + {data.length === 0 && ( +

No data

+ )} +
+ ); + } + + return ( +
+ {data.map((d) => ( +
+ + {d.value > 0 ? fmt(d.value) : ''} + +
+ {d.label} +
+ ))} + {data.length === 0 && ( +

No data

+ )} +
+ ); +} diff --git a/erp/resources/js/Components/Charts/LineChart.tsx b/erp/resources/js/Components/Charts/LineChart.tsx new file mode 100644 index 00000000000..e38d134b093 --- /dev/null +++ b/erp/resources/js/Components/Charts/LineChart.tsx @@ -0,0 +1,72 @@ +interface DataPoint { + label: string; + value: number; +} + +interface LineChartProps { + data: DataPoint[]; + valueFormatter?: (n: number) => string; +} + +export function LineChart({ data, valueFormatter }: LineChartProps) { + if (data.length < 2) { + return

Not enough data

; + } + + const W = 600; + const H = 140; + const padX = 10; + const padY = 12; + const plotW = W - padX * 2; + const plotH = H - padY * 2; + + const max = Math.max(...data.map((d) => d.value), 1); + + const pts = data.map((d, i) => ({ + x: padX + (i / (data.length - 1)) * plotW, + y: H - padY - (d.value / max) * plotH, + ...d, + })); + + const polyline = pts.map((p) => `${p.x},${p.y}`).join(' '); + const areaPath = `M${padX},${H - padY} ${pts.map((p) => `L${p.x},${p.y}`).join(' ')} L${W - padX},${H - padY} Z`; + + return ( +
+ + {/* Grid lines */} + {[0, 0.25, 0.5, 0.75, 1].map((t) => ( + + ))} + {/* Area fill */} + + {/* Line */} + + {/* Dots */} + {pts.map((p) => ( + + ))} + + {/* Labels */} +
+ {data.map((d, i) => ( + i === 0 || i === data.length - 1 || i % Math.ceil(data.length / 6) === 0 + ? {d.label} + : + ))} +
+
+ ); +} diff --git a/erp/resources/js/Components/Checkbox.tsx b/erp/resources/js/Components/Checkbox.tsx new file mode 100644 index 00000000000..a65ffce7a94 --- /dev/null +++ b/erp/resources/js/Components/Checkbox.tsx @@ -0,0 +1,17 @@ +import { InputHTMLAttributes } from 'react'; + +export default function Checkbox({ + className = '', + ...props +}: InputHTMLAttributes) { + return ( + + ); +} diff --git a/erp/resources/js/Components/Common/Badge.tsx b/erp/resources/js/Components/Common/Badge.tsx new file mode 100644 index 00000000000..7403fa7a3b0 --- /dev/null +++ b/erp/resources/js/Components/Common/Badge.tsx @@ -0,0 +1,42 @@ +import { ReactNode } from 'react'; + +type BadgeColor = + | 'gray' + | 'red' + | 'yellow' + | 'green' + | 'blue' + | 'indigo' + | 'purple' + | 'pink'; + +interface BadgeProps { + color?: BadgeColor; + children: ReactNode; + className?: string; +} + +const colorClasses: Record = { + gray: 'bg-slate-100 text-slate-700', + red: 'bg-red-100 text-red-700', + yellow: 'bg-yellow-100 text-yellow-800', + green: 'bg-green-100 text-green-700', + blue: 'bg-blue-100 text-blue-700', + indigo: 'bg-indigo-100 text-indigo-700', + purple: 'bg-purple-100 text-purple-700', + pink: 'bg-pink-100 text-pink-700', +}; + +export function Badge({ color = 'gray', children, className = '' }: BadgeProps) { + return ( + + {children} + + ); +} diff --git a/erp/resources/js/Components/Common/Button.tsx b/erp/resources/js/Components/Common/Button.tsx new file mode 100644 index 00000000000..46e16189e8f --- /dev/null +++ b/erp/resources/js/Components/Common/Button.tsx @@ -0,0 +1,77 @@ +import { ButtonHTMLAttributes, ReactNode } from 'react'; + +type Variant = 'primary' | 'secondary' | 'danger' | 'ghost'; +type Size = 'sm' | 'md' | 'lg'; + +interface ButtonProps extends ButtonHTMLAttributes { + variant?: Variant; + size?: Size; + loading?: boolean; + children: ReactNode; +} + +const variantClasses: Record = { + primary: + 'bg-indigo-600 text-white hover:bg-indigo-700 focus:ring-indigo-500 disabled:bg-indigo-400', + secondary: + 'bg-white text-slate-700 border border-slate-300 hover:bg-slate-50 focus:ring-indigo-500', + danger: + 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500 disabled:bg-red-400', + ghost: + 'bg-transparent text-slate-600 hover:bg-slate-100 focus:ring-slate-400', +}; + +const sizeClasses: Record = { + sm: 'px-3 py-1.5 text-sm', + md: 'px-4 py-2 text-sm', + lg: 'px-6 py-3 text-base', +}; + +export function Button({ + variant = 'primary', + size = 'md', + loading = false, + disabled, + children, + className = '', + ...props +}: ButtonProps) { + return ( + + ); +} diff --git a/erp/resources/js/Components/Common/Input.tsx b/erp/resources/js/Components/Common/Input.tsx new file mode 100644 index 00000000000..e0e9da9c3e7 --- /dev/null +++ b/erp/resources/js/Components/Common/Input.tsx @@ -0,0 +1,63 @@ +import { InputHTMLAttributes, ReactNode, useId } from 'react'; + +interface InputProps extends InputHTMLAttributes { + label?: string; + error?: string; + helper?: string; + leadingIcon?: ReactNode; +} + +export function Input({ + label, + error, + helper, + leadingIcon, + className = '', + id: providedId, + ...props +}: InputProps) { + const generatedId = useId(); + const id = providedId ?? generatedId; + + return ( +
+ {label && ( + + )} +
+ {leadingIcon && ( +
+ {leadingIcon} +
+ )} + +
+ {error && ( +

{error}

+ )} + {!error && helper && ( +

{helper}

+ )} +
+ ); +} diff --git a/erp/resources/js/Components/Common/Modal.tsx b/erp/resources/js/Components/Common/Modal.tsx new file mode 100644 index 00000000000..0b588b32f6f --- /dev/null +++ b/erp/resources/js/Components/Common/Modal.tsx @@ -0,0 +1,91 @@ +import { + ReactNode, + useEffect, + useRef, + KeyboardEvent, + MouseEvent, +} from 'react'; +import { createPortal } from 'react-dom'; + +interface ModalProps { + open: boolean; + onClose: () => void; + title?: string; + children: ReactNode; + size?: 'sm' | 'md' | 'lg' | 'xl'; +} + +const sizeClasses = { + sm: 'max-w-sm', + md: 'max-w-md', + lg: 'max-w-lg', + xl: 'max-w-2xl', +}; + +export function Modal({ open, onClose, title, children, size = 'md' }: ModalProps) { + const dialogRef = useRef(null); + + useEffect(() => { + if (open) { + dialogRef.current?.focus(); + document.body.style.overflow = 'hidden'; + } else { + document.body.style.overflow = ''; + } + return () => { + document.body.style.overflow = ''; + }; + }, [open]); + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + + const handleBackdropClick = (e: MouseEvent) => { + if (e.target === e.currentTarget) onClose(); + }; + + if (!open) return null; + + return createPortal( +
+
+ {title && ( +
+ + +
+ )} +
{children}
+
+
, + document.body, + ); +} diff --git a/erp/resources/js/Components/Common/Table.tsx b/erp/resources/js/Components/Common/Table.tsx new file mode 100644 index 00000000000..d36cf07cb0f --- /dev/null +++ b/erp/resources/js/Components/Common/Table.tsx @@ -0,0 +1,96 @@ +import { ReactNode } from 'react'; + +export interface Column { + key: string; + header: string; + render?: (row: T) => ReactNode; + sortable?: boolean; + className?: string; +} + +interface TableProps { + columns: Column[]; + data: T[]; + emptyMessage?: string; + onSort?: (key: string) => void; + sortKey?: string; + sortDir?: 'asc' | 'desc'; +} + +export function Table({ + columns, + data, + emptyMessage = 'No records found.', + onSort, + sortKey, + sortDir, +}: TableProps) { + return ( +
+ + + + {columns.map((col) => ( + + ))} + + + + {data.length === 0 ? ( + + + + ) : ( + data.map((row) => ( + + {columns.map((col) => ( + + ))} + + )) + )} + +
onSort?.(col.key) : undefined} + > + + {col.header} + {col.sortable && sortKey === col.key && ( + + + + )} + +
+ {emptyMessage} +
+ {col.render + ? col.render(row) + : String((row as Record)[col.key] ?? '')} +
+
+ ); +} diff --git a/erp/resources/js/Components/DangerButton.tsx b/erp/resources/js/Components/DangerButton.tsx new file mode 100644 index 00000000000..07954b4c87c --- /dev/null +++ b/erp/resources/js/Components/DangerButton.tsx @@ -0,0 +1,22 @@ +import { ButtonHTMLAttributes } from 'react'; + +export default function DangerButton({ + className = '', + disabled, + children, + ...props +}: ButtonHTMLAttributes) { + return ( + + ); +} diff --git a/erp/resources/js/Components/DataGrid.tsx b/erp/resources/js/Components/DataGrid.tsx new file mode 100644 index 00000000000..1a821ed2e1b --- /dev/null +++ b/erp/resources/js/Components/DataGrid.tsx @@ -0,0 +1,222 @@ +import React, { useState, useMemo, useCallback } from 'react'; + +export type SortDir = 'asc' | 'desc'; + +export interface Column { + key: string; + label: string; + sortable?: boolean; + className?: string; + render?: (row: T, idx: number) => React.ReactNode; +} + +interface Props { + columns: Column[]; + rows: T[]; + rowKey: (row: T) => string | number; + selectable?: boolean; + onSelectionChange?: (selected: (string | number)[]) => void; + pageSize?: number; + emptyMessage?: string; + loading?: boolean; + stickyHeader?: boolean; + actions?: (row: T) => React.ReactNode; +} + +export default function DataGrid>({ + columns, + rows, + rowKey, + selectable = false, + onSelectionChange, + pageSize = 25, + emptyMessage = 'No records found.', + loading = false, + stickyHeader = false, + actions, +}: Props) { + const [sortKey, setSortKey] = useState(null); + const [sortDir, setSortDir] = useState('asc'); + const [selected, setSelected] = useState>(new Set()); + const [page, setPage] = useState(1); + + const sorted = useMemo(() => { + if (!sortKey) return rows; + return [...rows].sort((a, b) => { + const va = a[sortKey] as string | number | null | undefined; + const vb = b[sortKey] as string | number | null | undefined; + if (va == null && vb == null) return 0; + if (va == null) return 1; + if (vb == null) return -1; + const cmp = va < vb ? -1 : va > vb ? 1 : 0; + return sortDir === 'asc' ? cmp : -cmp; + }); + }, [rows, sortKey, sortDir]); + + const totalPages = Math.max(1, Math.ceil(sorted.length / pageSize)); + const paged = sorted.slice((page - 1) * pageSize, page * pageSize); + + const handleSort = (key: string) => { + if (sortKey === key) { + setSortDir((d) => (d === 'asc' ? 'desc' : 'asc')); + } else { + setSortKey(key); + setSortDir('asc'); + } + setPage(1); + }; + + const toggleRow = useCallback((id: string | number) => { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + onSelectionChange?.([...next]); + return next; + }); + }, [onSelectionChange]); + + const toggleAll = useCallback(() => { + if (selected.size === paged.length) { + setSelected(new Set()); + onSelectionChange?.([]); + } else { + const allIds = paged.map((r) => rowKey(r)); + setSelected(new Set(allIds)); + onSelectionChange?.(allIds); + } + }, [selected, paged, rowKey, onSelectionChange]); + + const SortIcon = ({ col }: { col: string }) => { + if (sortKey !== col) return ; + return {sortDir === 'asc' ? '↑' : '↓'}; + }; + + return ( +
+
+ + + + {selectable && ( + + )} + {columns.map((col) => ( + + ))} + {actions && } + + + + {loading ? ( + + + + ) : paged.length === 0 ? ( + + + + ) : ( + paged.map((row, idx) => { + const id = rowKey(row); + const isSelected = selected.has(id); + return ( + + {selectable && ( + + )} + {columns.map((col) => ( + + ))} + {actions && ( + + )} + + ); + }) + )} + +
+ 0} + onChange={toggleAll} + className="rounded border-gray-300 text-blue-600 focus:ring-blue-500" + /> + handleSort(col.key) : undefined} + > + {col.label} + {col.sortable && } + Actions
+
+ + + + + Loading… +
+
{emptyMessage}
+ toggleRow(id)} + className="rounded border-gray-300 text-blue-600 focus:ring-blue-500" + /> + + {col.render ? col.render(row, idx) : String(row[col.key] ?? '—')} + {actions(row)}
+
+ + {totalPages > 1 && ( +
+ + Showing {(page - 1) * pageSize + 1}–{Math.min(page * pageSize, sorted.length)} of {sorted.length} + +
+ + + {Array.from({ length: Math.min(5, totalPages) }, (_, i) => { + const start = Math.max(1, Math.min(page - 2, totalPages - 4)); + const n = start + i; + return ( + + ); + })} + + +
+
+ )} +
+ ); +} diff --git a/erp/resources/js/Components/Dropdown.tsx b/erp/resources/js/Components/Dropdown.tsx new file mode 100644 index 00000000000..1e6ccb30087 --- /dev/null +++ b/erp/resources/js/Components/Dropdown.tsx @@ -0,0 +1,130 @@ +import { Transition } from '@headlessui/react'; +import { InertiaLinkProps, Link } from '@inertiajs/react'; +import { + createContext, + Dispatch, + PropsWithChildren, + SetStateAction, + useContext, + useState, +} from 'react'; + +const DropDownContext = createContext<{ + open: boolean; + setOpen: Dispatch>; + toggleOpen: () => void; +}>({ + open: false, + setOpen: () => {}, + toggleOpen: () => {}, +}); + +const Dropdown = ({ children }: PropsWithChildren) => { + const [open, setOpen] = useState(false); + + const toggleOpen = () => { + setOpen((previousState) => !previousState); + }; + + return ( + +
{children}
+
+ ); +}; + +const Trigger = ({ children }: PropsWithChildren) => { + const { open, setOpen, toggleOpen } = useContext(DropDownContext); + + return ( + <> +
{children}
+ + {open && ( +
setOpen(false)} + >
+ )} + + ); +}; + +const Content = ({ + align = 'right', + width = '48', + contentClasses = 'py-1 bg-white', + children, +}: PropsWithChildren<{ + align?: 'left' | 'right'; + width?: '48'; + contentClasses?: string; +}>) => { + const { open, setOpen } = useContext(DropDownContext); + + let alignmentClasses = 'origin-top'; + + if (align === 'left') { + alignmentClasses = 'ltr:origin-top-left rtl:origin-top-right start-0'; + } else if (align === 'right') { + alignmentClasses = 'ltr:origin-top-right rtl:origin-top-left end-0'; + } + + let widthClasses = ''; + + if (width === '48') { + widthClasses = 'w-48'; + } + + return ( + <> + +
setOpen(false)} + > +
+ {children} +
+
+
+ + ); +}; + +const DropdownLink = ({ + className = '', + children, + ...props +}: InertiaLinkProps) => { + return ( + + {children} + + ); +}; + +Dropdown.Trigger = Trigger; +Dropdown.Content = Content; +Dropdown.Link = DropdownLink; + +export default Dropdown; diff --git a/erp/resources/js/Components/FileDropzone.tsx b/erp/resources/js/Components/FileDropzone.tsx new file mode 100644 index 00000000000..bc1aea8e2ea --- /dev/null +++ b/erp/resources/js/Components/FileDropzone.tsx @@ -0,0 +1,172 @@ +import React, { useCallback, useRef, useState } from 'react'; + +export interface UploadedFile { + file: File; + name: string; + size: number; + preview?: string; + status: 'pending' | 'uploading' | 'done' | 'error'; + progress: number; + error?: string; +} + +interface Props { + onUpload: (files: File[]) => void | Promise; + accept?: string; + maxSizeMb?: number; + multiple?: boolean; + label?: string; + hint?: string; +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +export default function FileDropzone({ + onUpload, + accept, + maxSizeMb = 10, + multiple = true, + label = 'Drop files here or click to browse', + hint, +}: Props) { + const [isDragging, setIsDragging] = useState(false); + const [files, setFiles] = useState([]); + const inputRef = useRef(null); + + const maxBytes = maxSizeMb * 1024 * 1024; + + const processFiles = useCallback(async (selected: FileList | null) => { + if (!selected) return; + + const newFiles: UploadedFile[] = Array.from(selected).map((file) => ({ + file, + name: file.name, + size: file.size, + status: file.size > maxBytes ? 'error' : 'pending', + progress: 0, + error: file.size > maxBytes ? `File too large (max ${maxSizeMb}MB)` : undefined, + preview: file.type.startsWith('image/') ? URL.createObjectURL(file) : undefined, + })); + + setFiles((prev) => [...prev, ...newFiles]); + + const valid = newFiles.filter((f) => f.status === 'pending').map((f) => f.file); + if (valid.length === 0) return; + + setFiles((prev) => + prev.map((f) => valid.includes(f.file) ? { ...f, status: 'uploading' } : f) + ); + + try { + await onUpload(valid); + setFiles((prev) => + prev.map((f) => valid.includes(f.file) ? { ...f, status: 'done', progress: 100 } : f) + ); + } catch { + setFiles((prev) => + prev.map((f) => valid.includes(f.file) ? { ...f, status: 'error', error: 'Upload failed' } : f) + ); + } + }, [onUpload, maxBytes, maxSizeMb]); + + const onDrop = useCallback((e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + processFiles(e.dataTransfer.files); + }, [processFiles]); + + const onDragOver = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(true); }; + const onDragLeave = () => setIsDragging(false); + + const removeFile = (idx: number) => { + setFiles((prev) => { + const file = prev[idx]; + if (file.preview) URL.revokeObjectURL(file.preview); + return prev.filter((_, i) => i !== idx); + }); + }; + + const STATUS_ICON: Record = { + pending: '⏳', + uploading: '⬆', + done: '✓', + error: '✗', + }; + + const STATUS_COLOR: Record = { + pending: 'text-gray-500', + uploading: 'text-blue-500', + done: 'text-green-600', + error: 'text-red-500', + }; + + return ( +
+
inputRef.current?.click()} + onDrop={onDrop} + onDragOver={onDragOver} + onDragLeave={onDragLeave} + className={`relative flex flex-col items-center justify-center gap-2 px-6 py-10 border-2 border-dashed rounded-xl cursor-pointer transition-colors ${ + isDragging + ? 'border-blue-500 bg-blue-50' + : 'border-gray-300 hover:border-blue-400 hover:bg-gray-50' + }`} + > +
📁
+
{label}
+ {hint &&
{hint}
} +
Max {maxSizeMb}MB per file
+ processFiles(e.target.files)} + /> +
+ + {files.length > 0 && ( +
    + {files.map((f, idx) => ( +
  • + {f.preview ? ( + {f.name} + ) : ( +
    + 📄 +
    + )} +
    +
    {f.name}
    +
    {formatBytes(f.size)}
    + {f.status === 'uploading' && ( +
    +
    +
    + )} + {f.error &&
    {f.error}
    } +
    + + {STATUS_ICON[f.status]} + + +
  • + ))} +
+ )} +
+ ); +} diff --git a/erp/resources/js/Components/Finance/AssetStatusBadge.tsx b/erp/resources/js/Components/Finance/AssetStatusBadge.tsx new file mode 100644 index 00000000000..eb5844fc74f --- /dev/null +++ b/erp/resources/js/Components/Finance/AssetStatusBadge.tsx @@ -0,0 +1,18 @@ +interface Props { + status: 'active' | 'disposed' | 'fully_depreciated'; +} + +const config: Record = { + active: { label: 'Active', classes: 'bg-green-100 text-green-800' }, + disposed: { label: 'Disposed', classes: 'bg-slate-100 text-slate-700' }, + fully_depreciated: { label: 'Fully Depreciated', classes: 'bg-amber-100 text-amber-800' }, +}; + +export function AssetStatusBadge({ status }: Props) { + const { label, classes } = config[status] ?? { label: status, classes: 'bg-gray-100 text-gray-700' }; + return ( + + {label} + + ); +} diff --git a/erp/resources/js/Components/Finance/AttachmentPanel.tsx b/erp/resources/js/Components/Finance/AttachmentPanel.tsx new file mode 100644 index 00000000000..c522dbb8372 --- /dev/null +++ b/erp/resources/js/Components/Finance/AttachmentPanel.tsx @@ -0,0 +1,78 @@ +import { useForm } from '@inertiajs/react'; +import { Button } from '@/Components/Common/Button'; +import type { Attachment } from '@/types/finance'; + +interface Props { + attachments: Attachment[]; + modelType: string; + modelId: number; + canDelete?: boolean; +} + +export default function AttachmentPanel({ attachments, modelType, modelId, canDelete = true }: Props) { + const { data, setData, post, processing, reset } = useForm<{ file: File | null }>({ file: null }); + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!data.file) return; + post(`/finance/attachments/${modelType}/${modelId}`, { + forceFormData: true, + onSuccess: () => reset(), + }); + } + + function formatSize(bytes: number | null): string { + if (!bytes) return ''; + return bytes < 1024 ? `${bytes} B` : `${Math.round(bytes / 1024)} KB`; + } + + return ( +
+

Attachments

+ + {attachments.length === 0 && ( +

No attachments yet.

+ )} + +
    + {attachments.map((a) => ( +
  • +
    + {a.filename} + {a.size && {formatSize(a.size)}} +
    +
    + + Download + + {canDelete && ( +
    { + e.preventDefault(); + if (confirm('Delete attachment?')) (e.target as HTMLFormElement).submit(); + }}> + + +
    + )} +
    +
  • + ))} +
+ +
+ setData('file', e.target.files?.[0] ?? null)} + className="text-sm text-slate-600 file:mr-3 file:rounded file:border-0 file:bg-indigo-50 file:px-3 file:py-1 file:text-xs file:font-medium file:text-indigo-700 hover:file:bg-indigo-100" + /> + +
+
+ ); +} diff --git a/erp/resources/js/Components/Finance/BillStatusBadge.tsx b/erp/resources/js/Components/Finance/BillStatusBadge.tsx new file mode 100644 index 00000000000..1f49b4c1bcb --- /dev/null +++ b/erp/resources/js/Components/Finance/BillStatusBadge.tsx @@ -0,0 +1,16 @@ +import type { BillStatus } from '@/types/finance'; + +const map: Record = { + draft: 'bg-slate-100 text-slate-600', + received: 'bg-blue-100 text-blue-700', + paid: 'bg-green-100 text-green-700', + cancelled: 'bg-red-100 text-red-600', +}; + +export function BillStatusBadge({ status }: { status: BillStatus }) { + return ( + + {status.charAt(0).toUpperCase() + status.slice(1)} + + ); +} diff --git a/erp/resources/js/Components/Finance/BudgetStatusBadge.tsx b/erp/resources/js/Components/Finance/BudgetStatusBadge.tsx new file mode 100644 index 00000000000..c4abd4e8b9d --- /dev/null +++ b/erp/resources/js/Components/Finance/BudgetStatusBadge.tsx @@ -0,0 +1,16 @@ +type BudgetStatus = 'draft' | 'active' | 'closed' | 'archived'; + +const map: Record = { + draft: 'bg-slate-100 text-slate-600', + active: 'bg-green-100 text-green-700', + closed: 'bg-red-100 text-red-700', + archived: 'bg-slate-100 text-slate-500', +}; + +export function BudgetStatusBadge({ status }: { status: BudgetStatus }) { + return ( + + {status} + + ); +} diff --git a/erp/resources/js/Components/Finance/CreditNoteStatusBadge.tsx b/erp/resources/js/Components/Finance/CreditNoteStatusBadge.tsx new file mode 100644 index 00000000000..46219cae8e5 --- /dev/null +++ b/erp/resources/js/Components/Finance/CreditNoteStatusBadge.tsx @@ -0,0 +1,16 @@ +import type { CreditNoteStatus } from '@/types/finance'; + +const COLORS: Record = { + draft: 'bg-slate-100 text-slate-600', + issued: 'bg-blue-100 text-blue-700', + applied: 'bg-green-100 text-green-700', + void: 'bg-red-100 text-red-700', +}; + +export function CreditNoteStatusBadge({ status }: { status: CreditNoteStatus }) { + return ( + + {status.charAt(0).toUpperCase() + status.slice(1)} + + ); +} diff --git a/erp/resources/js/Components/Finance/InvoiceStatusBadge.tsx b/erp/resources/js/Components/Finance/InvoiceStatusBadge.tsx new file mode 100644 index 00000000000..19337fade5d --- /dev/null +++ b/erp/resources/js/Components/Finance/InvoiceStatusBadge.tsx @@ -0,0 +1,16 @@ +import type { InvoiceStatus } from '@/types/finance'; + +const map: Record = { + draft: 'bg-slate-100 text-slate-600', + sent: 'bg-blue-100 text-blue-700', + paid: 'bg-green-100 text-green-700', + cancelled: 'bg-red-100 text-red-600', +}; + +export function InvoiceStatusBadge({ status }: { status: InvoiceStatus }) { + return ( + + {status} + + ); +} diff --git a/erp/resources/js/Components/Finance/JournalEntryStatusBadge.tsx b/erp/resources/js/Components/Finance/JournalEntryStatusBadge.tsx new file mode 100644 index 00000000000..511121b3553 --- /dev/null +++ b/erp/resources/js/Components/Finance/JournalEntryStatusBadge.tsx @@ -0,0 +1,12 @@ +const map: Record = { + draft: 'bg-slate-100 text-slate-600', + posted: 'bg-green-100 text-green-700', +}; + +export function JournalEntryStatusBadge({ status }: { status: string }) { + return ( + + {status} + + ); +} diff --git a/erp/resources/js/Components/Finance/QuoteStatusBadge.tsx b/erp/resources/js/Components/Finance/QuoteStatusBadge.tsx new file mode 100644 index 00000000000..37dff9b4a53 --- /dev/null +++ b/erp/resources/js/Components/Finance/QuoteStatusBadge.tsx @@ -0,0 +1,17 @@ +import type { QuoteStatus } from '@/types/finance'; + +const COLORS: Record = { + draft: 'bg-slate-100 text-slate-600', + sent: 'bg-blue-100 text-blue-700', + accepted: 'bg-green-100 text-green-700', + declined: 'bg-red-100 text-red-700', + cancelled: 'bg-red-100 text-red-600', +}; + +export function QuoteStatusBadge({ status }: { status: QuoteStatus }) { + return ( + + {status.charAt(0).toUpperCase() + status.slice(1)} + + ); +} diff --git a/erp/resources/js/Components/Finance/RecurringStatusBadge.tsx b/erp/resources/js/Components/Finance/RecurringStatusBadge.tsx new file mode 100644 index 00000000000..3a388046bfc --- /dev/null +++ b/erp/resources/js/Components/Finance/RecurringStatusBadge.tsx @@ -0,0 +1,15 @@ +import type { RecurringStatus } from '@/types/finance'; + +const COLORS: Record = { + active: 'bg-green-100 text-green-700', + paused: 'bg-amber-100 text-amber-700', + ended: 'bg-slate-100 text-slate-600', +}; + +export function RecurringStatusBadge({ status }: { status: RecurringStatus }) { + return ( + + {status.charAt(0).toUpperCase() + status.slice(1)} + + ); +} diff --git a/erp/resources/js/Components/Finance/SalesOrderStatusBadge.tsx b/erp/resources/js/Components/Finance/SalesOrderStatusBadge.tsx new file mode 100644 index 00000000000..2255cb87c2c --- /dev/null +++ b/erp/resources/js/Components/Finance/SalesOrderStatusBadge.tsx @@ -0,0 +1,16 @@ +import type { SalesOrderStatus } from '@/types/finance'; + +const COLORS: Record = { + draft: 'bg-slate-100 text-slate-600', + confirmed: 'bg-blue-100 text-blue-700', + fulfilled: 'bg-green-100 text-green-700', + cancelled: 'bg-red-100 text-red-600', +}; + +export function SalesOrderStatusBadge({ status }: { status: SalesOrderStatus }) { + return ( + + {status.charAt(0).toUpperCase() + status.slice(1)} + + ); +} diff --git a/erp/resources/js/Components/HR/EmployeeStatusBadge.tsx b/erp/resources/js/Components/HR/EmployeeStatusBadge.tsx new file mode 100644 index 00000000000..bea5d78af25 --- /dev/null +++ b/erp/resources/js/Components/HR/EmployeeStatusBadge.tsx @@ -0,0 +1,15 @@ +import type { EmployeeStatus } from '@/types/hr'; + +const map: Record = { + active: 'bg-green-100 text-green-700', + on_leave: 'bg-yellow-100 text-yellow-700', + terminated: 'bg-red-100 text-red-600', +}; + +export function EmployeeStatusBadge({ status }: { status: EmployeeStatus }) { + return ( + + {status.replace('_', ' ')} + + ); +} diff --git a/erp/resources/js/Components/HR/ExpenseStatusBadge.tsx b/erp/resources/js/Components/HR/ExpenseStatusBadge.tsx new file mode 100644 index 00000000000..58a502acc84 --- /dev/null +++ b/erp/resources/js/Components/HR/ExpenseStatusBadge.tsx @@ -0,0 +1,17 @@ +export type ExpenseStatus = 'draft' | 'submitted' | 'approved' | 'rejected' | 'reimbursed'; + +const map: Record = { + draft: 'bg-slate-100 text-slate-600', + submitted: 'bg-blue-100 text-blue-700', + approved: 'bg-green-100 text-green-700', + rejected: 'bg-red-100 text-red-600', + reimbursed: 'bg-purple-100 text-purple-700', +}; + +export function ExpenseStatusBadge({ status }: { status: ExpenseStatus }) { + return ( + + {status} + + ); +} diff --git a/erp/resources/js/Components/HR/LeaveStatusBadge.tsx b/erp/resources/js/Components/HR/LeaveStatusBadge.tsx new file mode 100644 index 00000000000..d603fcea8e0 --- /dev/null +++ b/erp/resources/js/Components/HR/LeaveStatusBadge.tsx @@ -0,0 +1,16 @@ +import type { LeaveStatus } from '@/types/hr'; + +const map: Record = { + pending: 'bg-yellow-100 text-yellow-700', + approved: 'bg-green-100 text-green-700', + rejected: 'bg-red-100 text-red-600', + cancelled: 'bg-slate-100 text-slate-600', +}; + +export function LeaveStatusBadge({ status }: { status: LeaveStatus }) { + return ( + + {status} + + ); +} diff --git a/erp/resources/js/Components/HR/PayrollStatusBadge.tsx b/erp/resources/js/Components/HR/PayrollStatusBadge.tsx new file mode 100644 index 00000000000..20648ef206f --- /dev/null +++ b/erp/resources/js/Components/HR/PayrollStatusBadge.tsx @@ -0,0 +1,15 @@ +import type { PayrollStatus } from '@/types/hr'; + +const map: Record = { + draft: 'bg-slate-100 text-slate-600', + processed: 'bg-blue-100 text-blue-700', + paid: 'bg-green-100 text-green-700', +}; + +export function PayrollStatusBadge({ status }: { status: PayrollStatus }) { + return ( + + {status} + + ); +} diff --git a/erp/resources/js/Components/InputError.tsx b/erp/resources/js/Components/InputError.tsx new file mode 100644 index 00000000000..e20474fc944 --- /dev/null +++ b/erp/resources/js/Components/InputError.tsx @@ -0,0 +1,16 @@ +import { HTMLAttributes } from 'react'; + +export default function InputError({ + message, + className = '', + ...props +}: HTMLAttributes & { message?: string }) { + return message ? ( +

+ {message} +

+ ) : null; +} diff --git a/erp/resources/js/Components/InputLabel.tsx b/erp/resources/js/Components/InputLabel.tsx new file mode 100644 index 00000000000..61e6c364133 --- /dev/null +++ b/erp/resources/js/Components/InputLabel.tsx @@ -0,0 +1,20 @@ +import { LabelHTMLAttributes } from 'react'; + +export default function InputLabel({ + value, + className = '', + children, + ...props +}: LabelHTMLAttributes & { value?: string }) { + return ( + + ); +} diff --git a/erp/resources/js/Components/Inventory/Pagination.tsx b/erp/resources/js/Components/Inventory/Pagination.tsx new file mode 100644 index 00000000000..f23a78f47ab --- /dev/null +++ b/erp/resources/js/Components/Inventory/Pagination.tsx @@ -0,0 +1,53 @@ +import { Link } from '@inertiajs/react'; +import type { Paginator } from '@/types/inventory'; + +interface Props { + paginator: Paginator; + preserveScroll?: boolean; +} + +export function Pagination({ paginator, preserveScroll = true }: Props) { + const { current_page, last_page, from, to, total, prev_page_url, next_page_url } = paginator; + + if (last_page <= 1) return null; + + return ( +
+

+ Showing {from}{to} of{' '} + {total} results +

+
+ {prev_page_url ? ( + + Previous + + ) : ( + + Previous + + )} + + {current_page} / {last_page} + + {next_page_url ? ( + + Next + + ) : ( + + Next + + )} +
+
+ ); +} diff --git a/erp/resources/js/Components/Inventory/ProductForm.tsx b/erp/resources/js/Components/Inventory/ProductForm.tsx new file mode 100644 index 00000000000..b757c6b6673 --- /dev/null +++ b/erp/resources/js/Components/Inventory/ProductForm.tsx @@ -0,0 +1,171 @@ +import { useForm } from '@inertiajs/react'; +import { Button } from '@/Components/Common/Button'; +import { Input } from '@/Components/Common/Input'; +import type { ProductCategory, UnitOfMeasure } from '@/types/inventory'; + +interface ProductFormData { + sku: string; + name: string; + description: string; + category_id: string; + uom_id: string; + cost_price: string; + sale_price: string; + reorder_point: string; + is_active: boolean; + [key: string]: string | boolean; +} + +interface Props { + categories: Pick[]; + uoms: UnitOfMeasure[]; + defaults?: Partial; + action: string; + method?: 'post' | 'put' | 'patch'; + submitLabel?: string; + cancelHref?: string; +} + +export function ProductForm({ categories, uoms, defaults = {}, action, method = 'post', submitLabel = 'Save', cancelHref }: Props) { + const { data, setData, submit, processing, errors } = useForm({ + sku: defaults.sku ?? '', + name: defaults.name ?? '', + description: defaults.description ?? '', + category_id: defaults.category_id ?? '', + uom_id: defaults.uom_id ?? '', + cost_price: defaults.cost_price ?? '', + sale_price: defaults.sale_price ?? '', + reorder_point: defaults.reorder_point ?? '0', + is_active: defaults.is_active ?? true, + }); + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + submit(method, action); + } + + return ( +
+
+
+ + setData('sku', e.target.value)} + placeholder="e.g. LAP-001" + required + /> + {errors.sku &&

{errors.sku}

} +
+
+ + setData('name', e.target.value)} + placeholder="Product name" + required + /> + {errors.name &&

{errors.name}

} +
+
+ +