Changes 0228092025 Laptop

This commit is contained in:
Simon 2025-09-28 14:51:02 +02:00
parent 765beb78b7
commit b40ee9dcde
36 changed files with 2099 additions and 65 deletions

375
.github/copilot-instructions.md vendored Normal file
View File

@ -0,0 +1,375 @@
<laravel-boost-guidelines>
=== 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()`.
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet>
- 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.
<code-snippet name="Explicit Return Types and Method Params" lang="php">
protected function isAccessible(User $user, ?string $path = null): bool
{
...
}
</code-snippet>
## 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.
<code-snippet lang="php" name="Inertia::render Example">
// routes/web.php example
Route::get('/users', function () {
return Inertia::render('Users/Index', [
'users' => User::all()
]);
});
</code-snippet>
=== 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] <name>` 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 <name>`.
- 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:
<code-snippet name="Basic Pest Test Example" lang="php">
it('is true', function () {
expect(true)->toBeTrue();
});
</code-snippet>
### 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.:
<code-snippet name="Pest Example Asserting postJson Response" lang="php">
it('returns all', function () {
$response = $this->postJson('/api/docs', []);
$response->assertSuccessful();
});
</code-snippet>
### 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.
<code-snippet name="Pest Dataset Example" lang="php">
it('has emails', function (string $email) {
expect($email)->not->toBeEmpty();
})->with([
'james' => 'james@laravel.com',
'taylor' => 'taylor@laravel.com',
]);
</code-snippet>
=== inertia-vue/core rules ===
## Inertia + Vue
- Vue components must have a single root element.
- Use `router.visit()` or `<Link>` for navigation instead of traditional links.
<code-snippet name="Inertia Client Navigation" lang="vue">
import { Link } from '@inertiajs/vue3'
<Link href="/">Home</Link>
</code-snippet>
=== inertia-vue/v2/forms rules ===
## Inertia + Vue Forms
<code-snippet name="Inertia Vue useForm example" lang="vue">
<script setup>
import { useForm } from '@inertiajs/vue3'
const form = useForm({
email: null,
password: null,
remember: false,
})
</script>
<template>
<form @submit.prevent="form.post('/login')">
<!-- email -->
<input type="text" v-model="form.email">
<div v-if="form.errors.email">{{ form.errors.email }}</div>
<!-- password -->
<input type="password" v-model="form.password">
<div v-if="form.errors.password">{{ form.errors.password }}</div>
<!-- remember me -->
<input type="checkbox" v-model="form.remember"> Remember Me
<!-- submit -->
<button type="submit" :disabled="form.processing">Login</button>
</form>
</template>
</code-snippet>
=== 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.
<code-snippet name="Valid Flex Gap Spacing Example" lang="html">
<div class="flex gap-8">
<div>Superior</div>
<div>Michigan</div>
<div>Erie</div>
</div>
</code-snippet>
### 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.
</laravel-boost-guidelines>

View File

@ -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'])
]);
}

View File

@ -0,0 +1,72 @@
<?php
namespace App\Http\Controllers;
use App\Models\ContractConfig;
use App\Models\ContractType;
use App\Models\Segment;
use Illuminate\Http\Request;
use Inertia\Inertia;
class ContractConfigController extends Controller
{
public function index()
{
return Inertia::render('Settings/ContractConfigs/Index', [
'configs' => 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');
}
}

View File

@ -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!');
}
}

View File

@ -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');
}
}

View File

@ -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!');
}
}

View File

@ -0,0 +1,37 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreContractRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
$clientCase = $this->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'],
];
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreFieldJobSettingRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'segment_id' => ['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.',
];
}
}

View File

@ -0,0 +1,29 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreSegmentRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:50'],
'description' => ['nullable', 'string', 'max:255'],
'active' => ['boolean'],
];
}
public function messages(): array
{
return [
'name.required' => 'Name is required.',
];
}
}

View File

@ -0,0 +1,39 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateContractRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
$clientCase = $this->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'],
];
}
}

View File

@ -0,0 +1,29 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateSegmentRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:50'],
'description' => ['nullable', 'string', 'max:255'],
'active' => ['boolean'],
];
}
public function messages(): array
{
return [
'name.required' => 'Name is required.',
];
}
}

View File

@ -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);
}
}

View File

@ -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(),
]);
}
}
});
}
}

View File

@ -0,0 +1,37 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ContractConfig extends Model
{
use HasFactory;
protected $fillable = [
'contract_type_id',
'segment_id',
'is_initial',
'active',
];
protected function casts(): array
{
return [
'active' => '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');
}
}

View File

@ -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);
}
}

View File

@ -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');
}
}

View File

@ -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');

View File

@ -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);
}

View File

@ -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",

192
composer.lock generated
View File

@ -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",

View File

@ -0,0 +1,19 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration {
public function up(): void
{
// PostgreSQL: drop NOT NULL constraint on description
DB::statement('ALTER TABLE segments ALTER COLUMN description DROP NOT NULL');
}
public function down(): void
{
// Ensure no NULLs before setting NOT NULL
DB::statement("UPDATE segments SET description = '' WHERE description IS NULL");
DB::statement('ALTER TABLE segments ALTER COLUMN description SET NOT NULL');
}
};

View File

@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('field_jobs', function (Blueprint $table) {
$table->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');
});
}
};

View File

@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('contract_configs', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,64 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('contract_configs', function (Blueprint $table) {
// Rename initial_segment_id to segment_id
if (Schema::hasColumn('contract_configs', 'initial_segment_id')) {
$table->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');
}
});
}
};

View File

@ -0,0 +1,44 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
// Resolve duplicates among non-deleted rows by appending the row id to later duplicates
$dupes = DB::table('contracts')
->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');
}
};

View File

@ -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 = () => {
<template #title>{{ formContract.uuid ? 'Uredi pogodbo' : 'Dodaj pogodbo' }}</template>
<template #content>
<form @submit.prevent="storeOrUpdate">
<div v-if="formContract.errors.reference" class="mb-4 rounded border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{{ formContract.errors.reference }}
</div>
<SectionTitle class="mt-4 border-b mb-4">
<template #title>
Pogodba
@ -102,9 +123,10 @@ const storeOrUpdate = () => {
ref="contractRefInput"
v-model="formContract.reference"
type="text"
class="mt-1 block w-full"
:class="['mt-1 block w-full', formContract.errors.reference ? 'border-red-500 focus:border-red-500 focus:ring-red-500' : '']"
autocomplete="contract-reference"
/>
<InputError :message="formContract.errors.reference" />
</div>
<DatePickerField
id="contractStartDate"

View File

@ -10,6 +10,8 @@ const props = defineProps({
client_case: Object,
contract_types: Array,
contracts: { type: Array, default: () => [] },
segments: { type: Array, default: () => [] },
all_segments: { type: Array, default: () => [] },
})
const emit = defineEmits(['edit', 'delete', 'add-activity'])
@ -30,7 +32,8 @@ const onDelete = (c) => emit('delete', c)
const onAddActivity = (c) => emit('add-activity', c)
// CaseObject dialog state
import { ref } from 'vue'
import { ref, computed } from 'vue'
import { router } from '@inertiajs/vue3'
const showObjectDialog = ref(false)
const showObjectsList = ref(false)
const selectedContract = ref(null)
@ -38,6 +41,39 @@ const openObjectDialog = (c) => { selectedContract.value = c; showObjectDialog.v
const closeObjectDialog = () => { showObjectDialog.value = false; selectedContract.value = null }
const openObjectsList = (c) => { selectedContract.value = c; showObjectsList.value = true }
const closeObjectsList = () => { showObjectsList.value = false; selectedContract.value = null }
// Segment helpers
const contractActiveSegment = (c) => {
const arr = c?.segments || []
return arr.find(s => s.pivot?.active) || arr[0] || null
}
const segmentName = (id) => props.segments.find(s => s.id === id)?.name || ''
const confirmChange = ref({ show: false, contract: null, segmentId: null, fromAll: false })
const askChangeSegment = (c, segmentId, fromAll = false) => {
confirmChange.value = { show: true, contract: c, segmentId, fromAll }
}
const closeConfirm = () => { confirmChange.value = { show: false, contract: null, segmentId: null } }
const doChangeSegment = () => {
const { contract, segmentId, fromAll } = confirmChange.value
if (!contract || !segmentId) return closeConfirm()
if (fromAll) {
router.post(route('clientCase.segments.attach', props.client_case), {
segment_id: segmentId,
contract_uuid: contract.uuid,
make_active_for_contract: true,
}, {
preserveScroll: true,
only: ['contracts', 'segments'],
onFinish: () => closeConfirm(),
})
} else {
router.post(route('clientCase.contract.updateSegment', { client_case: props.client_case.uuid, uuid: contract.uuid }), { segment_id: segmentId }, {
preserveScroll: true,
only: ['contracts'],
onFinish: () => closeConfirm(),
})
}
}
</script>
<template>
@ -47,6 +83,7 @@ const closeObjectsList = () => { showObjectsList.value = false; selectedContract
<FwbTableHeadCell class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3">Ref.</FwbTableHeadCell>
<FwbTableHeadCell class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3">Datum začetka</FwbTableHeadCell>
<FwbTableHeadCell class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3">Tip</FwbTableHeadCell>
<FwbTableHeadCell class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3">Segment</FwbTableHeadCell>
<FwbTableHeadCell class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3 text-right">Predano</FwbTableHeadCell>
<FwbTableHeadCell class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3 text-right">Odprto</FwbTableHeadCell>
<FwbTableHeadCell class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3 text-center">Opis</FwbTableHeadCell>
@ -58,6 +95,43 @@ const closeObjectsList = () => { showObjectsList.value = false; selectedContract
<FwbTableCell>{{ c.reference }}</FwbTableCell>
<FwbTableCell>{{ formatDate(c.start_date) }}</FwbTableCell>
<FwbTableCell>{{ c?.type?.name }}</FwbTableCell>
<FwbTableCell>
<div class="flex items-center gap-2">
<span class="text-gray-700">{{ contractActiveSegment(c)?.name || '-' }}</span>
<Dropdown width="64" align="left">
<template #trigger>
<button
type="button"
class="inline-flex items-center justify-center h-7 w-7 rounded-full hover:bg-gray-100"
:class="{ 'opacity-50 cursor-not-allowed': !segments || segments.length === 0 }"
:title="segments && segments.length ? 'Change segment' : 'No segments available for this case'"
>
<FontAwesomeIcon :icon="faPenToSquare" class="h-4 w-4 text-gray-600" />
</button>
</template>
<template #content>
<div class="py-1">
<template v-if="segments && segments.length">
<button v-for="s in segments" :key="s.id" type="button" class="w-full px-3 py-1.5 text-left text-sm hover:bg-gray-50" @click="askChangeSegment(c, s.id)">
<span>{{ s.name }}</span>
</button>
</template>
<template v-else>
<template v-if="all_segments && all_segments.length">
<div class="px-3 py-2 text-xs text-gray-500">Ni segmentov v tem primeru. Dodaj in nastavi segment:</div>
<button v-for="s in all_segments" :key="s.id" type="button" class="w-full px-3 py-1.5 text-left text-sm hover:bg-gray-50" @click="askChangeSegment(c, s.id, true)">
<span>{{ s.name }}</span>
</button>
</template>
<template v-else>
<div class="px-3 py-2 text-sm text-gray-500">No segments configured.</div>
</template>
</template>
</div>
</template>
</Dropdown>
</div>
</FwbTableCell>
<FwbTableCell class="text-right">{{ Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(c?.account?.initial_amount ?? 0) }}</FwbTableCell>
<FwbTableCell class="text-right">{{ Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(c?.account?.balance_amount ?? 0) }}</FwbTableCell>
<FwbTableCell class="text-center">
@ -149,6 +223,18 @@ const closeObjectsList = () => { showObjectsList.value = false; selectedContract
</FwbTable>
<div v-if="!contracts || contracts.length === 0" class="p-6 text-center text-sm text-gray-500">No contracts.</div>
</div>
<!-- Confirm change segment -->
<div v-if="confirmChange.show" class="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
<div class="bg-white rounded-lg shadow-lg p-4 w-full max-w-sm">
<div class="text-sm text-gray-800">
Ali želite spremeniti segment za pogodbo <span class="font-medium">{{ confirmChange.contract?.reference }}</span>?
</div>
<div class="mt-4 flex justify-end gap-2">
<button class="px-4 py-2 rounded bg-gray-200 hover:bg-gray-300" @click="closeConfirm">Prekliči</button>
<button class="px-4 py-2 rounded bg-indigo-600 text-white hover:bg-indigo-700" @click="doChangeSegment">Potrdi</button>
</div>
</div>
</div>
<CaseObjectCreateDialog
:show="showObjectDialog"
@close="closeObjectDialog"

View File

@ -3,7 +3,7 @@ import PersonInfoGrid from "@/Components/PersonInfoGrid.vue";
import SectionTitle from "@/Components/SectionTitle.vue";
import AppLayout from "@/Layouts/AppLayout.vue";
import { FwbButton } from "flowbite-vue";
import { onBeforeMount, ref } from "vue";
import { onBeforeMount, ref, computed } from "vue";
import ContractDrawer from "./Partials/ContractDrawer.vue";
import ContractTable from "./Partials/ContractTable.vue";
import ActivityDrawer from "./Partials/ActivityDrawer.vue";
@ -12,10 +12,11 @@ import DocumentsTable from "@/Components/DocumentsTable.vue";
import DocumentUploadDialog from "@/Components/DocumentUploadDialog.vue";
import DocumentViewerDialog from "@/Components/DocumentViewerDialog.vue";
import { classifyDocument } from "@/Services/documents";
import { router } from '@inertiajs/vue3';
import { router, useForm } from '@inertiajs/vue3';
import { AngleDownIcon, AngleUpIcon } from "@/Utilities/Icons";
import Pagination from "@/Components/Pagination.vue";
import ConfirmDialog from "@/Components/ConfirmDialog.vue";
import DialogModal from '@/Components/DialogModal.vue';
const props = defineProps({
client: Object,
@ -25,7 +26,9 @@ const props = defineProps({
contract_types: Array,
actions: Array,
types: Object,
documents: Array
documents: Array,
segments: { type: Array, default: () => [] },
all_segments: { type: Array, default: () => [] },
});
const showUpload = ref(false);
@ -99,6 +102,27 @@ const showClientDetails = () => {
const hideClietnDetails = () => {
clientDetails.value = true;
};
// Attach segment to case
const showAttachSegment = ref(false)
const openAttachSegment = () => { showAttachSegment.value = true }
const closeAttachSegment = () => { showAttachSegment.value = false }
const attachForm = useForm({ segment_id: null })
const availableSegments = computed(() => {
const current = new Set((props.segments || []).map(s => s.id))
return (props.all_segments || []).filter(s => !current.has(s.id))
})
const submitAttachSegment = () => {
if (!attachForm.segment_id) { return }
attachForm.post(route('clientCase.segments.attach', props.client_case), {
preserveScroll: true,
only: ['segments'],
onSuccess: () => {
closeAttachSegment()
attachForm.reset('segment_id')
}
})
}
</script>
<template>
<AppLayout title="Client case">
@ -169,12 +193,18 @@ const hideClietnDetails = () => {
<SectionTitle>
<template #title> Pogodbe </template>
</SectionTitle>
<FwbButton @click="openDrawerCreateContract">Nova</FwbButton>
<div class="flex items-center gap-2">
<FwbButton @click="openDrawerCreateContract">Nova</FwbButton>
<FwbButton color="light" :disabled="availableSegments.length === 0" @click="openAttachSegment">
{{ availableSegments.length ? 'Dodaj segment' : 'Ni razpoložljivih segmentov' }}
</FwbButton>
</div>
</div>
<ContractTable
:client_case="client_case"
:contracts="contracts"
:contract_types="contract_types"
:segments="segments"
@edit="openDrawerEditContract"
@delete="requestDeleteContract"
@add-activity="openDrawerAddActivity"
@ -252,4 +282,21 @@ const hideClietnDetails = () => {
@close="closeConfirmDelete"
@confirm="doDeleteContract"
/>
<DialogModal :show="showAttachSegment" @close="closeAttachSegment">
<template #title>Dodaj segment k primeru</template>
<template #content>
<div class="space-y-2">
<label class="block text-sm font-medium text-gray-700">Segment</label>
<select v-model="attachForm.segment_id" class="w-full rounded border-gray-300 focus:border-indigo-500 focus:ring-indigo-500">
<option :value="null" disabled>-- izberi segment --</option>
<option v-for="s in availableSegments" :key="s.id" :value="s.id">{{ s.name }}</option>
</select>
<div v-if="attachForm.errors.segment_id" class="text-sm text-red-600">{{ attachForm.errors.segment_id }}</div>
</div>
</template>
<template #footer>
<FwbButton color="light" @click="closeAttachSegment">Prekliči</FwbButton>
<FwbButton class="ml-2" :disabled="attachForm.processing || !attachForm.segment_id" @click="submitAttachSegment">Dodaj</FwbButton>
</template>
</DialogModal>
</template>

View File

@ -0,0 +1,172 @@
<script setup>
import AppLayout from '@/Layouts/AppLayout.vue'
import SectionTitle from '@/Components/SectionTitle.vue'
import PrimaryButton from '@/Components/PrimaryButton.vue'
import DialogModal from '@/Components/DialogModal.vue'
import ConfirmationModal from '@/Components/ConfirmationModal.vue'
import InputLabel from '@/Components/InputLabel.vue'
import InputError from '@/Components/InputError.vue'
import { useForm, router } from '@inertiajs/vue3'
import { ref } from 'vue'
const props = defineProps({
configs: Array,
types: Array,
segments: Array,
})
// create modal
const showCreate = ref(false)
const openCreate = () => { showCreate.value = true }
const closeCreate = () => { showCreate.value = false; createForm.reset() }
const createForm = useForm({ contract_type_id: null, segment_id: null, is_initial: false })
const submitCreate = () => {
createForm.post(route('settings.contractConfigs.store'), {
preserveScroll: true,
onSuccess: () => closeCreate(),
})
}
// inline edit
const editing = ref(null)
const editForm = useForm({ segment_id: null, is_initial: false, active: true })
const openEdit = (row) => { editing.value = row; editForm.segment_id = row?.segment_id ?? row?.segment?.id; editForm.is_initial = !!row.is_initial; editForm.active = !!row.active }
const closeEdit = () => { editing.value = null }
const submitEdit = () => {
if (!editing.value) return
editForm.put(route('settings.contractConfigs.update', editing.value.id), {
preserveScroll: true,
onSuccess: () => closeEdit(),
})
}
// delete confirmation
const showDelete = ref(false)
const toDelete = ref(null)
const confirmDelete = (row) => { toDelete.value = row; showDelete.value = true }
const cancelDelete = () => { toDelete.value = null; showDelete.value = false }
const destroyConfig = () => {
if (!toDelete.value) return
router.delete(route('settings.contractConfigs.destroy', toDelete.value.id), {
preserveScroll: true,
onFinish: () => cancelDelete(),
})
}
</script>
<template>
<AppLayout title="Settings: Contract Configurations">
<template #header />
<div class="pt-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg border-l-4 border-indigo-400">
<div class="p-4 flex items-center justify-between">
<SectionTitle>
<template #title>Contract configurations</template>
</SectionTitle>
<PrimaryButton @click="openCreate">+ New</PrimaryButton>
</div>
<div class="px-4 pb-4">
<table class="min-w-full text-left text-sm">
<thead>
<tr class="border-b">
<th class="py-2 pr-4">Type</th>
<th class="py-2 pr-4">Segment</th>
<th class="py-2 pr-4">Active</th>
<th class="py-2 pr-4 text-right">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="cfg in configs" :key="cfg.id" class="border-b last:border-0">
<td class="py-2 pr-4">{{ cfg.type?.name }}</td>
<td class="py-2 pr-4">{{ cfg.segment?.name }} <span v-if="cfg.is_initial" class="ml-2 text-xs text-indigo-600">(initial)</span></td>
<td class="py-2 pr-4">{{ cfg.active ? 'Yes' : 'No' }}</td>
<td class="py-2 pr-4 text-right">
<button class="px-2 py-1 text-indigo-600 hover:underline" @click="openEdit(cfg)">Edit</button>
<button class="ml-2 px-2 py-1 text-red-600 hover:underline" @click="confirmDelete(cfg)">Delete</button>
</td>
</tr>
<tr v-if="!configs || configs.length === 0">
<td colspan="4" class="py-6 text-center text-gray-500">No configurations.</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- create modal -->
<DialogModal :show="showCreate" @close="closeCreate">
<template #title>New Contract Configuration</template>
<template #content>
<div class="space-y-4">
<div>
<InputLabel for="type">Contract Type</InputLabel>
<select id="type" v-model="createForm.contract_type_id" class="mt-1 w-full rounded border-gray-300 focus:border-indigo-500 focus:ring-indigo-500">
<option :value="null" disabled>-- select type --</option>
<option v-for="t in types" :key="t.id" :value="t.id">{{ t.name }}</option>
</select>
<InputError :message="createForm.errors.contract_type_id" />
</div>
<div>
<InputLabel for="segment">Segment</InputLabel>
<select id="segment" v-model="createForm.segment_id" class="mt-1 w-full rounded border-gray-300 focus:border-indigo-500 focus:ring-indigo-500">
<option :value="null" disabled>-- select segment --</option>
<option v-for="s in segments" :key="s.id" :value="s.id">{{ s.name }}</option>
</select>
<InputError :message="createForm.errors.segment_id" />
<div class="mt-3 flex items-center gap-2">
<input id="is_initial" type="checkbox" v-model="createForm.is_initial" class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
<label for="is_initial" class="text-sm text-gray-700">Mark as initial</label>
</div>
</div>
</div>
</template>
<template #footer>
<button class="px-4 py-2 rounded bg-gray-200" @click="closeCreate">Cancel</button>
<PrimaryButton class="ml-2" :disabled="createForm.processing || !createForm.contract_type_id || !createForm.segment_id" @click="submitCreate">Create</PrimaryButton>
</template>
</DialogModal>
<!-- simple inline edit dialog -->
<DialogModal :show="!!editing" @close="closeEdit">
<template #title>Edit Configuration</template>
<template #content>
<div class="space-y-4">
<div>
<InputLabel>Segment</InputLabel>
<select v-model="editForm.segment_id" class="mt-1 w-full rounded border-gray-300 focus:border-indigo-500 focus:ring-indigo-500">
<option v-for="s in segments" :key="s.id" :value="s.id">{{ s.name }}</option>
</select>
<InputError :message="editForm.errors.segment_id" />
</div>
<div class="flex items-center gap-2">
<input id="is_initial_edit" type="checkbox" v-model="editForm.is_initial" class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
<label for="is_initial_edit" class="text-sm text-gray-700">Initial</label>
</div>
<div class="flex items-center gap-2">
<input id="active" type="checkbox" v-model="editForm.active" class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
<label for="active" class="text-sm text-gray-700">Active</label>
</div>
</div>
</template>
<template #footer>
<button class="px-4 py-2 rounded bg-gray-200" @click="closeEdit">Cancel</button>
<PrimaryButton class="ml-2" :disabled="editForm.processing || !editForm.segment_id" @click="submitEdit">Save</PrimaryButton>
</template>
</DialogModal>
</AppLayout>
<ConfirmationModal :show="showDelete" @close="cancelDelete">
<template #title>
Delete configuration
</template>
<template #content>
Are you sure you want to delete configuration for type "{{ toDelete?.type?.name }}"?
</template>
<template #footer>
<button @click="cancelDelete" class="px-4 py-2 rounded bg-gray-200 hover:bg-gray-300 me-2">Cancel</button>
<PrimaryButton @click="destroyConfig">Delete</PrimaryButton>
</template>
</ConfirmationModal>
</template>

View File

@ -1,10 +1,51 @@
<script setup>
import AppLayout from '@/Layouts/AppLayout.vue';
import { Link } from '@inertiajs/vue3';
import { Link, useForm } from '@inertiajs/vue3';
import DialogModal from '@/Components/DialogModal.vue';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import InputLabel from '@/Components/InputLabel.vue';
import InputError from '@/Components/InputError.vue';
import { ref, onMounted } from 'vue';
import Multiselect from 'vue-multiselect';
const props = defineProps({
settings: Array,
segments: Array,
decisions: Array,
});
const showCreate = ref(false);
const segmentOptions = ref([]);
const decisionOptions = ref([]);
onMounted(() => {
segmentOptions.value = (props.segments || []).map(s => ({ id: s.id, name: s.name }));
decisionOptions.value = (props.decisions || []).map(d => ({ id: d.id, name: d.name }));
});
const form = useForm({
segment_id: null,
initial_decision_id: null,
asign_decision_id: null,
complete_decision_id: null,
});
const openCreate = () => {
form.reset();
showCreate.value = true;
};
const closeCreate = () => {
showCreate.value = false;
form.reset();
};
const store = () => {
form.post(route('settings.fieldjob.store'), {
preserveScroll: true,
onSuccess: () => closeCreate(),
});
};
</script>
<template>
@ -12,20 +53,93 @@ const props = defineProps({
<template #header></template>
<div class="pt-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6">
<h3 class="text-lg font-semibold mb-2">Segments</h3>
<p class="text-sm text-gray-600 mb-4">Manage segments used across the app.</p>
<Link :href="route('settings.segments')" class="inline-flex items-center px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700">Open Segments</Link>
</div>
</div>
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6">
<h2 class="text-xl font-semibold mb-4">Field Job Settings</h2>
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold">Field Job Settings</h2>
<PrimaryButton @click="openCreate">+ New</PrimaryButton>
</div>
<DialogModal :show="showCreate" @close="closeCreate">
<template #title>
Create Field Job Setting
</template>
<template #content>
<form @submit.prevent="store">
<div class="grid grid-cols-1 gap-4">
<div>
<InputLabel for="segment" value="Segment" />
<multiselect
id="segment"
v-model="form.segment_id"
:options="segmentOptions.map(o=>o.id)"
:multiple="false"
:searchable="true"
placeholder="Select segment"
:append-to-body="true"
:custom-label="(opt) => (segmentOptions.find(o=>o.id===opt)?.name || '')"
/>
<InputError :message="form.errors.segment_id" class="mt-1" />
</div>
<div>
<InputLabel for="initialDecision" value="Initial Decision" />
<multiselect
id="initialDecision"
v-model="form.initial_decision_id"
:options="decisionOptions.map(o=>o.id)"
:multiple="false"
:searchable="true"
placeholder="Select initial decision"
:append-to-body="true"
:custom-label="(opt) => (decisionOptions.find(o=>o.id===opt)?.name || '')"
/>
<InputError :message="form.errors.initial_decision_id" class="mt-1" />
</div>
<div>
<InputLabel for="assignDecision" value="Assign Decision" />
<multiselect
id="assignDecision"
v-model="form.asign_decision_id"
:options="decisionOptions.map(o=>o.id)"
:multiple="false"
:searchable="true"
placeholder="Select assign decision"
:append-to-body="true"
:custom-label="(opt) => (decisionOptions.find(o=>o.id===opt)?.name || '')"
/>
<InputError :message="form.errors.asign_decision_id" class="mt-1" />
</div>
<div class="mt-2">
<InputLabel for="completeDecision" value="Complete Decision" />
<multiselect
id="completeDecision"
v-model="form.complete_decision_id"
:options="decisionOptions.map(o=>o.id)"
:multiple="false"
:searchable="true"
placeholder="Select complete decision"
:append-to-body="true"
:custom-label="(opt) => (decisionOptions.find(o=>o.id===opt)?.name || '')"
/>
<InputError :message="form.errors.complete_decision_id" class="mt-1" />
</div>
</div>
<div class="flex justify-end gap-2 mt-6">
<button type="button" @click="closeCreate" class="px-4 py-2 rounded bg-gray-200 hover:bg-gray-300">Cancel</button>
<PrimaryButton :disabled="form.processing">Create</PrimaryButton>
</div>
</form>
</template>
</DialogModal>
<table class="min-w-full text-left text-sm">
<thead>
<tr class="border-b">
<th class="py-2 pr-4">ID</th>
<th class="py-2 pr-4">Segment</th>
<th class="py-2 pr-4">Initial Decision</th>
<th class="py-2 pr-4">Assign Decision</th>
<th class="py-2 pr-4">Complete Decision</th>
</tr>
@ -34,6 +148,7 @@ const props = defineProps({
<tr v-for="row in settings" :key="row.id" class="border-b last:border-0">
<td class="py-2 pr-4">{{ row.id }}</td>
<td class="py-2 pr-4">{{ row.segment?.name }}</td>
<td class="py-2 pr-4">{{ row.initial_decision?.name || row.initialDecision?.name }}</td>
<td class="py-2 pr-4">{{ row.asign_decision?.name || row.asignDecision?.name }}</td>
<td class="py-2 pr-4">{{ row.complete_decision?.name || row.completeDecision?.name }}</td>
</tr>

View File

@ -24,6 +24,11 @@ import { Link } from '@inertiajs/vue3';
<p class="text-sm text-gray-600 mb-4">Configure segment-based field job rules.</p>
<Link :href="route('settings.fieldjob.index')" class="inline-flex items-center px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700">Open Field Job</Link>
</div>
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6">
<h3 class="text-lg font-semibold mb-2">Contract Configs</h3>
<p class="text-sm text-gray-600 mb-4">Auto-assign initial segments for contracts by type.</p>
<Link :href="route('settings.contractConfigs.index')" class="inline-flex items-center px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700">Open Contract Configs</Link>
</div>
</div>
</div>
</div>

View File

@ -1,9 +1,10 @@
<script setup>
import { FwbTable, FwbTableBody, FwbTableHead, FwbTableHeadCell, FwbTableCell, FwbTableRow, FwbDropdown } from 'flowbite-vue';
import { DottedMenu, EditIcon, TrashBinIcon } from '@/Utilities/Icons';
import { FwbTable, FwbTableBody, FwbTableHead, FwbTableHeadCell, FwbTableCell, FwbTableRow } from 'flowbite-vue';
import { EditIcon, TrashBinIcon } from '@/Utilities/Icons';
import DialogModal from '@/Components/DialogModal.vue';
import { onMounted, ref } from 'vue';
import { useForm } from '@inertiajs/vue3';
import ConfirmationModal from '@/Components/ConfirmationModal.vue';
import { computed, onMounted, ref, watch } from 'vue';
import { router, useForm } from '@inertiajs/vue3';
import InputLabel from '@/Components/InputLabel.vue';
import TextInput from '@/Components/TextInput.vue';
import Multiselect from 'vue-multiselect';
@ -16,10 +17,13 @@ const props = defineProps({
segments: Array
});
const menuDropdown = ref();
const drawerEdit = ref(false);
const drawerCreate = ref(false);
const showDelete = ref(false);
const toDelete = ref(null);
const search = ref('');
const selectedSegment = ref(null);
const selectOptions = ref([]);
const segmentOptions = ref([]);
@ -91,6 +95,15 @@ onMounted(() => {
});
const filtered = computed(() => {
const term = search.value?.toLowerCase() ?? '';
return (props.actions || []).filter(a => {
const matchesSearch = !term || a.name?.toLowerCase().includes(term) || a.color_tag?.toLowerCase().includes(term);
const matchesSegment = !selectedSegment.value || a.segment?.id === selectedSegment.value;
return matchesSearch && matchesSegment;
});
});
const update = () => {
form.put(route('settings.actions.update', { id: form.id }), {
onSuccess: () => {
@ -107,11 +120,44 @@ const store = () => {
});
};
const confirmDelete = (action) => {
toDelete.value = action;
showDelete.value = true;
};
const cancelDelete = () => {
toDelete.value = null;
showDelete.value = false;
};
const destroyAction = () => {
if (!toDelete.value) return;
router.delete(route('settings.actions.destroy', { id: toDelete.value.id }), {
preserveScroll: true,
onFinish: () => cancelDelete(),
});
};
</script>
<template>
<div class="py-4 px-3">
<div class="p-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="flex gap-3 items-center w-full sm:w-auto">
<TextInput v-model="search" placeholder="Search actions..." class="w-full sm:w-64" />
<div class="w-64">
<Multiselect
v-model="selectedSegment"
:options="segmentOptions.map(o=>o.id)"
:multiple="false"
:searchable="true"
placeholder="Filter by segment"
:append-to-body="true"
:custom-label="(opt) => (segmentOptions.find(o=>o.id===opt)?.name || '')"
/>
</div>
</div>
<PrimaryButton @click="openCreateDrawer">+ Dodaj akcijo</PrimaryButton>
</div>
<fwb-table>
<fwb-table-head>
<fwb-table-head-cell>#</fwb-table-head-cell>
@ -123,22 +169,25 @@ const store = () => {
</fwb-table-head-cell>
</fwb-table-head>
<fwb-table-body>
<fwb-table-row v-for="act in actions" :key="act.id">
<fwb-table-row v-for="act in filtered" :key="act.id">
<fwb-table-cell>{{ act.id }}</fwb-table-cell>
<fwb-table-cell>{{ act.name }}</fwb-table-cell>
<fwb-table-cell>{{ act.color_tag }}</fwb-table-cell>
<fwb-table-cell>
<div class="flex items-center gap-2">
<span class="inline-block h-4 w-4 rounded" :style="{ backgroundColor: act.color_tag }"></span>
<span>{{ act.color_tag }}</span>
</div>
</fwb-table-cell>
<fwb-table-cell>{{ act.decisions.length }}</fwb-table-cell>
<fwb-table-cell>
<button class="px-2" @click="openEditDrawer(act)"><EditIcon size="md" css="text-gray-500" /></button>
<button class="px-2 disabled:opacity-40" :disabled="(act.activities_count ?? 0) > 0" @click="confirmDelete(act)"><TrashBinIcon size="md" css="text-red-500" /></button>
</fwb-table-cell>
</fwb-table-row>
</fwb-table-body>
</fwb-table>
<DialogModal
:show="drawerEdit"
@close="closeEditDrawer"
>
<DialogModal :show="drawerEdit" @close="closeEditDrawer">
<template #title>
<span>Spremeni akcijo</span>
</template>
@ -215,10 +264,7 @@ const store = () => {
</template>
</DialogModal>
<DialogModal
:show="drawerCreate"
@close="closeCreateDrawer"
>
<DialogModal :show="drawerCreate" @close="closeCreateDrawer">
<template #title>
<span>Dodaj akcijo</span>
</template>
@ -290,4 +336,17 @@ const store = () => {
</form>
</template>
</DialogModal>
<ConfirmationModal :show="showDelete" @close="cancelDelete">
<template #title>
Delete action
</template>
<template #content>
Are you sure you want to delete action "{{ toDelete?.name }}"? This cannot be undone.
</template>
<template #footer>
<button @click="cancelDelete" class="px-4 py-2 rounded bg-gray-200 hover:bg-gray-300 me-2">Cancel</button>
<PrimaryButton @click="destroyAction">Delete</PrimaryButton>
</template>
</ConfirmationModal>
</template>

View File

@ -1,9 +1,10 @@
<script setup>
import { FwbTable, FwbTableBody, FwbTableHead, FwbTableHeadCell, FwbTableCell, FwbTableRow } from 'flowbite-vue';
import { EditIcon } from '@/Utilities/Icons';
import { EditIcon, TrashBinIcon } from '@/Utilities/Icons';
import DialogModal from '@/Components/DialogModal.vue';
import { onMounted, ref } from 'vue';
import { useForm } from '@inertiajs/vue3';
import ConfirmationModal from '@/Components/ConfirmationModal.vue';
import { computed, onMounted, ref } from 'vue';
import { router, useForm } from '@inertiajs/vue3';
import InputLabel from '@/Components/InputLabel.vue';
import TextInput from '@/Components/TextInput.vue';
import Multiselect from 'vue-multiselect';
@ -17,6 +18,10 @@ const props = defineProps({
const drawerEdit = ref(false);
const drawerCreate = ref(false);
const showDelete = ref(false);
const toDelete = ref(null);
const search = ref('');
const actionOptions = ref([]);
@ -72,6 +77,13 @@ onMounted(() => {
});
});
const filtered = computed(() => {
const term = search.value?.toLowerCase() ?? '';
return (props.decisions || []).filter(d => {
return !term || d.name?.toLowerCase().includes(term) || d.color_tag?.toLowerCase().includes(term);
});
});
const update = () => {
form.put(route('settings.decisions.update', { id: form.id }), {
onSuccess: () => {
@ -88,11 +100,31 @@ const store = () => {
});
};
const confirmDelete = (decision) => {
toDelete.value = decision;
showDelete.value = true;
};
const cancelDelete = () => {
toDelete.value = null;
showDelete.value = false;
};
const destroyDecision = () => {
if (!toDelete.value) return;
router.delete(route('settings.decisions.destroy', { id: toDelete.value.id }), {
preserveScroll: true,
onFinish: () => cancelDelete(),
});
};
</script>
<template>
<div class="py-4 px-3">
<div class="p-4 flex items-center justify-between gap-3">
<TextInput v-model="search" placeholder="Search decisions..." class="w-full sm:w-72" />
<PrimaryButton @click="openCreateDrawer">+ Dodaj odločitev</PrimaryButton>
</div>
<fwb-table>
<fwb-table-head>
<fwb-table-head-cell>#</fwb-table-head-cell>
@ -104,22 +136,25 @@ const store = () => {
</fwb-table-head-cell>
</fwb-table-head>
<fwb-table-body>
<fwb-table-row v-for="d in decisions" :key="d.id">
<fwb-table-row v-for="d in filtered" :key="d.id">
<fwb-table-cell>{{ d.id }}</fwb-table-cell>
<fwb-table-cell>{{ d.name }}</fwb-table-cell>
<fwb-table-cell>{{ d.color_tag }}</fwb-table-cell>
<fwb-table-cell>
<div class="flex items-center gap-2">
<span class="inline-block h-4 w-4 rounded" :style="{ backgroundColor: d.color_tag }"></span>
<span>{{ d.color_tag }}</span>
</div>
</fwb-table-cell>
<fwb-table-cell>{{ d.actions.length }}</fwb-table-cell>
<fwb-table-cell>
<button class="px-2" @click="openEditDrawer(d)"><EditIcon size="md" css="text-gray-500" /></button>
<button class="px-2 disabled:opacity-40" :disabled="(d.activities_count ?? 0) > 0" @click="confirmDelete(d)"><TrashBinIcon size="md" css="text-red-500" /></button>
</fwb-table-cell>
</fwb-table-row>
</fwb-table-body>
</fwb-table>
<DialogModal
:show="drawerEdit"
@close="closeEditDrawer"
>
<DialogModal :show="drawerEdit" @close="closeEditDrawer">
<template #title>
<span>Spremeni odločitev</span>
</template>
@ -177,10 +212,7 @@ const store = () => {
</template>
</DialogModal>
<DialogModal
:show="drawerCreate"
@close="closeCreateDrawer"
>
<DialogModal :show="drawerCreate" @close="closeCreateDrawer">
<template #title>
<span>Dodaj odločitev</span>
</template>
@ -237,4 +269,17 @@ const store = () => {
</form>
</template>
</DialogModal>
<ConfirmationModal :show="showDelete" @close="cancelDelete">
<template #title>
Delete decision
</template>
<template #content>
Are you sure you want to delete decision "{{ toDelete?.name }}"? This cannot be undone.
</template>
<template #footer>
<button @click="cancelDelete" class="px-4 py-2 rounded bg-gray-200 hover:bg-gray-300 me-2">Cancel</button>
<PrimaryButton @click="destroyDecision">Delete</PrimaryButton>
</template>
</ConfirmationModal>
</template>

View File

@ -1,9 +1,73 @@
<script setup>
import AppLayout from '@/Layouts/AppLayout.vue';
import { useForm, router } from '@inertiajs/vue3';
import { ref } from 'vue';
import DialogModal from '@/Components/DialogModal.vue';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import InputLabel from '@/Components/InputLabel.vue';
import InputError from '@/Components/InputError.vue';
import TextInput from '@/Components/TextInput.vue';
const props = defineProps({
segments: Array,
});
const showCreate = ref(false);
const showEdit = ref(false);
const editing = ref(null);
const createForm = useForm({
name: '',
description: '',
active: true,
});
const editForm = useForm({
id: null,
name: '',
description: '',
active: true,
});
const openCreate = () => {
createForm.reset();
createForm.active = true;
showCreate.value = true;
};
const closeCreate = () => {
showCreate.value = false;
createForm.reset();
};
const store = () => {
createForm.post(route('settings.segments.store'), {
preserveScroll: true,
onSuccess: () => closeCreate(),
});
};
const openEdit = (segment) => {
editing.value = segment;
editForm.id = segment.id;
editForm.name = segment.name;
editForm.description = segment.description ?? '';
editForm.active = !!segment.active;
showEdit.value = true;
};
const closeEdit = () => {
showEdit.value = false;
editing.value = null;
editForm.reset();
};
const update = () => {
editForm.put(route('settings.segments.update', { segment: editForm.id }), {
preserveScroll: true,
onSuccess: () => closeEdit(),
});
};
</script>
<template>
@ -12,10 +76,93 @@ const props = defineProps({
<div class="pt-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6">
<h2 class="text-xl font-semibold mb-4">Segments</h2>
<ul class="list-disc list-inside">
<li v-for="s in segments" :key="s.id">{{ s.name }}</li>
</ul>
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold">Segments</h2>
<PrimaryButton @click="openCreate">+ New</PrimaryButton>
</div>
<DialogModal :show="showCreate" @close="closeCreate">
<template #title>New Segment</template>
<template #content>
<form @submit.prevent="store" class="space-y-4">
<div>
<InputLabel for="nameCreate" value="Name" />
<TextInput id="nameCreate" v-model="createForm.name" type="text" class="mt-1 block w-full" />
<InputError :message="createForm.errors.name" class="mt-1" />
</div>
<div>
<InputLabel for="descCreate" value="Description" />
<TextInput id="descCreate" v-model="createForm.description" type="text" class="mt-1 block w-full" />
<InputError :message="createForm.errors.description" class="mt-1" />
</div>
<div class="flex items-center gap-2">
<input id="activeCreate" type="checkbox" v-model="createForm.active" />
<label for="activeCreate">Active</label>
</div>
<div class="flex justify-end gap-2 mt-4">
<button type="button" @click="closeCreate" class="px-4 py-2 rounded bg-gray-200 hover:bg-gray-300">Cancel</button>
<PrimaryButton :disabled="createForm.processing">Create</PrimaryButton>
</div>
</form>
</template>
</DialogModal>
<DialogModal :show="showEdit" @close="closeEdit">
<template #title>Edit Segment</template>
<template #content>
<form @submit.prevent="update" class="space-y-4">
<div>
<InputLabel for="nameEdit" value="Name" />
<TextInput id="nameEdit" v-model="editForm.name" type="text" class="mt-1 block w-full" />
<InputError :message="editForm.errors.name" class="mt-1" />
</div>
<div>
<InputLabel for="descEdit" value="Description" />
<TextInput id="descEdit" v-model="editForm.description" type="text" class="mt-1 block w-full" />
<InputError :message="editForm.errors.description" class="mt-1" />
</div>
<div class="flex items-center gap-2">
<input id="activeEdit" type="checkbox" v-model="editForm.active" />
<label for="activeEdit">Active</label>
</div>
<div class="flex justify-end gap-2 mt-4">
<button type="button" @click="closeEdit" class="px-4 py-2 rounded bg-gray-200 hover:bg-gray-300">Cancel</button>
<PrimaryButton :disabled="editForm.processing">Save</PrimaryButton>
</div>
</form>
</template>
</DialogModal>
<table class="min-w-full text-left text-sm">
<thead>
<tr class="border-b">
<th class="py-2 pr-4">ID</th>
<th class="py-2 pr-4">Name</th>
<th class="py-2 pr-4">Description</th>
<th class="py-2 pr-4">Active</th>
<th class="py-2 pr-4">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="s in segments" :key="s.id" class="border-b last:border-0">
<td class="py-2 pr-4">{{ s.id }}</td>
<td class="py-2 pr-4">{{ s.name }}</td>
<td class="py-2 pr-4">{{ s.description }}</td>
<td class="py-2 pr-4">
<span class="inline-flex items-center gap-1">
<span :class="s.active ? 'bg-green-500' : 'bg-gray-400'" class="inline-block w-2 h-2 rounded-full"></span>
{{ s.active ? 'Yes' : 'No' }}
</span>
</td>
<td class="py-2 pr-4">
<button class="text-indigo-600 hover:text-indigo-800" @click="openEdit(s)">Edit</button>
<!-- Delete intentionally skipped as requested -->
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>

View File

@ -10,6 +10,11 @@
// with `$trail`. This is nice for IDE type checking and completion.
use Diglactic\Breadcrumbs\Generator as BreadcrumbTrail;
Breadcrumbs::for('settings.contractConfigs.index', function (BreadcrumbTrail $trail): void {
$trail->parent('settings');
$trail->push('Contract Configs', route('settings.contractConfigs.index'));
});
// Dashboard
Breadcrumbs::for('dashboard', function (BreadcrumbTrail $trail) {
$trail->push('Nadzorna plošča', route('dashboard'));
@ -46,3 +51,21 @@
$trail->parent('dashboard');
$trail->push('Nastavitve', route('settings'));
});
// Dashboard > Settings > Segments
Breadcrumbs::for('settings.segments', function (BreadcrumbTrail $trail) {
$trail->parent('settings');
$trail->push('Segmenti', route('settings.segments'));
});
// Dashboard > Settings > Workflow
Breadcrumbs::for('settings.workflow', function (BreadcrumbTrail $trail) {
$trail->parent('settings');
$trail->push('Potek dela', route('settings.workflow'));
});
// Dashboard > Settings > Field Job
Breadcrumbs::for('settings.fieldjob.index', function (BreadcrumbTrail $trail) {
$trail->parent('settings');
$trail->push('Terensko delo', route('settings.fieldjob.index'));
});

View File

@ -9,6 +9,7 @@
use App\Http\Controllers\WorkflowController;
use App\Http\Controllers\SegmentController;
use App\Http\Controllers\FieldJobSettingController;
use App\Http\Controllers\ContractConfigController;
use App\Http\Controllers\ImportController;
use App\Http\Controllers\ImportTemplateController;
use App\Http\Controllers\CaseObjectController;
@ -118,6 +119,7 @@
//client-case
Route::get('client-cases', [ClientCaseContoller::class, 'index'])->name('clientCase');
Route::get('client-cases/{client_case:uuid}', [ClientCaseContoller::class, 'show'])->name('clientCase.show');
Route::post('client-cases/{client_case:uuid}/contracts/{uuid}/segment', [ClientCaseContoller::class, 'updateContractSegment'])->name('clientCase.contract.updateSegment');
Route::post('client-cases', [ClientCaseContoller::class, 'store'])->name('clientCase.store');
//client-case / contract
Route::post('client-cases/{client_case:uuid}/contract', [ClientCaseContoller::class, 'storeContract'])->name('clientCase.contract.store');
@ -129,6 +131,8 @@
Route::delete('client-cases/{client_case:uuid}/objects/{id}', [CaseObjectController::class, 'destroy'])->name('clientCase.object.delete');
//client-case / activity
Route::post('client-cases/{client_case:uuid}/activity', [ClientCaseContoller::class, 'storeActivity'])->name('clientCase.activity.store');
// client-case / segments
Route::post('client-cases/{client_case:uuid}/segments', [ClientCaseContoller::class, 'attachSegment'])->name('clientCase.segments.attach');
//client-case / documents
Route::post('client-cases/{client_case:uuid}/documents', [ClientCaseContoller::class, 'storeDocument'])->name('clientCase.document.store');
Route::get('client-cases/{client_case:uuid}/documents/{document:uuid}/view', [ClientCaseContoller::class, 'viewDocument'])->name('clientCase.document.view');
@ -136,12 +140,22 @@
//settings
Route::get('settings', [SettingController::class, 'index'])->name('settings');
Route::get('settings/segments', [SegmentController::class, 'settings'])->name('settings.segments');
Route::post('settings/segments', [SegmentController::class, 'store'])->name('settings.segments.store');
Route::put('settings/segments/{segment}', [SegmentController::class, 'update'])->name('settings.segments.update');
Route::get('settings/workflow', [WorkflowController::class, 'index'])->name('settings.workflow');
Route::get('settings/field-job', [FieldJobSettingController::class, 'index'])->name('settings.fieldjob.index');
Route::post('settings/field-job', [FieldJobSettingController::class, 'store'])->name('settings.fieldjob.store');
// settings / contract-configs
Route::get('settings/contract-configs', [ContractConfigController::class, 'index'])->name('settings.contractConfigs.index');
Route::post('settings/contract-configs', [ContractConfigController::class, 'store'])->name('settings.contractConfigs.store');
Route::put('settings/contract-configs/{config}', [ContractConfigController::class, 'update'])->name('settings.contractConfigs.update');
Route::delete('settings/contract-configs/{config}', [ContractConfigController::class, 'destroy'])->name('settings.contractConfigs.destroy');
Route::post('settings/actions', [WorkflowController::class, 'storeAction'])->name('settings.actions.store');
Route::put('settings/actions/{id}', [WorkflowController::class, 'updateAction'])->name('settings.actions.update');
Route::post('settings/decisions', [WorkflowController::class, 'storeDecision'])->name('settings.decisions.store');
Route::put('settings/decisions/{id}', [WorkflowController::class, 'updateDecision'])->name('settings.decisions.update');
Route::delete('settings/actions/{id}', [WorkflowController::class, 'destroyAction'])->name('settings.actions.destroy');
Route::delete('settings/decisions/{id}', [WorkflowController::class, 'destroyDecision'])->name('settings.decisions.destroy');
// imports
Route::get('imports/create', [ImportController::class, 'create'])->name('imports.create');