field job added option to add multiple contracts to user at once

This commit is contained in:
Simon Pocrnjič 2025-10-16 21:28:40 +02:00
parent e782bcca7c
commit 04f31e62aa
8 changed files with 247 additions and 18 deletions

View File

@ -132,6 +132,77 @@ public function assign(Request $request)
}
/**
* Bulk assign multiple contracts to a single user.
*/
public function assignBulk(Request $request)
{
$data = $request->validate([
'contract_uuids' => 'required|array|min:1',
'contract_uuids.*' => 'required|string|distinct|exists:contracts,uuid',
'assigned_user_id' => 'required|integer|exists:users,id',
]);
try {
DB::transaction(function () use ($data) {
$setting = FieldJobSetting::query()->latest('id')->first();
if (! $setting) {
throw new Exception('No Field Job Setting found. Create one in Settings → Field Job Settings.');
}
if (! ($setting->action_id && $setting->assign_decision_id)) {
throw new Exception('The current Field Job Setting is missing an action or assign decision. Please update it in Settings → Field Job Settings.');
}
$assigneeName = User::query()->where('id', $data['assigned_user_id'])->value('name');
$noteBase = 'Terensko opravilo dodeljeno'.($assigneeName ? ' uporabniku '.$assigneeName : '');
// Load all contracts in one query
$contracts = Contract::query()->whereIn('uuid', $data['contract_uuids'])->get();
foreach ($contracts as $contract) {
// Skip if already has an active job
$hasActive = FieldJob::query()
->where('contract_id', $contract->id)
->whereNull('completed_at')
->whereNull('cancelled_at')
->exists();
if ($hasActive) {
continue;
}
$job = FieldJob::create([
'field_job_setting_id' => $setting->id,
'assigned_user_id' => $data['assigned_user_id'],
'contract_id' => $contract->id,
'assigned_at' => now(),
]);
Activity::create([
'due_date' => null,
'amount' => null,
'note' => $noteBase,
'action_id' => $setting->action_id,
'decision_id' => $setting->assign_decision_id,
'client_case_id' => $contract->client_case_id,
'contract_id' => $contract->id,
]);
// Move contract to the configured segment for field jobs
$job->moveContractToSegment($setting->segment_id);
}
});
return back()->with('success', 'Field jobs assigned.');
} catch (QueryException $e) {
return back()->withErrors(['database' => 'Database error: '.$e->getMessage()]);
} catch (Exception $e) {
return back()->withErrors(['error' => 'Error: '.$e->getMessage()]);
}
}
public function cancel(Request $request)
{
$data = $request->validate([

View File

@ -3,6 +3,7 @@
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
use App\Models\Segment;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Action>
@ -19,6 +20,7 @@ public function definition(): array
return [
'name' => $this->faker->unique()->words(2, true),
'color_tag' => $this->faker->optional()->safeColorName(),
'segment_id' => Segment::factory(),
];
}
}

View File

@ -0,0 +1,37 @@
<?php
namespace Database\Factories;
use App\Models\Action;
use App\Models\Decision;
use App\Models\Segment;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\FieldJobSetting>
*/
class FieldJobSettingFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
$action = Action::factory()->create();
$decision = Decision::factory()->create();
$segment = Segment::factory()->create();
return [
'segment_id' => $segment->id,
'initial_decision_id' => $decision->id,
'assign_decision_id' => $decision->id,
'complete_decision_id' => $decision->id,
'cancel_decision_id' => $decision->id,
'return_segment_id' => $segment->id,
'queue_segment_id' => $segment->id,
'action_id' => $action->id,
];
}
}

View File

@ -78,21 +78,20 @@ function buildRelated(a) {
// Only client case link (other routes not defined yet)
if (a.client_case_uuid || a.client_case_id) {
const caseParam = a.client_case_uuid || a.client_case_id;
if (typeof route === "function" && route().hasOwnProperty) {
try {
links.push({
type: "client_case",
label: "Primer",
href: route("clientCase.show", caseParam),
});
} catch (e) {
/* silently ignore */
}
} else {
try {
// Prefer Ziggy when available and force stringification here
const href = String(route("clientCase.show", { client_case: caseParam }));
links.push({
type: "client_case",
label: "Primer",
href: route("clientCase.show", caseParam),
href,
});
} catch (e) {
// Safe fallback to a best-effort URL to avoid breaking render
links.push({
type: "client_case",
label: "Primer",
href: `/client-cases/${caseParam}`,
});
}
}
@ -132,6 +131,24 @@ function formatJobTime(ts) {
return "";
}
}
// Safely build a client case href using Ziggy when available, with a plain fallback.
function safeCaseHref(uuid, segment = null) {
if (!uuid) {
return "#";
}
try {
const params = { client_case: uuid };
if (segment != null) {
params.segment = segment;
}
return String(route("clientCase.show", params));
} catch (e) {
return segment != null
? `/client-cases/${uuid}?segment=${segment}`
: `/client-cases/${uuid}`;
}
}
</script>
<template>
@ -425,10 +442,14 @@ function formatJobTime(ts) {
>
<div class="min-w-0">
<Link
:href="route('clientCase.show', c.uuid)"
v-if="c?.uuid"
:href="safeCaseHref(c.uuid)"
class="text-indigo-600 dark:text-indigo-400 hover:underline font-medium"
>{{ c.client_ref || c.uuid.slice(0, 8) }}</Link
>
<span v-else class="text-gray-700 dark:text-gray-300 font-medium">{{
c.client_ref || "Primer"
}}</span>
<p class="text-[11px] text-gray-400 dark:text-gray-500">
Brez aktivnosti:
{{ formatStaleDaysLabel(c.days_without_activity ?? c.days_stale) }}
@ -479,16 +500,17 @@ function formatJobTime(ts) {
<template v-if="f.contract">
·
<Link
v-if="f.contract.client_case_uuid"
:href="
route('clientCase.show', {
client_case: f.contract.client_case_uuid,
segment: f.contract.segment_id,
})
safeCaseHref(f.contract.client_case_uuid, f.contract.segment_id)
"
class="text-indigo-600 dark:text-indigo-400 hover:underline"
>
{{ f.contract.reference || f.contract.uuid?.slice(0, 8) }}
</Link>
<span v-else class="text-gray-700 dark:text-gray-300">{{
f.contract.reference || f.contract.uuid?.slice(0, 8)
}}</span>
<span
v-if="f.contract.person_full_name"
class="text-gray-500 dark:text-gray-400"

View File

@ -18,6 +18,12 @@ const form = useForm({
end_date: null,
});
// Bulk selection form
const bulkForm = useForm({
contract_uuids: [],
assigned_user_id: null,
});
// Global search (applies to both tables)
const search = ref("");
@ -68,6 +74,16 @@ function assign(contract) {
form.post(route("fieldjobs.assign"));
}
function assignSelected() {
// Use the same selected user as in the single-assign dropdown
bulkForm.assigned_user_id = form.assigned_user_id;
bulkForm.post(route("fieldjobs.assign-bulk"), {
onSuccess: () => {
bulkForm.contract_uuids = [];
},
});
}
function cancelAssignment(contract) {
const payload = { contract_uuid: contract.uuid };
form.transform(() => payload).post(route("fieldjobs.cancel"));
@ -154,6 +170,7 @@ watch([search, assignedFilterUserId], () => {
// Column definitions for DataTableClient
const unassignedColumns = [
{ key: "_select", label: "", class: "w-8" },
{ key: "reference", label: "Pogodba", sortable: true, class: "w-32" },
{
key: "case_person",
@ -307,6 +324,22 @@ const assignedRows = computed(() =>
<div v-if="form.errors.assigned_user_id" class="text-red-600 text-sm mt-1">
{{ form.errors.assigned_user_id }}
</div>
<div class="mt-3 flex items-center gap-2">
<button
class="px-3 py-2 text-sm rounded bg-indigo-600 text-white disabled:opacity-50"
:disabled="!bulkForm.contract_uuids.length || !form.assigned_user_id"
@click="assignSelected"
>
Dodeli izbrane ({{ bulkForm.contract_uuids.length }})
</button>
<button
class="px-3 py-2 text-sm rounded border border-gray-300 disabled:opacity-50"
:disabled="!bulkForm.contract_uuids.length"
@click="bulkForm.contract_uuids = []"
>
Počisti izbor
</button>
</div>
</div>
<DataTableClient
:columns="unassignedColumns"
@ -317,6 +350,14 @@ const assignedRows = computed(() =>
v-model:page="unassignedPage"
v-model:pageSize="unassignedPageSize"
>
<template #cell-_select="{ row }">
<input
type="checkbox"
class="h-4 w-4"
:value="row.uuid"
v-model="bulkForm.contract_uuids"
/>
</template>
<template #cell-case_person="{ row }">
<Link
v-if="row.client_case?.uuid"

View File

@ -1321,7 +1321,7 @@ async function fetchSimulation() {
</div>
<div class="flex-shrink-0">
<a
:href="route('clientCase.show', row.case_uuid)"
:href="route('clientCase.show', { client_case: row.case_uuid })"
class="text-blue-600 hover:underline text-xs"
>Odpri primer</a
>

View File

@ -291,6 +291,7 @@
// field jobs assignment
Route::get('field-jobs', [FieldJobController::class, 'index'])->name('fieldjobs.index');
Route::post('field-jobs/assign', [FieldJobController::class, 'assign'])->name('fieldjobs.assign');
Route::post('field-jobs/assign-bulk', [FieldJobController::class, 'assignBulk'])->name('fieldjobs.assign-bulk');
Route::post('field-jobs/cancel', [FieldJobController::class, 'cancel'])->name('fieldjobs.cancel');
Route::post('settings/field-job', [FieldJobSettingController::class, 'store'])->name('settings.fieldjob.store');
Route::put('settings/field-job/{setting}', [FieldJobSettingController::class, 'update'])->name('settings.fieldjob.update');

View File

@ -0,0 +1,55 @@
<?php
use App\Models\Activity;
use App\Models\ClientCase;
use App\Models\Contract;
use App\Models\FieldJob;
use App\Models\FieldJobSetting;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
it('bulk assigns multiple contracts and skips already assigned', function () {
$user = User::factory()->create();
$assignee = User::factory()->create();
$this->actingAs($user);
// Minimal related data
$case = ClientCase::factory()->create();
$c1 = Contract::factory()->create(['client_case_id' => $case->id]);
$c2 = Contract::factory()->create(['client_case_id' => $case->id]);
$c3 = Contract::factory()->create(['client_case_id' => $case->id]);
// Existing active assignment for c3
$setting = FieldJobSetting::factory()->create([
'segment_id' => 1,
'action_id' => 1,
'assign_decision_id' => 1,
'complete_decision_id' => 1,
'return_segment_id' => 1,
'queue_segment_id' => 1,
]);
FieldJob::create([
'field_job_setting_id' => $setting->id,
'assigned_user_id' => $assignee->id,
'contract_id' => $c3->id,
'assigned_at' => now(),
]);
// Post bulk assign
$resp = $this->post(route('fieldjobs.assign-bulk'), [
'contract_uuids' => [$c1->uuid, $c2->uuid, $c3->uuid],
'assigned_user_id' => $assignee->id,
]);
$resp->assertRedirect();
// c1 and c2 should have active jobs
expect(FieldJob::where('contract_id', $c1->id)->whereNull('cancelled_at')->whereNull('completed_at')->exists())->toBeTrue();
expect(FieldJob::where('contract_id', $c2->id)->whereNull('cancelled_at')->whereNull('completed_at')->exists())->toBeTrue();
// c3 should still have only one active job
expect(FieldJob::where('contract_id', $c3->id)->whereNull('cancelled_at')->whereNull('completed_at')->count())->toBe(1);
// Activities were created for c1 and c2 (two records)
expect(Activity::where('contract_id', $c1->id)->exists())->toBeTrue();
expect(Activity::where('contract_id', $c2->id)->exists())->toBeTrue();
});