From b40ee9dcde9b42830aab600a84dddb9a53a3afc6 Mon Sep 17 00:00:00 2001 From: Simon Date: Sun, 28 Sep 2025 14:51:02 +0200 Subject: [PATCH] Changes 0228092025 Laptop --- .github/copilot-instructions.md | 375 ++++++++++++++++++ app/Http/Controllers/ClientCaseContoller.php | 96 ++++- .../Controllers/ContractConfigController.php | 72 ++++ .../Controllers/FieldJobSettingController.php | 21 +- app/Http/Controllers/SegmentController.php | 26 ++ app/Http/Controllers/WorkflowController.php | 32 +- app/Http/Requests/StoreContractRequest.php | 37 ++ .../Requests/StoreFieldJobSettingRequest.php | 33 ++ app/Http/Requests/StoreSegmentRequest.php | 29 ++ app/Http/Requests/UpdateContractRequest.php | 39 ++ app/Http/Requests/UpdateSegmentRequest.php | 29 ++ app/Models/Action.php | 6 + app/Models/Contract.php | 68 ++++ app/Models/ContractConfig.php | 37 ++ app/Models/Decision.php | 5 + app/Models/FieldJob.php | 29 +- app/Models/FieldJobSetting.php | 6 + app/Models/Segment.php | 13 + composer.json | 1 + composer.lock | 192 ++++++++- ...00_alter_segments_description_nullable.php | 19 + ...11200_alter_field_jobs_add_contract_id.php | 27 ++ ...8_120500_create_contract_configs_table.php | 25 ++ ...lter_contract_configs_support_multiple.php | 64 +++ ..._contract_reference_per_client_case_v2.php | 44 ++ .../Pages/Cases/Partials/ContractDrawer.vue | 28 +- .../js/Pages/Cases/Partials/ContractTable.vue | 88 +++- resources/js/Pages/Cases/Show.vue | 55 ++- .../Pages/Settings/ContractConfigs/Index.vue | 172 ++++++++ .../js/Pages/Settings/FieldJob/Index.vue | 133 ++++++- resources/js/Pages/Settings/Index.vue | 5 + .../Pages/Settings/Partials/ActionTable.vue | 93 ++++- .../Pages/Settings/Partials/DecisionTable.vue | 73 +++- .../js/Pages/Settings/Segments/Index.vue | 155 +++++++- routes/breadcrumbs.php | 23 ++ routes/web.php | 14 + 36 files changed, 2099 insertions(+), 65 deletions(-) create mode 100644 .github/copilot-instructions.md create mode 100644 app/Http/Controllers/ContractConfigController.php create mode 100644 app/Http/Requests/StoreContractRequest.php create mode 100644 app/Http/Requests/StoreFieldJobSettingRequest.php create mode 100644 app/Http/Requests/StoreSegmentRequest.php create mode 100644 app/Http/Requests/UpdateContractRequest.php create mode 100644 app/Http/Requests/UpdateSegmentRequest.php create mode 100644 app/Models/ContractConfig.php create mode 100644 database/migrations/2025_09_28_110600_alter_segments_description_nullable.php create mode 100644 database/migrations/2025_09_28_111200_alter_field_jobs_add_contract_id.php create mode 100644 database/migrations/2025_09_28_120500_create_contract_configs_table.php create mode 100644 database/migrations/2025_09_28_121500_alter_contract_configs_support_multiple.php create mode 100644 database/migrations/2025_09_28_151500_add_unique_contract_reference_per_client_case_v2.php create mode 100644 resources/js/Pages/Settings/ContractConfigs/Index.vue diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..30545f3 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,375 @@ + +=== foundation rules === + +# Laravel Boost Guidelines + +The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications. + +## Foundational Context +This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions. + +- php - 8.4.6 +- inertiajs/inertia-laravel (INERTIA) - v2 +- laravel/fortify (FORTIFY) - v1 +- laravel/framework (LARAVEL) - v12 +- laravel/prompts (PROMPTS) - v0 +- laravel/sanctum (SANCTUM) - v4 +- laravel/scout (SCOUT) - v10 +- tightenco/ziggy (ZIGGY) - v2 +- laravel/mcp (MCP) - v0 +- laravel/pint (PINT) - v1 +- laravel/sail (SAIL) - v1 +- pestphp/pest (PEST) - v3 +- phpunit/phpunit (PHPUNIT) - v11 +- @inertiajs/vue3 (INERTIA) - v2 +- tailwindcss (TAILWINDCSS) - v3 +- vue (VUE) - v3 + + +## Conventions +- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming. +- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`. +- Check for existing components to reuse before writing a new one. + +## Verification Scripts +- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important. + +## Application Structure & Architecture +- Stick to existing directory structure - don't create new base folders without approval. +- Do not change the application's dependencies without approval. + +## Frontend Bundling +- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them. + +## Replies +- Be concise in your explanations - focus on what's important rather than explaining obvious details. + +## Documentation Files +- You must only create documentation files if explicitly requested by the user. + + +=== boost rules === + +## Laravel Boost +- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them. + +## Artisan +- Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters. + +## URLs +- Whenever you share a project URL with the user you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain / IP, and port. + +## Tinker / Debugging +- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly. +- Use the `database-query` tool when you only need to read from the database. + +## Reading Browser Logs With the `browser-logs` Tool +- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost. +- Only recent browser logs will be useful - ignore old logs. + +## Searching Documentation (Critically Important) +- Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages. +- The 'search-docs' tool is perfect for all Laravel related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc. +- You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches. +- Search the documentation before making code changes to ensure we are taking the correct approach. +- Use multiple, broad, simple, topic based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`. +- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`. + +### Available Search Syntax +- You can and should pass multiple queries at once. The most relevant results will be returned first. + +1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth' +2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit" +3. Quoted Phrases (Exact Position) - query="infinite scroll" - Words must be adjacent and in that order +4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit" +5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms + + +=== php rules === + +## PHP + +- Always use curly braces for control structures, even if it has one line. + +### Constructors +- Use PHP 8 constructor property promotion in `__construct()`. + - public function __construct(public GitHub $github) { } +- Do not allow empty `__construct()` methods with zero parameters. + +### Type Declarations +- Always use explicit return type declarations for methods and functions. +- Use appropriate PHP type hints for method parameters. + + +protected function isAccessible(User $user, ?string $path = null): bool +{ + ... +} + + +## Comments +- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on. + +## PHPDoc Blocks +- Add useful array shape type definitions for arrays when appropriate. + +## Enums +- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`. + + +=== inertia-laravel/core rules === + +## Inertia Core + +- Inertia.js components should be placed in the `resources/js/Pages` directory unless specified differently in the JS bundler (vite.config.js). +- Use `Inertia::render()` for server-side routing instead of traditional Blade views. +- Use `search-docs` for accurate guidance on all things Inertia. + + +// routes/web.php example +Route::get('/users', function () { + return Inertia::render('Users/Index', [ + 'users' => User::all() + ]); +}); + + + +=== inertia-laravel/v2 rules === + +## Inertia v2 + +- Make use of all Inertia features from v1 & v2. Check the documentation before making any changes to ensure we are taking the correct approach. + +### Inertia v2 New Features +- Polling +- Prefetching +- Deferred props +- Infinite scrolling using merging props and `WhenVisible` +- Lazy loading data on scroll + +### Deferred Props & Empty States +- When using deferred props on the frontend, you should add a nice empty state with pulsing / animated skeleton. + +### Inertia Form General Guidance +- Build forms using the `useForm` helper. Use the code examples and `search-docs` tool with a query of `useForm helper` for guidance. + + +=== laravel/core rules === + +## Do Things the Laravel Way + +- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool. +- If you're creating a generic PHP class, use `artisan make:class`. +- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior. + +### Database +- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins. +- Use Eloquent models and relationships before suggesting raw database queries +- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them. +- Generate code that prevents N+1 query problems by using eager loading. +- Use Laravel's query builder for very complex database operations. + +### Model Creation +- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`. + +### APIs & Eloquent Resources +- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention. + +### Controllers & Validation +- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages. +- Check sibling Form Requests to see if the application uses array or string based validation rules. + +### Queues +- Use queued jobs for time-consuming operations with the `ShouldQueue` interface. + +### Authentication & Authorization +- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.). + +### URL Generation +- When generating links to other pages, prefer named routes and the `route()` function. + +### Configuration +- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`. + +### Testing +- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model. +- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`. +- When creating tests, make use of `php artisan make:test [options] ` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests. + +### Vite Error +- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`. + + +=== laravel/v12 rules === + +## Laravel 12 + +- Use the `search-docs` tool to get version specific documentation. +- Since Laravel 11, Laravel has a new streamlined file structure which this project uses. + +### Laravel 12 Structure +- No middleware files in `app/Http/Middleware/`. +- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files. +- `bootstrap/providers.php` contains application specific service providers. +- **No app\Console\Kernel.php** - use `bootstrap/app.php` or `routes/console.php` for console configuration. +- **Commands auto-register** - files in `app/Console/Commands/` are automatically available and do not require manual registration. + +### Database +- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost. +- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`. + +### Models +- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models. + + +=== pint/core rules === + +## Laravel Pint Code Formatter + +- You must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style. +- Do not run `vendor/bin/pint --test`, simply run `vendor/bin/pint` to fix any formatting issues. + + +=== pest/core rules === + +## Pest + +### Testing +- If you need to verify a feature is working, write or update a Unit / Feature test. + +### Pest Tests +- All tests must be written using Pest. Use `php artisan make:test --pest `. +- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application. +- Tests should test all of the happy paths, failure paths, and weird paths. +- Tests live in the `tests/Feature` and `tests/Unit` directories. +- Pest tests look and behave like this: + +it('is true', function () { + expect(true)->toBeTrue(); +}); + + +### Running Tests +- Run the minimal number of tests using an appropriate filter before finalizing code edits. +- To run all tests: `php artisan test`. +- To run all tests in a file: `php artisan test tests/Feature/ExampleTest.php`. +- To filter on a particular test name: `php artisan test --filter=testName` (recommended after making a change to a related file). +- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing. + +### Pest Assertions +- When asserting status codes on a response, use the specific method like `assertForbidden` and `assertNotFound` instead of using `assertStatus(403)` or similar, e.g.: + +it('returns all', function () { + $response = $this->postJson('/api/docs', []); + + $response->assertSuccessful(); +}); + + +### Mocking +- Mocking can be very helpful when appropriate. +- When mocking, you can use the `Pest\Laravel\mock` Pest function, but always import it via `use function Pest\Laravel\mock;` before using it. Alternatively, you can use `$this->mock()` if existing tests do. +- You can also create partial mocks using the same import or self method. + +### Datasets +- Use datasets in Pest to simplify tests which have a lot of duplicated data. This is often the case when testing validation rules, so consider going with this solution when writing tests for validation rules. + + +it('has emails', function (string $email) { + expect($email)->not->toBeEmpty(); +})->with([ + 'james' => 'james@laravel.com', + 'taylor' => 'taylor@laravel.com', +]); + + + +=== inertia-vue/core rules === + +## Inertia + Vue + +- Vue components must have a single root element. +- Use `router.visit()` or `` for navigation instead of traditional links. + + + + import { Link } from '@inertiajs/vue3' + Home + + + + +=== inertia-vue/v2/forms rules === + +## Inertia + Vue Forms + + + + + + + + + + +=== tailwindcss/core rules === + +## Tailwind Core + +- Use Tailwind CSS classes to style HTML, check and use existing tailwind conventions within the project before writing your own. +- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc..) +- Think through class placement, order, priority, and defaults - remove redundant classes, add classes to parent or child carefully to limit repetition, group elements logically +- You can use the `search-docs` tool to get exact examples from the official documentation when needed. + +### Spacing +- When listing items, use gap utilities for spacing, don't use margins. + + +
+
Superior
+
Michigan
+
Erie
+
+
+ + +### Dark Mode +- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`. + + +=== tailwindcss/v3 rules === + +## Tailwind 3 + +- Always use Tailwind CSS v3 - verify you're using only classes supported by this version. + + +=== tests rules === + +## Test Enforcement + +- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass. +- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test` with a specific filename or filter. +
\ No newline at end of file diff --git a/app/Http/Controllers/ClientCaseContoller.php b/app/Http/Controllers/ClientCaseContoller.php index b26c4f3..c4c58b2 100644 --- a/app/Http/Controllers/ClientCaseContoller.php +++ b/app/Http/Controllers/ClientCaseContoller.php @@ -10,6 +10,8 @@ use Exception; use Illuminate\Database\QueryException; use Illuminate\Http\Request; +use App\Http\Requests\StoreContractRequest; +use App\Http\Requests\UpdateContractRequest; use Inertia\Inertia; class ClientCaseContoller extends Controller @@ -93,7 +95,7 @@ public function store(Request $request) return to_route('client.show', $client); } - public function storeContract(ClientCase $clientCase, Request $request) + public function storeContract(ClientCase $clientCase, StoreContractRequest $request) { \DB::transaction(function() use ($request, $clientCase){ @@ -106,6 +108,8 @@ public function storeContract(ClientCase $clientCase, Request $request) 'description' => $request->input('description'), ]); + // Note: Contract config auto-application is handled in Contract model created hook. + // Optionally create/update related account amounts $initial = $request->input('initial_amount'); $balance = $request->input('balance_amount'); @@ -121,7 +125,7 @@ public function storeContract(ClientCase $clientCase, Request $request) return to_route('clientCase.show', $clientCase); } - public function updateContract(ClientCase $clientCase, String $uuid, Request $request) + public function updateContract(ClientCase $clientCase, String $uuid, UpdateContractRequest $request) { $contract = Contract::where('uuid', $uuid)->firstOrFail(); @@ -209,6 +213,86 @@ public function deleteContract(ClientCase $clientCase, String $uuid, Request $re return to_route('clientCase.show', $clientCase); } + public function updateContractSegment(ClientCase $clientCase, string $uuid, Request $request) + { + $validated = $request->validate([ + 'segment_id' => ['required', 'integer', 'exists:segments,id'], + ]); + + $contract = $clientCase->contracts()->where('uuid', $uuid)->firstOrFail(); + + \DB::transaction(function () use ($contract, $validated) { + // Deactivate current active relation(s) + \DB::table('contract_segment') + ->where('contract_id', $contract->id) + ->where('active', true) + ->update(['active' => false]); + + // Attach or update the selected segment as active + $existing = \DB::table('contract_segment') + ->where('contract_id', $contract->id) + ->where('segment_id', $validated['segment_id']) + ->first(); + + if ($existing) { + \DB::table('contract_segment') + ->where('id', $existing->id) + ->update(['active' => true, 'updated_at' => now()]); + } else { + $contract->segments()->attach($validated['segment_id'], ['active' => true, 'created_at' => now(), 'updated_at' => now()]); + } + }); + + return back()->with('success', 'Contract segment updated.'); + } + + public function attachSegment(ClientCase $clientCase, Request $request) + { + $validated = $request->validate([ + 'segment_id' => ['required', 'integer', 'exists:segments,id'], + 'contract_uuid' => ['nullable', 'uuid'], + 'make_active_for_contract' => ['sometimes', 'boolean'], + ]); + + \DB::transaction(function () use ($clientCase, $validated) { + // Attach segment to client case if not already attached + $attached = \DB::table('client_case_segment') + ->where('client_case_id', $clientCase->id) + ->where('segment_id', $validated['segment_id']) + ->first(); + if (!$attached) { + $clientCase->segments()->attach($validated['segment_id'], ['active' => true]); + } else if (!$attached->active) { + \DB::table('client_case_segment') + ->where('id', $attached->id) + ->update(['active' => true, 'updated_at' => now()]); + } + + // Optionally make it active for a specific contract + if (!empty($validated['contract_uuid']) && ($validated['make_active_for_contract'] ?? false)) { + $contract = $clientCase->contracts()->where('uuid', $validated['contract_uuid'])->firstOrFail(); + \DB::table('contract_segment') + ->where('contract_id', $contract->id) + ->where('active', true) + ->update(['active' => false]); + + $existing = \DB::table('contract_segment') + ->where('contract_id', $contract->id) + ->where('segment_id', $validated['segment_id']) + ->first(); + if ($existing) { + \DB::table('contract_segment') + ->where('id', $existing->id) + ->update(['active' => true, 'updated_at' => now()]); + } else { + $contract->segments()->attach($validated['segment_id'], ['active' => true, 'created_at' => now(), 'updated_at' => now()]); + } + } + }); + + return back()->with('success', 'Segment attached to case.'); + } + public function storeDocument(ClientCase $clientCase, Request $request) { $validated = $request->validate([ @@ -336,11 +420,11 @@ public function show(ClientCase $clientCase) 'phone_types' => \App\Models\Person\PhoneType::all() ]; - return Inertia::render('Cases/Show', [ + return Inertia::render('Cases/Show', [ 'client' => $case->client()->with('person', fn($q) => $q->with(['addresses', 'phones']))->firstOrFail(), 'client_case' => $case, 'contracts' => $case->contracts() - ->with(['type', 'account', 'objects']) + ->with(['type', 'account', 'objects', 'segments:id,name']) ->orderByDesc('created_at')->get(), 'activities' => $case->activities()->with(['action', 'decision', 'contract:id,uuid,reference']) ->orderByDesc('created_at') @@ -348,7 +432,9 @@ public function show(ClientCase $clientCase) 'documents' => $case->documents()->orderByDesc('created_at')->get(), 'contract_types' => \App\Models\ContractType::whereNull('deleted_at')->get(), 'actions' => \App\Models\Action::with('decisions')->get(), - 'types' => $types + 'types' => $types, + 'segments' => $case->segments()->wherePivot('active', true)->get(['segments.id','segments.name']), + 'all_segments' => \App\Models\Segment::query()->where('active', true)->get(['id','name']) ]); } diff --git a/app/Http/Controllers/ContractConfigController.php b/app/Http/Controllers/ContractConfigController.php new file mode 100644 index 0000000..8b98176 --- /dev/null +++ b/app/Http/Controllers/ContractConfigController.php @@ -0,0 +1,72 @@ + ContractConfig::with(['type:id,name', 'segment:id,name'])->get(), + 'types' => ContractType::query()->get(['id','name']), + 'segments' => Segment::query()->where('active', true)->get(['id','name']), + ]); + } + + public function store(Request $request) + { + $data = $request->validate([ + 'contract_type_id' => ['required', 'integer', 'exists:contract_types,id'], + 'segment_id' => ['required', 'integer', 'exists:segments,id'], + 'is_initial' => ['sometimes', 'boolean'], + 'active' => ['sometimes', 'boolean'], + ]); + + // Prevent duplicates for same type/segment + $exists = ContractConfig::query() + ->where('contract_type_id', $data['contract_type_id']) + ->where('segment_id', $data['segment_id']) + ->exists(); + if ($exists) { + return back()->withErrors(['segment_id' => 'This segment is already configured for the selected type.']); + } + + ContractConfig::create([ + 'contract_type_id' => $data['contract_type_id'], + 'segment_id' => $data['segment_id'], + 'is_initial' => (bool)($data['is_initial'] ?? false), + 'active' => (bool)($data['active'] ?? true), + ]); + + return back()->with('success', 'Configuration created'); + } + + public function update(ContractConfig $config, Request $request) + { + $data = $request->validate([ + 'segment_id' => ['required', 'integer', 'exists:segments,id'], + 'is_initial' => ['sometimes', 'boolean'], + 'active' => ['sometimes', 'boolean'], + ]); + + $config->update([ + 'segment_id' => $data['segment_id'], + 'is_initial' => (bool)($data['is_initial'] ?? $config->is_initial), + 'active' => (bool)($data['active'] ?? $config->active), + ]); + + return back()->with('success', 'Configuration updated'); + } + + public function destroy(ContractConfig $config) + { + $config->delete(); + return back()->with('success', 'Configuration deleted'); + } +} diff --git a/app/Http/Controllers/FieldJobSettingController.php b/app/Http/Controllers/FieldJobSettingController.php index 41fcdbf..b41bf1b 100644 --- a/app/Http/Controllers/FieldJobSettingController.php +++ b/app/Http/Controllers/FieldJobSettingController.php @@ -3,6 +3,9 @@ namespace App\Http\Controllers; use App\Models\FieldJobSetting; +use App\Models\Segment; +use App\Models\Decision; +use App\Http\Requests\StoreFieldJobSettingRequest; use Illuminate\Http\Request; use Inertia\Inertia; @@ -11,11 +14,27 @@ class FieldJobSettingController extends Controller public function index(Request $request) { $settings = FieldJobSetting::query() - ->with(['segment', 'asignDecision', 'completeDecision']) + ->with(['segment', 'initialDecision', 'asignDecision', 'completeDecision']) ->get(); return Inertia::render('Settings/FieldJob/Index', [ 'settings' => $settings, + 'segments' => Segment::query()->get(), + 'decisions' => Decision::query()->get(), ]); } + + public function store(StoreFieldJobSettingRequest $request) + { + $attributes = $request->validated(); + + FieldJobSetting::create([ + 'segment_id' => $attributes['segment_id'], + 'initial_decision_id' => $attributes['initial_decision_id'], + 'asign_decision_id' => $attributes['asign_decision_id'], + 'complete_decision_id' => $attributes['complete_decision_id'], + ]); + + return to_route('settings.fieldjob.index')->with('success', 'Field job setting created successfully!'); + } } diff --git a/app/Http/Controllers/SegmentController.php b/app/Http/Controllers/SegmentController.php index 96a2f92..dfef658 100644 --- a/app/Http/Controllers/SegmentController.php +++ b/app/Http/Controllers/SegmentController.php @@ -3,6 +3,8 @@ namespace App\Http\Controllers; use App\Models\Segment; +use App\Http\Requests\StoreSegmentRequest; +use App\Http\Requests\UpdateSegmentRequest; use Illuminate\Http\Request; use Inertia\Inertia; @@ -14,5 +16,29 @@ public function settings(Request $request) 'segments' => Segment::query()->get(), ]); } + + public function store(StoreSegmentRequest $request) + { + $data = $request->validated(); + Segment::create([ + 'name' => $data['name'], + 'description' => $data['description'] ?? null, + 'active' => $data['active'] ?? true, + ]); + + return to_route('settings.segments')->with('success', 'Segment created'); + } + + public function update(UpdateSegmentRequest $request, Segment $segment) + { + $data = $request->validated(); + $segment->update([ + 'name' => $data['name'], + 'description' => $data['description'] ?? null, + 'active' => $data['active'] ?? $segment->active, + ]); + + return to_route('settings.segments')->with('success', 'Segment updated'); + } } diff --git a/app/Http/Controllers/WorkflowController.php b/app/Http/Controllers/WorkflowController.php index b4178d0..63601a0 100644 --- a/app/Http/Controllers/WorkflowController.php +++ b/app/Http/Controllers/WorkflowController.php @@ -14,8 +14,8 @@ class WorkflowController extends Controller public function index(Request $request) { return Inertia::render('Settings/Workflow/Index', [ - 'actions' => Action::query()->with(['decisions', 'segment'])->get(), - 'decisions' => Decision::query()->with('actions')->get(), + 'actions' => Action::query()->with(['decisions', 'segment'])->withCount('activities')->get(), + 'decisions' => Decision::query()->with('actions')->withCount('activities')->get(), 'segments' => Segment::query()->get(), ]); } @@ -127,4 +127,32 @@ public function updateDecision(int $id, Request $request) return to_route('settings.workflow')->with('success', 'Decision updated successfully!'); } + + public function destroyAction(int $id) + { + $row = Action::findOrFail($id); + if ($row->activities()->exists()) { + return back()->with('error', 'Cannot delete action because dependent activities exist.'); + } + + \DB::transaction(function () use ($row) { + $row->decisions()->detach(); + $row->delete(); + }); + return back()->with('success', 'Action deleted successfully!'); + } + + public function destroyDecision(int $id) + { + $row = Decision::findOrFail($id); + if ($row->activities()->exists()) { + return back()->with('error', 'Cannot delete decision because dependent activities exist.'); + } + + \DB::transaction(function () use ($row) { + $row->actions()->detach(); + $row->delete(); + }); + return back()->with('success', 'Decision deleted successfully!'); + } } diff --git a/app/Http/Requests/StoreContractRequest.php b/app/Http/Requests/StoreContractRequest.php new file mode 100644 index 0000000..ea96a26 --- /dev/null +++ b/app/Http/Requests/StoreContractRequest.php @@ -0,0 +1,37 @@ +route('client_case'); + + return [ + 'reference' => [ + 'nullable', + 'string', + 'max:125', + Rule::unique('contracts', 'reference') + ->where(fn ($q) => $q + ->where('client_case_id', optional($clientCase)->id) + ->whereNull('deleted_at') + ), + ], + 'start_date' => ['required', 'date'], + 'type_id' => ['required', 'integer', 'exists:contract_types,id'], + 'description' => ['nullable', 'string', 'max:255'], + 'initial_amount' => ['nullable', 'numeric'], + 'balance_amount' => ['nullable', 'numeric'], + ]; + } +} diff --git a/app/Http/Requests/StoreFieldJobSettingRequest.php b/app/Http/Requests/StoreFieldJobSettingRequest.php new file mode 100644 index 0000000..798a0ad --- /dev/null +++ b/app/Http/Requests/StoreFieldJobSettingRequest.php @@ -0,0 +1,33 @@ + ['required', 'integer', 'exists:segments,id'], + 'initial_decision_id' => ['required', 'integer', 'exists:decisions,id'], + 'asign_decision_id' => ['required', 'integer', 'exists:decisions,id'], + 'complete_decision_id' => ['required', 'integer', 'exists:decisions,id'], + ]; + } + + public function messages(): array + { + return [ + 'segment_id.required' => 'Segment is required.', + 'initial_decision_id.required' => 'Initial decision is required.', + 'asign_decision_id.required' => 'Assign decision is required.', + 'complete_decision_id.required' => 'Complete decision is required.', + ]; + } +} diff --git a/app/Http/Requests/StoreSegmentRequest.php b/app/Http/Requests/StoreSegmentRequest.php new file mode 100644 index 0000000..926bbe8 --- /dev/null +++ b/app/Http/Requests/StoreSegmentRequest.php @@ -0,0 +1,29 @@ + ['required', 'string', 'max:50'], + 'description' => ['nullable', 'string', 'max:255'], + 'active' => ['boolean'], + ]; + } + + public function messages(): array + { + return [ + 'name.required' => 'Name is required.', + ]; + } +} diff --git a/app/Http/Requests/UpdateContractRequest.php b/app/Http/Requests/UpdateContractRequest.php new file mode 100644 index 0000000..f6921a0 --- /dev/null +++ b/app/Http/Requests/UpdateContractRequest.php @@ -0,0 +1,39 @@ +route('client_case'); + $uuid = $this->route('uuid'); + + return [ + 'reference' => [ + 'nullable', + 'string', + 'max:125', + Rule::unique('contracts', 'reference') + ->where(fn ($q) => $q + ->where('client_case_id', optional($clientCase)->id) + ->whereNull('deleted_at') + ->where('uuid', '!=', $uuid) + ), + ], + 'start_date' => ['sometimes', 'date'], + 'type_id' => ['required', 'integer', 'exists:contract_types,id'], + 'description' => ['nullable', 'string', 'max:255'], + 'initial_amount' => ['nullable', 'numeric'], + 'balance_amount' => ['nullable', 'numeric'], + ]; + } +} diff --git a/app/Http/Requests/UpdateSegmentRequest.php b/app/Http/Requests/UpdateSegmentRequest.php new file mode 100644 index 0000000..eb58893 --- /dev/null +++ b/app/Http/Requests/UpdateSegmentRequest.php @@ -0,0 +1,29 @@ + ['required', 'string', 'max:50'], + 'description' => ['nullable', 'string', 'max:255'], + 'active' => ['boolean'], + ]; + } + + public function messages(): array + { + return [ + 'name.required' => 'Name is required.', + ]; + } +} diff --git a/app/Models/Action.php b/app/Models/Action.php index c3ef762..1aec287 100644 --- a/app/Models/Action.php +++ b/app/Models/Action.php @@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\HasMany; use Laravel\Scout\Searchable; class Action extends Model @@ -26,4 +27,9 @@ public function segment(): BelongsTo return $this->belongsTo(\App\Models\Segment::class); } + public function activities(): HasMany + { + return $this->hasMany(\App\Models\Activity::class); + } + } diff --git a/app/Models/Contract.php b/app/Models/Contract.php index ec92afb..5c86eab 100644 --- a/app/Models/Contract.php +++ b/app/Models/Contract.php @@ -65,4 +65,72 @@ public function objects(): HasMany return $this->hasMany(\App\Models\CaseObject::class, 'contract_id'); } + protected static function booted(): void + { + static::created(function (Contract $contract): void { + // Only apply configs when type and client case are set and no segments are already attached + if (empty($contract->type_id) || empty($contract->client_case_id)) { + return; + } + + $existing = \DB::table('contract_segment') + ->where('contract_id', $contract->id) + ->count(); + if ($existing > 0) { + // Respect pre-attached segments (e.g. custom import logic) + return; + } + + $configs = ContractConfig::query() + ->where('contract_type_id', $contract->type_id) + ->where('active', true) + ->get(['segment_id', 'is_initial']); + + if ($configs->isEmpty()) { + return; + } + + foreach ($configs as $cfg) { + // Ensure the segment is attached to the client case and active + $attached = \DB::table('client_case_segment') + ->where('client_case_id', $contract->client_case_id) + ->where('segment_id', $cfg->segment_id) + ->first(); + if (!$attached) { + \DB::table('client_case_segment')->insert([ + 'client_case_id' => $contract->client_case_id, + 'segment_id' => $cfg->segment_id, + 'active' => true, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } elseif (!$attached->active) { + \DB::table('client_case_segment') + ->where('id', $attached->id) + ->update(['active' => true, 'updated_at' => now()]); + } + + // Attach to contract; mark active if initial, otherwise inactive + $pivot = \DB::table('contract_segment') + ->where('contract_id', $contract->id) + ->where('segment_id', $cfg->segment_id) + ->first(); + + $activeFlag = $cfg->is_initial ? true : false; + if ($pivot) { + \DB::table('contract_segment') + ->where('id', $pivot->id) + ->update(['active' => $activeFlag, 'updated_at' => now()]); + } else { + \DB::table('contract_segment')->insert([ + 'contract_id' => $contract->id, + 'segment_id' => $cfg->segment_id, + 'active' => $activeFlag, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + } + }); + } } diff --git a/app/Models/ContractConfig.php b/app/Models/ContractConfig.php new file mode 100644 index 0000000..7aac0e7 --- /dev/null +++ b/app/Models/ContractConfig.php @@ -0,0 +1,37 @@ + 'boolean', + 'is_initial' => 'boolean', + ]; + } + + public function type(): BelongsTo + { + return $this->belongsTo(ContractType::class, 'contract_type_id'); + } + + public function segment(): BelongsTo + { + return $this->belongsTo(Segment::class, 'segment_id'); + } +} diff --git a/app/Models/Decision.php b/app/Models/Decision.php index 32d3329..b673be5 100644 --- a/app/Models/Decision.php +++ b/app/Models/Decision.php @@ -24,4 +24,9 @@ public function events(): BelongsToMany { return $this->belongsToMany(\App\Models\Event::class); } + + public function activities(): HasMany + { + return $this->hasMany(\App\Models\Activity::class); + } } diff --git a/app/Models/FieldJob.php b/app/Models/FieldJob.php index 2df7446..cc20915 100644 --- a/app/Models/FieldJob.php +++ b/app/Models/FieldJob.php @@ -5,21 +5,32 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\SoftDeletes; class FieldJob extends Model { use HasFactory; + use SoftDeletes; protected $fillable = [ 'field_job_setting_id', 'asigned_user_id', - 'start_date', - 'end_date', + 'user_id', + 'contract_id', + 'assigned_at', + 'completed_at', + 'cancelled_at', + 'priority', + 'notes', + 'address_snapshot ', ]; protected $casts = [ - 'start_date' => 'date', - 'end_date' => 'date', + 'assigned_at' => 'date', + 'completed_at' => 'date', + 'cancelled_at' => 'date', + 'priority' => 'boolean', + 'address_snapshot ' => 'array', ]; protected static function booted(){ @@ -39,4 +50,14 @@ public function assignedUser(): BelongsTo { return $this->belongsTo(User::class, 'asigned_user_id'); } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } + + public function contract(): BelongsTo + { + return $this->belongsTo(Contract::class, 'contract_id'); + } } diff --git a/app/Models/FieldJobSetting.php b/app/Models/FieldJobSetting.php index b06bf47..7a6f260 100644 --- a/app/Models/FieldJobSetting.php +++ b/app/Models/FieldJobSetting.php @@ -13,6 +13,7 @@ class FieldJobSetting extends Model protected $fillable = [ 'segment_id', + 'initial_decision_id', 'asign_decision_id', 'complete_decision_id', ]; @@ -27,6 +28,11 @@ public function asignDecision(): BelongsTo return $this->belongsTo(Decision::class, 'asign_decision_id'); } + public function initialDecision(): BelongsTo + { + return $this->belongsTo(Decision::class, 'initial_decision_id'); + } + public function completeDecision(): BelongsTo { return $this->belongsTo(Decision::class, 'complete_decision_id'); diff --git a/app/Models/Segment.php b/app/Models/Segment.php index 4b9aba9..12c6753 100644 --- a/app/Models/Segment.php +++ b/app/Models/Segment.php @@ -11,6 +11,19 @@ class Segment extends Model /** @use HasFactory<\Database\Factories\SegmentFactory> */ use HasFactory; + protected $fillable = [ + 'name', + 'description', + 'active', + ]; + + protected function casts(): array + { + return [ + 'active' => 'boolean', + ]; + } + public function contracts(): BelongsToMany { return $this->belongsToMany(\App\Models\Contract::class); } diff --git a/composer.json b/composer.json index 70473e6..4252aab 100644 --- a/composer.json +++ b/composer.json @@ -21,6 +21,7 @@ }, "require-dev": { "fakerphp/faker": "^1.23", + "laravel/boost": "^1.1", "laravel/pint": "^1.13", "laravel/sail": "^1.26", "mockery/mockery": "^1.6", diff --git a/composer.lock b/composer.lock index 1fa583e..3ba6e97 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "845b21f4ecbbf1baa0aed4846a6355b8", + "content-hash": "af8a7f4584f3bab04f410483a25e092f", "packages": [ { "name": "arielmejiadev/larapex-charts", @@ -7517,6 +7517,135 @@ }, "time": "2025-03-19T14:43:43+00:00" }, + { + "name": "laravel/boost", + "version": "v1.1.5", + "source": { + "type": "git", + "url": "https://github.com/laravel/boost.git", + "reference": "4bd1692c064b2135eb2f8f7bd8bcb710e5e75f86" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/boost/zipball/4bd1692c064b2135eb2f8f7bd8bcb710e5e75f86", + "reference": "4bd1692c064b2135eb2f8f7bd8bcb710e5e75f86", + "shasum": "" + }, + "require": { + "guzzlehttp/guzzle": "^7.9", + "illuminate/console": "^10.0|^11.0|^12.0", + "illuminate/contracts": "^10.0|^11.0|^12.0", + "illuminate/routing": "^10.0|^11.0|^12.0", + "illuminate/support": "^10.0|^11.0|^12.0", + "laravel/mcp": "^0.1.1", + "laravel/prompts": "^0.1.9|^0.3", + "laravel/roster": "^0.2.5", + "php": "^8.1" + }, + "require-dev": { + "laravel/pint": "^1.14", + "mockery/mockery": "^1.6", + "orchestra/testbench": "^8.22.0|^9.0|^10.0", + "pestphp/pest": "^2.0|^3.0", + "phpstan/phpstan": "^2.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Boost\\BoostServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Boost\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Laravel Boost accelerates AI-assisted development to generate high-quality, Laravel-specific code.", + "homepage": "https://github.com/laravel/boost", + "keywords": [ + "ai", + "dev", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/boost/issues", + "source": "https://github.com/laravel/boost" + }, + "time": "2025-09-18T07:33:27+00:00" + }, + { + "name": "laravel/mcp", + "version": "v0.1.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/mcp.git", + "reference": "6d6284a491f07c74d34f48dfd999ed52c567c713" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/mcp/zipball/6d6284a491f07c74d34f48dfd999ed52c567c713", + "reference": "6d6284a491f07c74d34f48dfd999ed52c567c713", + "shasum": "" + }, + "require": { + "illuminate/console": "^10.0|^11.0|^12.0", + "illuminate/contracts": "^10.0|^11.0|^12.0", + "illuminate/http": "^10.0|^11.0|^12.0", + "illuminate/routing": "^10.0|^11.0|^12.0", + "illuminate/support": "^10.0|^11.0|^12.0", + "illuminate/validation": "^10.0|^11.0|^12.0", + "php": "^8.1|^8.2" + }, + "require-dev": { + "laravel/pint": "^1.14", + "orchestra/testbench": "^8.22.0|^9.0|^10.0", + "phpstan/phpstan": "^2.0" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Mcp": "Laravel\\Mcp\\Server\\Facades\\Mcp" + }, + "providers": [ + "Laravel\\Mcp\\Server\\McpServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Mcp\\": "src/", + "Workbench\\App\\": "workbench/app/", + "Laravel\\Mcp\\Tests\\": "tests/", + "Laravel\\Mcp\\Server\\": "src/Server/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "The easiest way to add MCP servers to your Laravel app.", + "homepage": "https://github.com/laravel/mcp", + "keywords": [ + "dev", + "laravel", + "mcp" + ], + "support": { + "issues": "https://github.com/laravel/mcp/issues", + "source": "https://github.com/laravel/mcp" + }, + "time": "2025-08-16T09:50:43+00:00" + }, { "name": "laravel/pint", "version": "v1.21.2", @@ -7583,6 +7712,67 @@ }, "time": "2025-03-14T22:31:42+00:00" }, + { + "name": "laravel/roster", + "version": "v0.2.8", + "source": { + "type": "git", + "url": "https://github.com/laravel/roster.git", + "reference": "832a6db43743bf08a58691da207f977ec8dc43aa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/roster/zipball/832a6db43743bf08a58691da207f977ec8dc43aa", + "reference": "832a6db43743bf08a58691da207f977ec8dc43aa", + "shasum": "" + }, + "require": { + "illuminate/console": "^10.0|^11.0|^12.0", + "illuminate/contracts": "^10.0|^11.0|^12.0", + "illuminate/routing": "^10.0|^11.0|^12.0", + "illuminate/support": "^10.0|^11.0|^12.0", + "php": "^8.1|^8.2", + "symfony/yaml": "^6.4|^7.2" + }, + "require-dev": { + "laravel/pint": "^1.14", + "mockery/mockery": "^1.6", + "orchestra/testbench": "^8.22.0|^9.0|^10.0", + "pestphp/pest": "^2.0|^3.0", + "phpstan/phpstan": "^2.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Roster\\RosterServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Roster\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Detect packages & approaches in use within a Laravel project", + "homepage": "https://github.com/laravel/roster", + "keywords": [ + "dev", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/roster/issues", + "source": "https://github.com/laravel/roster" + }, + "time": "2025-09-22T13:28:47+00:00" + }, { "name": "laravel/sail", "version": "v1.41.0", diff --git a/database/migrations/2025_09_28_110600_alter_segments_description_nullable.php b/database/migrations/2025_09_28_110600_alter_segments_description_nullable.php new file mode 100644 index 0000000..e435283 --- /dev/null +++ b/database/migrations/2025_09_28_110600_alter_segments_description_nullable.php @@ -0,0 +1,19 @@ +foreignId('contract_id') + ->nullable() + ->after('user_id') + ->constrained('contracts') + ->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('field_jobs', function (Blueprint $table) { + $table->dropForeign(['contract_id']); + $table->dropColumn('contract_id'); + }); + } +}; diff --git a/database/migrations/2025_09_28_120500_create_contract_configs_table.php b/database/migrations/2025_09_28_120500_create_contract_configs_table.php new file mode 100644 index 0000000..4bd9fef --- /dev/null +++ b/database/migrations/2025_09_28_120500_create_contract_configs_table.php @@ -0,0 +1,25 @@ +id(); + $table->foreignIdFor(\App\Models\ContractType::class, 'contract_type_id')->constrained('contract_types')->cascadeOnDelete(); + $table->foreignIdFor(\App\Models\Segment::class, 'initial_segment_id')->constrained('segments')->cascadeOnDelete(); + $table->boolean('active')->default(true); + $table->timestamps(); + $table->unique('contract_type_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('contract_configs'); + } +}; diff --git a/database/migrations/2025_09_28_121500_alter_contract_configs_support_multiple.php b/database/migrations/2025_09_28_121500_alter_contract_configs_support_multiple.php new file mode 100644 index 0000000..ed0c1fe --- /dev/null +++ b/database/migrations/2025_09_28_121500_alter_contract_configs_support_multiple.php @@ -0,0 +1,64 @@ +renameColumn('initial_segment_id', 'segment_id'); + } + }); + + Schema::table('contract_configs', function (Blueprint $table) { + // Add is_initial and drop unique(contract_type_id) + $table->boolean('is_initial')->default(false)->after('segment_id'); + }); + + // Drop existing unique index on contract_type_id if present and add composite unique + try { + Schema::table('contract_configs', function (Blueprint $table) { + $table->dropUnique(['contract_type_id']); + }); + } catch (Throwable $e) { + // ignore if index does not exist + } + + Schema::table('contract_configs', function (Blueprint $table) { + $table->unique(['contract_type_id', 'segment_id']); + }); + + // Mark existing rows as initial + \DB::table('contract_configs')->update(['is_initial' => true]); + } + + public function down(): void + { + // Remove composite unique + Schema::table('contract_configs', function (Blueprint $table) { + $table->dropUnique(['contract_type_id', 'segment_id']); + }); + + // Try to restore unique on contract_type_id + Schema::table('contract_configs', function (Blueprint $table) { + $table->unique('contract_type_id'); + }); + + // Drop is_initial + Schema::table('contract_configs', function (Blueprint $table) { + $table->dropColumn('is_initial'); + }); + + // Rename segment_id back to initial_segment_id + Schema::table('contract_configs', function (Blueprint $table) { + if (Schema::hasColumn('contract_configs', 'segment_id')) { + $table->renameColumn('segment_id', 'initial_segment_id'); + } + }); + } +}; diff --git a/database/migrations/2025_09_28_151500_add_unique_contract_reference_per_client_case_v2.php b/database/migrations/2025_09_28_151500_add_unique_contract_reference_per_client_case_v2.php new file mode 100644 index 0000000..0f82b8a --- /dev/null +++ b/database/migrations/2025_09_28_151500_add_unique_contract_reference_per_client_case_v2.php @@ -0,0 +1,44 @@ +select('client_case_id', 'reference', DB::raw('COUNT(*) as cnt')) + ->whereNull('deleted_at') + ->whereNotNull('reference') + ->groupBy('client_case_id', 'reference') + ->havingRaw('COUNT(*) > 1') + ->get(); + + foreach ($dupes as $d) { + $rows = DB::table('contracts') + ->where('client_case_id', $d->client_case_id) + ->where('reference', $d->reference) + ->whereNull('deleted_at') + ->orderBy('id') + ->get(['id', 'reference']); + + $keepFirst = true; + foreach ($rows as $row) { + if ($keepFirst) { $keepFirst = false; continue; } + $base = mb_substr($row->reference, 0, 120); + $newRef = $base . '-' . $row->id; + DB::table('contracts')->where('id', $row->id)->update(['reference' => $newRef]); + } + } + + // Create a partial unique index (Postgres) for non-deleted rows + DB::statement('CREATE UNIQUE INDEX IF NOT EXISTS contracts_client_case_reference_unique ON contracts (client_case_id, reference) WHERE deleted_at IS NULL'); + } + + public function down(): void + { + DB::statement('DROP INDEX IF EXISTS contracts_client_case_reference_unique'); + } +}; diff --git a/resources/js/Pages/Cases/Partials/ContractDrawer.vue b/resources/js/Pages/Cases/Partials/ContractDrawer.vue index 563ae7e..8c4be3b 100644 --- a/resources/js/Pages/Cases/Partials/ContractDrawer.vue +++ b/resources/js/Pages/Cases/Partials/ContractDrawer.vue @@ -2,12 +2,13 @@ import ActionMessage from '@/Components/ActionMessage.vue'; import DialogModal from '@/Components/DialogModal.vue'; import InputLabel from '@/Components/InputLabel.vue'; +import InputError from '@/Components/InputError.vue'; import PrimaryButton from '@/Components/PrimaryButton.vue'; import SectionTitle from '@/Components/SectionTitle.vue'; import TextInput from '@/Components/TextInput.vue'; import DatePickerField from '@/Components/DatePickerField.vue'; import { useForm } from '@inertiajs/vue3'; -import { watch } from 'vue'; +import { watch, nextTick, ref as vRef } from 'vue'; const props = defineProps({ client_case: Object, @@ -22,7 +23,10 @@ console.log(props.types); const emit = defineEmits(['close']); const close = () => { - emit('close'); + // Clear any previous validation warnings when closing + formContract.clearErrors() + formContract.recentlySuccessful = false + emit('close') } // form state for create or edit @@ -58,6 +62,20 @@ watch(() => props.show, (open) => { // reset for create applyContract(null) } + if (!open) { + // Ensure warnings are cleared when dialog hides + formContract.clearErrors() + formContract.recentlySuccessful = false + } +}) + +// optional: focus the reference input when a reference validation error appears +const contractRefInput = vRef(null) +watch(() => formContract.errors.reference, async (err) => { + if (err && props.show) { + await nextTick() + try { contractRefInput.value?.focus?.() } catch (e) {} + } }) const storeOrUpdate = () => { @@ -90,6 +108,9 @@ const storeOrUpdate = () => {