mivita/app/Http/Controllers/Admin/CustomerPriorityController.php
Kevin Adametz 9ad69a3218 Kundenhoheit: E-Mail-Aenderung mit Bestaetigung, Zuordnungsfehler behoben, Admin-Uebersicht
- E-Mail-Aenderung durch Berater wird erst nach Bestaetigung durch den Kunden
  aktiv (Link an die neue Adresse, Portal kunde/email-bestaetigen); danach
  ziehen alle Eintraege des Kunden und seine Abos mit. Admins aendern direkt.
- Kundenhoheit: E-Mail eines Beraterkontos oder eines Kunden eines anderen
  Beraters wird abgelehnt (beim Speichern und bei der Bestaetigung).
- Fix CustomerPriority::entryExists: aelteste gepruefte Zuordnung gilt,
  Eintraege in Pruefung (is_like) dienen nicht mehr als Vorlage.
- "Berater zuweisen" voreingestellt auf alle Bestellungen des Kunden.
- Neue Admin-Seite Kundenhoheit: offene Pruefungen, Kunden bei mehreren
  Beratern, abgelehnte/offene E-Mail-Aenderungen; Zaehler im Menue.
- Neue Tabelle shopping_user_email_changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 08:48:17 +00:00

181 lines
10 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\ShoppingUser;
use App\Models\ShoppingUserEmailChange;
use App\Services\CustomerEmailChangeService;
use App\Services\CustomerPriority;
use App\Services\CustomerPriorityOverviewService;
use App\User;
use Illuminate\Contracts\View\View;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
/**
* Admin overview "Kundenhoheit": everything where the customer sovereignty still needs a
* decision, so the work does not depend on info mails that get lost.
*/
class CustomerPriorityController extends Controller
{
public const TABS = ['reviews', 'conflicts', 'email-changes'];
public function __construct(private CustomerPriorityOverviewService $overview)
{
$this->middleware('admin');
}
public function index(string $tab = 'reviews'): View
{
abort_unless(in_array($tab, self::TABS, true), 404);
return view('admin.customer_priority.index', [
'tab' => $tab,
'counts' => $this->overview->counts(),
]);
}
public function reviewsDatatable(): JsonResponse
{
$back = route('admin_customer_priority', ['reviews']);
return \DataTables::eloquent($this->overview->reviewsQuery())
->addColumn('created', fn (ShoppingUser $customer): string => $customer->created_at->format('d.m.Y H:i')
.'<br><small class="text-muted">'.$customer->created_at->diffForHumans().'</small>')
->addColumn('customer', fn (ShoppingUser $customer): string => $this->customerCell($customer))
->addColumn('member', fn (ShoppingUser $customer): string => $this->memberCell($customer->member))
->addColumn('shop', fn (ShoppingUser $customer): string => $this->memberCell($customer->shopping_order?->user_shop?->user))
->addColumn('matches', fn (ShoppingUser $customer): string => (string) count($customer->getNotice('like') ?: []))
->addColumn('action', function (ShoppingUser $customer) use ($back): string {
return '<button type="button" class="btn btn-sm btn-primary mb-1" data-toggle="modal" data-target="#modals-load-content"'
.' data-id="'.$customer->id.'" data-action="shopping-user-is-like-member" data-back="'.e($back).'"'
.' data-modal="modal-xl" data-route="'.route('modal_load').'"><i class="fa fa-edit"></i> Prüfen &amp; zuordnen</button> '
.'<form method="POST" action="'.route('admin_customer_priority_keep', [$customer->id]).'" class="d-inline"'
.' onsubmit="return confirm(\'Prüffall schließen? Berater und Kundennummer bleiben unverändert.\')">'
.csrf_field()
.'<button type="submit" class="btn btn-sm btn-outline-secondary mb-1">Zuordnung beibehalten</button></form>';
})
->filterColumn('customer', function ($query, $keyword): void {
$query->where(function ($query) use ($keyword) {
$query->where('billing_email', 'like', '%'.$keyword.'%')
->orWhere('billing_lastname', 'like', '%'.$keyword.'%')
->orWhere('billing_firstname', 'like', '%'.$keyword.'%')
->orWhere('billing_zipcode', 'like', '%'.$keyword.'%');
});
})
->orderColumn('created', 'shopping_users.created_at $1')
->rawColumns(['created', 'customer', 'member', 'shop', 'action'])
->make(true);
}
public function conflictsDatatable(): JsonResponse
{
$back = route('admin_customer_priority', ['conflicts']);
return \DataTables::of($this->overview->conflicts())
->addColumn('customer', fn (array $row): string => '<strong>'.e($row['name']).'</strong><br>'.e($row['email']))
->addColumn('groups', function (array $row) use ($back): string {
$html = '';
foreach ($row['groups'] as $group) {
$html .= '<div class="mb-2">'
.($group['oldest'] ? '<span class="badge badge-success">älteste Zuordnung</span> ' : '<span class="badge badge-warning">weitere Zuordnung</span> ')
.$this->memberCell($group['member'])
.' <span class="text-muted">· Kunden-Nr. '.e($group['number']).' · '.$group['entries'].' Einträge · zuletzt '.e(date('d.m.Y', strtotime($group['last_at']))).'</span> '
.'<a href="'.route('admin_customer_detail', [$group['latest_id']]).'" class="btn btn-xs btn-outline-primary"><i class="fa fa-eye"></i></a> '
.'<button type="button" class="btn btn-xs btn-outline-secondary" data-toggle="modal" data-target="#modals-load-content"'
.' data-id="'.$group['latest_id'].'" data-action="shopping-user-change-member" data-view="customer" data-back="'.e($back).'"'
.' data-route="'.route('modal_load').'"><i class="fa fa-edit"></i> Berater zuweisen</button>'
.'</div>';
}
return $html;
})
->addColumn('last', fn (array $row): string => date('d.m.Y', strtotime($row['last_at'])))
->filter(function ($instance): void {
$keyword = mb_strtolower((string) request('search.value'));
if ($keyword !== '') {
$instance->collection = $instance->collection->filter(
fn (array $row) => str_contains($row['email'], $keyword) || str_contains(mb_strtolower($row['name']), $keyword)
);
}
})
->rawColumns(['customer', 'groups'])
->make(true);
}
public function emailChangesDatatable(): JsonResponse
{
return \DataTables::eloquent($this->overview->emailChangesQuery())
->addColumn('created', fn (ShoppingUserEmailChange $change): string => $change->created_at->format('d.m.Y H:i'))
->addColumn('customer', fn (ShoppingUserEmailChange $change): string => $change->shopping_user
? $this->customerCell($change->shopping_user)
: '<span class="text-muted">Kunde #'.$change->shopping_user_id.' gelöscht</span>')
->addColumn('change', fn (ShoppingUserEmailChange $change): string => e($change->old_email).' <i class="fa fa-arrow-right"></i> <strong>'.e($change->new_email).'</strong>')
->addColumn('requested_by', fn (ShoppingUserEmailChange $change): string => $this->memberCell($change->requested_by))
->addColumn('status', function (ShoppingUserEmailChange $change): string {
if ($change->blocked_at) {
return '<span class="badge badge-danger">abgelehnt (Kundenhoheit)</span><br><small class="text-muted">Adresse gehört einem anderen Berater oder Beraterkonto</small>';
}
return '<span class="badge badge-info">wartet auf Bestätigung</span><br><small class="text-muted">Link gültig bis '.$change->expires_at->format('d.m.Y H:i').'</small>';
})
->addColumn('action', function (ShoppingUserEmailChange $change): string {
if ($change->blocked_at) {
return '<form method="POST" action="'.route('admin_customer_priority_email_resolve', [$change->id]).'" class="d-inline">'
.csrf_field().'<button type="submit" class="btn btn-sm btn-outline-success"><i class="fa fa-check"></i> Erledigt</button></form>';
}
return '<form method="POST" action="'.route('admin_customer_priority_email_discard', [$change->id]).'" class="d-inline"'
.' onsubmit="return confirm(\'Anfrage verwerfen? Der Link in der Bestätigungsmail funktioniert dann nicht mehr.\')">'
.csrf_field().'<button type="submit" class="btn btn-sm btn-outline-danger">Verwerfen</button></form>';
})
->orderColumn('created', 'shopping_user_email_changes.created_at $1')
->rawColumns(['customer', 'change', 'requested_by', 'status', 'action'])
->make(true);
}
public function keepAssignment(int $id): RedirectResponse
{
$customer = ShoppingUser::where('is_like', true)->findOrFail($id);
CustomerPriority::keepAssignment($customer);
\Session()->flash('alert-success', 'Prüffall geschlossen, Zuordnung beibehalten.');
return redirect(route('admin_customer_priority', ['reviews']));
}
public function resolveEmailChange(int $id, CustomerEmailChangeService $service): RedirectResponse
{
$service->resolveBlocked(ShoppingUserEmailChange::blockedOpen()->findOrFail($id), \Auth::id());
\Session()->flash('alert-success', 'Als erledigt markiert.');
return redirect(route('admin_customer_priority', ['email-changes']));
}
public function discardEmailChange(int $id, CustomerEmailChangeService $service): RedirectResponse
{
$service->discard(ShoppingUserEmailChange::pending()->findOrFail($id));
\Session()->flash('alert-success', 'Anfrage verworfen.');
return redirect(route('admin_customer_priority', ['email-changes']));
}
private function customerCell(ShoppingUser $customer): string
{
return '<a href="'.route('admin_customer_detail', [$customer->id]).'"><strong>'
.e(trim($customer->billing_firstname.' '.$customer->billing_lastname)).'</strong></a>'
.($customer->number ? ' <span class="text-muted">#'.e($customer->number).'</span>' : '')
.'<br>'.e($customer->billing_email)
.'<br><small class="text-muted">'.e($customer->billing_zipcode.' '.$customer->billing_city).'</small>';
}
private function memberCell(?User $member): string
{
if (! $member) {
return '<span class="text-muted">–</span>';
}
return '<a href="'.route('admin_lead_edit', [$member->id]).'">'.e($member->getFullName(false) ?: $member->email).'</a>'
.' <span class="text-muted">#'.e($member->number).'</span>';
}
}