diff --git a/src/Http/Controllers/TicketingSettingsController.php b/src/Http/Controllers/TicketingSettingsController.php
index 6369b70..d85418e 100644
--- a/src/Http/Controllers/TicketingSettingsController.php
+++ b/src/Http/Controllers/TicketingSettingsController.php
@@ -4,6 +4,8 @@ namespace Dashboard\Ticketing\Http\Controllers;
use App\Models\Setting;
use App\Models\User;
+use App\Support\PersonChip;
+use App\Support\PersonNames;
use Dashboard\Ticketing\Models\EmailConnection;
use Dashboard\Ticketing\Models\PriorityLevel;
use Dashboard\Ticketing\Models\TicketingAgentAccess;
@@ -464,6 +466,67 @@ class TicketingSettingsController extends Controller
return $request->expectsJson();
}
+ /**
+ * WHO AM I APPOINTING — the Agents tab's people search ([central-ticketing]
+ * #4926).
+ *
+ * Until this existed the first field of the add-an-agent form was
+ * `` labelled "User ID", with "Find IDs in Admin →
+ * Users" under it. That is a primary key asked of an office administrator,
+ * and on the ONE form the product calls "the only recovery" for a group
+ * with no manager left (#4806): the workflow was to leave the page, search
+ * a name somewhere else, read a number off a URL and type it back in.
+ *
+ * The cost is not only the detour. `storeAgent()` validates
+ * `exists:users,id`, so a NON-existent id is refused cleanly — but 47 typed
+ * for 74 is two real accounts, and the one that gets written is handed
+ * `manager` over a whole help desk queue: every ticket in it, plus the
+ * power to rename, archive and staff it. Nothing on that screen named the
+ * person before the press or after it, so #4781's audit row was the first
+ * place anybody could have found out, after the fact.
+ *
+ * So this is the same search every other Central picker is: `q` over name
+ * and email, answered by `PersonChip::rows()` — the one serializer for a
+ * searchable person (#871) — so the operator's People setting reaches this
+ * picker like the rest, and the page draws `` rather than a
+ * hand-written name row.
+ *
+ * ── THE GATE IS THE PAGE'S, NOT THE WRITE'S ─────────────────────────────
+ *
+ * requireAgentAccess(), the same one index() uses, and deliberately not a
+ * per-group one: the form picks the PERSON before the GROUP, so there is no
+ * group to scope to yet, and narrowing it afterwards would mean a search
+ * that empties when the picker's second field changes. Nothing is widened
+ * by that — the write itself still goes through
+ * requireManagerAccessOrOrphanRecovery(), so a person found here can only
+ * be appointed to a group the caller manages or is rescuing. Reaching this
+ * route at all already means `permission:ticketing.settings`, which is a
+ * narrower population than TicketController::searchUsers() (any agent on
+ * any one ticket's group) already answers the same question for.
+ */
+ public function searchUsers(Request $request)
+ {
+ $this->requireAgentAccess();
+
+ $q = trim((string) $request->input('q', ''));
+
+ // Two characters, like every other picker on the platform: one letter
+ // is most of the directory, and the dropdown is ten rows.
+ if (strlen($q) < 2) {
+ return response()->json([]);
+ }
+
+ return response()->json(PersonChip::rows(
+ User::where(function ($query) use ($q) {
+ $query->where('name', 'like', "%{$q}%")
+ ->orWhere('email', 'like', "%{$q}%");
+ })
+ ->tap(fn ($query) => PersonNames::orderUsers($query))
+ ->limit(10)
+ ->get(PersonChip::columns())
+ ));
+ }
+
public function storeAgent(Request $request)
{
$this->requireAgentAccess();
@@ -488,17 +551,46 @@ class TicketingSettingsController extends Controller
['role' => $validated['role']]
);
+ /*
+ * NAME THE PERSON — [central-ticketing] #4926.
+ *
+ * The flash already went to the trouble of naming the GROUP and saying
+ * it had nobody; naming the appointee is the other half of the same
+ * sentence, and it is the half somebody can check. This write hands a
+ * person read access to every ticket in a queue — and, as a manager,
+ * the power to rename, archive and staff it — so "Agent added." left
+ * the only record of WHO in #4781's audit log, which is somewhere you
+ * go after you already suspect something.
+ *
+ * `PersonNames::display()` and not `$user->name`: the confirmation has
+ * to read the way the rest of the platform writes a name (#524), and
+ * the chip the admin picked from in the form is composed the same way,
+ * so the two agree. A user who vanished between the `exists` rule and
+ * this line leaves `null` and the sentence falls back to exactly what
+ * it printed before — a flash is not where a race should surface.
+ */
+ $person = User::find($validated['user_id']);
+ $who = $person === null ? null : PersonNames::display($person);
+ $asWhat = $validated['role'] === 'manager' ? 'a manager' : 'an agent';
+ $name = TicketingGroup::find($validated['group_id'])?->name;
+
// Say which group was rescued and that it had nobody: a recovery is a
// rare, consequential click and "Agent added." does not describe it.
if ($isRecovery) {
- $name = TicketingGroup::find($validated['group_id'])?->name;
+ $added = $who === null ? 'Agent added' : "{$who} added as {$asWhat}";
return back()->with('success', $name === null
- ? 'Agent added — that group had no manager who could still act.'
- : "Agent added to “{$name}”, which had no manager who could still act.");
+ ? "{$added} — that group had no manager who could still act."
+ : "{$added} to “{$name}”, which had no manager who could still act.");
}
- return back()->with('success', 'Agent added.');
+ if ($who === null) {
+ return back()->with('success', 'Agent added.');
+ }
+
+ return back()->with('success', $name === null
+ ? "{$who} added as {$asWhat}."
+ : "{$who} added to “{$name}” as {$asWhat}.");
}
public function destroyAgent(TicketingAgentAccess $access)
diff --git a/src/routes/ticketing.php b/src/routes/ticketing.php
index dad7af1..f19d798 100644
--- a/src/routes/ticketing.php
+++ b/src/routes/ticketing.php
@@ -47,6 +47,12 @@ Route::middleware(['web', 'auth', 'app.access:ticketing'])
Route::put('/settings/groups/{group}', [TicketingSettingsController::class, 'updateGroup']) ->name('settings.groups.update');
Route::delete('/settings/groups/{group}', [TicketingSettingsController::class, 'destroyGroup']) ->name('settings.groups.destroy');
Route::post('/settings/groups/{group}/restore', [TicketingSettingsController::class, 'restoreGroup']) ->name('settings.groups.restore');
+ // #4926 — the add-an-agent form's people search. Before it the form's
+ // first field was a raw numeric user id with "Find IDs in Admin → Users"
+ // under it, on the one screen that is the only recovery for a help desk
+ // group with no manager left (#4806). Two segments, so it cannot collide
+ // with the single-segment `/settings/{tab}` above.
+ Route::get('/settings/agents/search', [TicketingSettingsController::class, 'searchUsers']) ->name('settings.agents.search');
Route::post('/settings/agents', [TicketingSettingsController::class, 'storeAgent']) ->name('settings.agents.store');
Route::delete('/settings/agents/{access}', [TicketingSettingsController::class, 'destroyAgent']) ->name('settings.agents.destroy');
Route::post('/settings/priorities', [TicketingSettingsController::class, 'storePriority']) ->name('settings.priorities.store');