field job added option to add multiple contracts to user at once
This commit is contained in:
parent
e782bcca7c
commit
04f31e62aa
|
|
@ -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)
|
public function cancel(Request $request)
|
||||||
{
|
{
|
||||||
$data = $request->validate([
|
$data = $request->validate([
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
namespace Database\Factories;
|
namespace Database\Factories;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
use App\Models\Segment;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Action>
|
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Action>
|
||||||
|
|
@ -19,6 +20,7 @@ public function definition(): array
|
||||||
return [
|
return [
|
||||||
'name' => $this->faker->unique()->words(2, true),
|
'name' => $this->faker->unique()->words(2, true),
|
||||||
'color_tag' => $this->faker->optional()->safeColorName(),
|
'color_tag' => $this->faker->optional()->safeColorName(),
|
||||||
|
'segment_id' => Segment::factory(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
37
database/factories/FieldJobSettingFactory.php
Normal file
37
database/factories/FieldJobSettingFactory.php
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -78,21 +78,20 @@ function buildRelated(a) {
|
||||||
// Only client case link (other routes not defined yet)
|
// Only client case link (other routes not defined yet)
|
||||||
if (a.client_case_uuid || a.client_case_id) {
|
if (a.client_case_uuid || a.client_case_id) {
|
||||||
const caseParam = 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 {
|
try {
|
||||||
|
// Prefer Ziggy when available and force stringification here
|
||||||
|
const href = String(route("clientCase.show", { client_case: caseParam }));
|
||||||
links.push({
|
links.push({
|
||||||
type: "client_case",
|
type: "client_case",
|
||||||
label: "Primer",
|
label: "Primer",
|
||||||
href: route("clientCase.show", caseParam),
|
href,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
/* silently ignore */
|
// Safe fallback to a best-effort URL to avoid breaking render
|
||||||
}
|
|
||||||
} else {
|
|
||||||
links.push({
|
links.push({
|
||||||
type: "client_case",
|
type: "client_case",
|
||||||
label: "Primer",
|
label: "Primer",
|
||||||
href: route("clientCase.show", caseParam),
|
href: `/client-cases/${caseParam}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -132,6 +131,24 @@ function formatJobTime(ts) {
|
||||||
return "";
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -425,10 +442,14 @@ function formatJobTime(ts) {
|
||||||
>
|
>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<Link
|
<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"
|
class="text-indigo-600 dark:text-indigo-400 hover:underline font-medium"
|
||||||
>{{ c.client_ref || c.uuid.slice(0, 8) }}</Link
|
>{{ 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">
|
<p class="text-[11px] text-gray-400 dark:text-gray-500">
|
||||||
Brez aktivnosti:
|
Brez aktivnosti:
|
||||||
{{ formatStaleDaysLabel(c.days_without_activity ?? c.days_stale) }}
|
{{ formatStaleDaysLabel(c.days_without_activity ?? c.days_stale) }}
|
||||||
|
|
@ -479,16 +500,17 @@ function formatJobTime(ts) {
|
||||||
<template v-if="f.contract">
|
<template v-if="f.contract">
|
||||||
·
|
·
|
||||||
<Link
|
<Link
|
||||||
|
v-if="f.contract.client_case_uuid"
|
||||||
:href="
|
:href="
|
||||||
route('clientCase.show', {
|
safeCaseHref(f.contract.client_case_uuid, f.contract.segment_id)
|
||||||
client_case: f.contract.client_case_uuid,
|
|
||||||
segment: f.contract.segment_id,
|
|
||||||
})
|
|
||||||
"
|
"
|
||||||
class="text-indigo-600 dark:text-indigo-400 hover:underline"
|
class="text-indigo-600 dark:text-indigo-400 hover:underline"
|
||||||
>
|
>
|
||||||
{{ f.contract.reference || f.contract.uuid?.slice(0, 8) }}
|
{{ f.contract.reference || f.contract.uuid?.slice(0, 8) }}
|
||||||
</Link>
|
</Link>
|
||||||
|
<span v-else class="text-gray-700 dark:text-gray-300">{{
|
||||||
|
f.contract.reference || f.contract.uuid?.slice(0, 8)
|
||||||
|
}}</span>
|
||||||
<span
|
<span
|
||||||
v-if="f.contract.person_full_name"
|
v-if="f.contract.person_full_name"
|
||||||
class="text-gray-500 dark:text-gray-400"
|
class="text-gray-500 dark:text-gray-400"
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,12 @@ const form = useForm({
|
||||||
end_date: null,
|
end_date: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Bulk selection form
|
||||||
|
const bulkForm = useForm({
|
||||||
|
contract_uuids: [],
|
||||||
|
assigned_user_id: null,
|
||||||
|
});
|
||||||
|
|
||||||
// Global search (applies to both tables)
|
// Global search (applies to both tables)
|
||||||
const search = ref("");
|
const search = ref("");
|
||||||
|
|
||||||
|
|
@ -68,6 +74,16 @@ function assign(contract) {
|
||||||
form.post(route("fieldjobs.assign"));
|
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) {
|
function cancelAssignment(contract) {
|
||||||
const payload = { contract_uuid: contract.uuid };
|
const payload = { contract_uuid: contract.uuid };
|
||||||
form.transform(() => payload).post(route("fieldjobs.cancel"));
|
form.transform(() => payload).post(route("fieldjobs.cancel"));
|
||||||
|
|
@ -154,6 +170,7 @@ watch([search, assignedFilterUserId], () => {
|
||||||
|
|
||||||
// Column definitions for DataTableClient
|
// Column definitions for DataTableClient
|
||||||
const unassignedColumns = [
|
const unassignedColumns = [
|
||||||
|
{ key: "_select", label: "", class: "w-8" },
|
||||||
{ key: "reference", label: "Pogodba", sortable: true, class: "w-32" },
|
{ key: "reference", label: "Pogodba", sortable: true, class: "w-32" },
|
||||||
{
|
{
|
||||||
key: "case_person",
|
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">
|
<div v-if="form.errors.assigned_user_id" class="text-red-600 text-sm mt-1">
|
||||||
{{ form.errors.assigned_user_id }}
|
{{ form.errors.assigned_user_id }}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<DataTableClient
|
<DataTableClient
|
||||||
:columns="unassignedColumns"
|
:columns="unassignedColumns"
|
||||||
|
|
@ -317,6 +350,14 @@ const assignedRows = computed(() =>
|
||||||
v-model:page="unassignedPage"
|
v-model:page="unassignedPage"
|
||||||
v-model:pageSize="unassignedPageSize"
|
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 }">
|
<template #cell-case_person="{ row }">
|
||||||
<Link
|
<Link
|
||||||
v-if="row.client_case?.uuid"
|
v-if="row.client_case?.uuid"
|
||||||
|
|
|
||||||
|
|
@ -1321,7 +1321,7 @@ async function fetchSimulation() {
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0">
|
||||||
<a
|
<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"
|
class="text-blue-600 hover:underline text-xs"
|
||||||
>Odpri primer</a
|
>Odpri primer</a
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -291,6 +291,7 @@
|
||||||
// field jobs assignment
|
// field jobs assignment
|
||||||
Route::get('field-jobs', [FieldJobController::class, 'index'])->name('fieldjobs.index');
|
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', [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('field-jobs/cancel', [FieldJobController::class, 'cancel'])->name('fieldjobs.cancel');
|
||||||
Route::post('settings/field-job', [FieldJobSettingController::class, 'store'])->name('settings.fieldjob.store');
|
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');
|
Route::put('settings/field-job/{setting}', [FieldJobSettingController::class, 'update'])->name('settings.fieldjob.update');
|
||||||
|
|
|
||||||
55
tests/Feature/FieldJobBulkAssignTest.php
Normal file
55
tests/Feature/FieldJobBulkAssignTest.php
Normal 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();
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user