Kunden loeschen: DSGVO-geloeschte Kunden ausblenden und sperren; Checkout-Herkunft freiwillig

Kunden loeschen (Rueckmeldung "es wird nichts geloescht"):
- DSGVO-geloeschte Eintraege blieben mit Name in den Listen stehen; Kunde
  61009 wurde deshalb achtmal geloescht, bei Eintrag 45013 wurde die E-Mail
  danach ueber ein offenes Bearbeiten-Formular zurueckgeschrieben
- neue Spalte shopping_users.gdpr_deleted_at (Bestand per Migration markiert)
- Admin-Kundenliste blendet geloeschte Kunden aus (Filter: ausblenden /
  mit anzeigen / nur geloeschte), Berater-Kundenliste immer
- Bearbeiten/Speichern im Admin und beim Berater gesperrt; ShoppingUser
  verweigert das Zurueckschreiben von E-Mail und Telefon
- Modal und Erfolgsmeldung (de/en/es/fr) sagen klar, dass Name und
  Rechnungsadresse wegen der Aufbewahrungspflicht bleiben
- gdpr:repair-customers [--execute] zieht protokollierte Loeschungen nach
- DSGVO-Loeschung prueft Superadmin serverseitig; Berater duerfen eine
  Kunden-E-Mail nur beim eigenen Kunden nachtragen
- setFakerMail setzt faker_mail bei geloeschten Eintraegen nicht zurueck

Checkout: "Wie bist du auf uns aufmerksam geworden?" ist kein Pflichtfeld
mehr und wird farbig hervorgehoben.

Deploy: php artisan migrate && php artisan gdpr:repair-customers --execute

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin 2026-10-06 12:36:02 +00:00
parent 083d643220
commit 3d0864713b
19 changed files with 635 additions and 90 deletions

View file

@ -1,6 +1,13 @@
<div class="card">
<!-- Info -->
<div class="card-body pb-1">
@if($shopping_user->isGdprDeleted())
<div class="alert alert-secondary">
<b>DSGVO-gelöscht am {{ $shopping_user->gdpr_deleted_at->format('d.m.Y H:i') }}.</b>
Kontaktdaten wurden entfernt. Name und Rechnungsadresse bleiben wegen der gesetzlichen
Aufbewahrungspflicht an den Bestellungen erhalten. Der Eintrag kann nicht mehr bearbeitet werden.
</div>
@endif
@if($isAdmin)
<div class="row">
<div class="col-md-3 mb-3">
@ -20,7 +27,9 @@
{{$shopping_user->created_at->format("d.m.Y H:i")}}
</div>
<div class="col-md-3 mb-3">
@if(!$shopping_user->isGdprDeleted())
<a class="btn btn-sm btn-secondary mt-2" href="{{route('admin_customer_edit', [$shopping_user->id])}}">{{ __('customer.edit_customer_data') }}</a>
@endif
<button type="button" class="btn btn-sm btn-danger mt-2" data-toggle="modal" data-target="#modals-customer-delete">{{ __('customer.delete_customer') }}</button>
</div>
</div>
@ -44,7 +53,7 @@
</div>
<div class="col-md-3 mb-3">
@if(!$shopping_user->is_like)
@if(!$shopping_user->is_like && !$shopping_user->isGdprDeleted())
<a class="btn btn-sm btn-secondary mt-2" href="{{route('user_customer_edit', [$shopping_user->id])}}">{{ __('customer.edit_customer_data') }}</a>
@endif
</div>

View file

@ -49,7 +49,8 @@
<td>{{ $row->id }}</td>
<td>{{ $row->number ?: '-' }}</td>
<td>{{ trim($row->billing_firstname.' '.$row->billing_lastname) }}
@if ($row->deleted_at) <span class="badge badge-secondary">gelöscht</span> @endif
@if ($row->deleted_at) <span class="badge badge-secondary">gelöscht</span>
@elseif ($row->isGdprDeleted()) <span class="badge badge-secondary">DSGVO-gelöscht</span> @endif
</td>
<td>{{ $row->member->email ?? '-' }}</td>
<td>{{ $deletion['orders_by_row'][$row->id] ?? 0 }}</td>
@ -60,12 +61,24 @@
</table>
</div>
@if ($deletion['orders_count'] > 0)
@if ($deletion['all_gdpr_deleted'])
<div class="alert alert-success mb-0">
Der Kunde wurde bereits am <b>{{ $deletion['gdpr_deleted_at']->format('d.m.Y H:i') }}</b> DSGVO-gelöscht.
Alle Kontaktdaten (E-Mail, Telefon, Notizen, Newsletter) und Portal-Logins sind entfernt. Name und
Rechnungsadresse bleiben wegen der gesetzlichen Aufbewahrungspflicht an den Bestellungen erhalten.
Die Einträge sind in der Kundenliste ausgeblendet (Filter „DSGVO-gelöschte Kunden“) und gegen
Bearbeiten gesperrt.
</div>
@elseif ($deletion['orders_count'] > 0)
<div class="alert alert-warning">
Der Kunde hat schon <b>{{ $deletion['orders_count'] }} Bestellung(en)</b>. Es ist nur <b>DSGVO löschen</b> möglich:
Alle personenbezogenen Daten werden unwiderruflich anonymisiert. Bei Einträgen mit Bestellung bleiben Name und
Rechnungsadresse wegen der gesetzlichen Aufbewahrungspflichten erhalten; alle Kontaktdaten (E-Mail, Telefon,
Notizen, Newsletter) sowie die Portal-Logins werden entfernt.
Alle Kontaktdaten (E-Mail, Telefon, Notizen, Newsletter) sowie die Portal-Logins werden unwiderruflich entfernt.
<b>Name und Rechnungsadresse bleiben</b> bei Einträgen mit Bestellung wegen der gesetzlichen
Aufbewahrungspflicht erhalten. Die Einträge werden danach in der Kundenliste ausgeblendet und gegen
Bearbeiten gesperrt.
@if ($deletion['gdpr_deleted_rows'] > 0)
<br>{{ $deletion['gdpr_deleted_rows'] }} von {{ $deletion['rows']->count() }} Einträgen sind bereits gelöscht.
@endif
@if ($deletion['active_abos'] > 0)
<br><b>{{ __('msg.gdpr_error_active_abo') }}</b>
@endif

View file

@ -10,7 +10,7 @@
<div class="card">
<div class="card-header">
<div class="form-row align-items-center">
<div class="col-sm-10 mb-2">
<div class="col-sm-7 mb-2">
<label class="form-label" for="filter_user_shop_id">Filter zugewiesener Berater</label>
<select class="selectpicker" data-style="btn-default" name="filter_member_id" id="filter_member_id" data-live-search="true">
<option value="">Filter aus</option>
@ -19,6 +19,14 @@
@endforeach
</select>
</div>
<div class="col-sm-3 mb-2">
<label class="form-label" for="filter_gdpr_deleted">DSGVO-gelöschte Kunden</label>
<select class="custom-select" name="filter_gdpr_deleted" id="filter_gdpr_deleted">
<option value="">ausblenden</option>
<option value="all" @if(get_user_attr('filter_gdpr_deleted') === 'all') selected @endif>mit anzeigen</option>
<option value="only" @if(get_user_attr('filter_gdpr_deleted') === 'only') selected @endif>nur gelöschte</option>
</select>
</div>
<div class="col-2 col-sm-2 mb-2 mt-4">
<a href="{{ route('admin_customers') }}?reset=filter" data-toggle="tooltip" data-placement="top" title="Reset Filter" class="btn icon-btn btn-sm btn-outline-dark float-right">
<span class="fa fa-sync"></span>
@ -73,6 +81,7 @@
data: function(d) {
d.filter_member_id = $('select[name=filter_member_id]').val();
d.filter_customer_member = $('select[name=filter_customer_member]').val();
d.filter_gdpr_deleted = $('select[name=filter_gdpr_deleted]').val();
}
},
"order": [[0, "desc" ]],
@ -105,6 +114,9 @@
$('#filter_customer_member').on('change', function(){
oTable.draw();
});
$('#filter_gdpr_deleted').on('change', function(){
oTable.draw();
});
});
</script>

View file

@ -111,6 +111,19 @@
font-size: 0.8em;
font-style: italic;
}
.order-source-highlight {
background: #f3f7f0;
border: 1px solid #97b085;
border-left: 4px solid #97b085;
border-radius: 4px;
padding: 12px 15px;
}
.order-source-highlight label {
font-weight: 600;
color: #5f7a4e;
}
select.form-control:not([size]):not([multiple]) {
height: 38px;
color: #6f6f6f;
@ -554,9 +567,10 @@
</div>
<div class="row mt-3">
<div class="col-md-12 col-sm-12">
<div class="form-group {{($errors->has('customer_order_source_comment') ? 'error' : '')}}">
<label class="mt-0 fs-14 fw-400" for="customer_order_source_comment">Wie bist du auf uns aufmerksam geworden? *</label>
{!! Form::textarea('customer_order_source_comment', old('customer_order_source_comment'), ['class' => 'form-control '.($errors->has('customer_order_source_comment') ? 'error' : ''), 'id'=>'customer_order_source_comment', 'rows' => 3, 'maxlength' => 500, 'required' => 'required', 'placeholder' => 'Bitte konkret: z. B. Name der Person, die dich empfohlen hat, oder die genaue Messe/Veranstaltung']) !!}
<div class="form-group order-source-highlight {{($errors->has('customer_order_source_comment') ? 'error' : '')}}">
<label class="mt-0 fs-14" for="customer_order_source_comment"><i class="fa fa-heart"></i> Wie bist du auf uns aufmerksam geworden?</label>
<span class="info-small mb-2" style="display: block;">Freiwillig – hilft uns aber sehr, dich der richtigen Person zuzuordnen.</span>
{!! Form::textarea('customer_order_source_comment', old('customer_order_source_comment'), ['class' => 'form-control '.($errors->has('customer_order_source_comment') ? 'error' : ''), 'id'=>'customer_order_source_comment', 'rows' => 3, 'maxlength' => 500, 'placeholder' => 'z. B. Name der Person, die dich empfohlen hat, oder die genaue Messe/Veranstaltung']) !!}
@if ($errors->has('customer_order_source_comment'))
<label for="customer_order_source_comment" class="error text-danger small" style="display: block;">{{ $errors->first('customer_order_source_comment') }}</label>
@endif