Verbessere Mitgliederfilter und Zahlungszuordnung
This commit is contained in:
+189
@@ -56,6 +56,99 @@ function imports_find_participant(PDO $pdo, int $tenantId, string $name): ?array
|
||||
];
|
||||
}
|
||||
|
||||
function imports_normalize_match_text(string $value): string
|
||||
{
|
||||
$value = trim($value);
|
||||
$value = preg_replace('/\s+/', ' ', $value) ?? $value;
|
||||
|
||||
return function_exists('mb_strtolower')
|
||||
? mb_strtolower($value, 'UTF-8')
|
||||
: strtolower($value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert wahrscheinliche Teilnehmer fuer eine manuelle Zuordnung. Die
|
||||
* automatische Buchung bleibt strenger; diese Vorschlaege sind nur eine Hilfe
|
||||
* fuer Admins/Kassenwarte in CSV- und PayPal-Warteschlangen.
|
||||
*
|
||||
* @return list<array{participant_id:int, display_name:string, email:?string, paypal_name:?string, score:int}>
|
||||
*/
|
||||
function imports_suggest_participants(PDO $pdo, int $tenantId, string $rawName, ?string $note = null, int $limit = 5): array
|
||||
{
|
||||
$haystack = imports_normalize_match_text(trim($rawName . ' ' . (string)$note));
|
||||
if ($haystack === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$tokens = array_values(array_unique(array_filter(
|
||||
preg_split('/\s+/', $haystack) ?: [],
|
||||
static fn(string $token): bool => strlen($token) >= 3
|
||||
)));
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT id, display_name, email, paypal_name
|
||||
FROM participants
|
||||
WHERE tenant_id = ? AND active = 1
|
||||
ORDER BY display_name'
|
||||
);
|
||||
$stmt->execute([$tenantId]);
|
||||
|
||||
$candidates = [];
|
||||
foreach ($stmt->fetchAll() as $row) {
|
||||
$displayName = (string)$row['display_name'];
|
||||
$paypalName = (string)($row['paypal_name'] ?? '');
|
||||
$email = (string)($row['email'] ?? '');
|
||||
$displayNorm = imports_normalize_match_text($displayName);
|
||||
$paypalNorm = imports_normalize_match_text($paypalName);
|
||||
$emailNorm = imports_normalize_match_text($email);
|
||||
$score = 0;
|
||||
|
||||
if ($displayNorm !== '' && $displayNorm === imports_normalize_match_text($rawName)) {
|
||||
$score += 90;
|
||||
}
|
||||
if ($paypalNorm !== '' && $paypalNorm === imports_normalize_match_text($rawName)) {
|
||||
$score += 100;
|
||||
}
|
||||
if ($displayNorm !== '' && str_contains($haystack, $displayNorm)) {
|
||||
$score += 45;
|
||||
}
|
||||
if ($paypalNorm !== '' && str_contains($haystack, $paypalNorm)) {
|
||||
$score += 55;
|
||||
}
|
||||
|
||||
foreach ($tokens as $token) {
|
||||
if ($displayNorm !== '' && str_contains($displayNorm, $token)) {
|
||||
$score += 12;
|
||||
}
|
||||
if ($paypalNorm !== '' && str_contains($paypalNorm, $token)) {
|
||||
$score += 16;
|
||||
}
|
||||
if ($emailNorm !== '' && str_contains($emailNorm, $token)) {
|
||||
$score += 6;
|
||||
}
|
||||
}
|
||||
|
||||
if ($score <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$candidates[] = [
|
||||
'participant_id' => (int)$row['id'],
|
||||
'display_name' => $displayName,
|
||||
'email' => $row['email'] !== null ? (string)$row['email'] : null,
|
||||
'paypal_name' => $row['paypal_name'] !== null ? (string)$row['paypal_name'] : null,
|
||||
'score' => $score,
|
||||
];
|
||||
}
|
||||
|
||||
usort($candidates, static function (array $a, array $b): int {
|
||||
return $b['score'] <=> $a['score']
|
||||
?: strcasecmp((string)$a['display_name'], (string)$b['display_name']);
|
||||
});
|
||||
|
||||
return array_slice($candidates, 0, max(1, $limit));
|
||||
}
|
||||
|
||||
/**
|
||||
* A payment counts as a duplicate if the same participant already has a
|
||||
* non-voided ledger payment of the same amount on the same day.
|
||||
@@ -188,6 +281,102 @@ function imports_fetch_batch(PDO $pdo, int $tenantId, int $batchId): ?array
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{id:int, original_filename:string, status:string, created_at:string, committed_at:?string, total_rows:int, matched_rows:int, unresolved_rows:int, imported_rows:int, ignored_rows:int}>
|
||||
*/
|
||||
function imports_fetch_recent_batches(PDO $pdo, int $tenantId, int $limit = 10): array
|
||||
{
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT b.id, b.original_filename, b.status, b.created_at, b.committed_at,
|
||||
COUNT(r.id) AS total_rows,
|
||||
SUM(CASE WHEN r.status = 'matched' THEN 1 ELSE 0 END) AS matched_rows,
|
||||
SUM(CASE WHEN r.status IN ('unmatched', 'duplicate', 'invalid') THEN 1 ELSE 0 END) AS unresolved_rows,
|
||||
SUM(CASE WHEN r.status = 'imported' THEN 1 ELSE 0 END) AS imported_rows,
|
||||
SUM(CASE WHEN r.status = 'ignored' THEN 1 ELSE 0 END) AS ignored_rows
|
||||
FROM payment_import_batches b
|
||||
LEFT JOIN payment_import_rows r ON r.batch_id = b.id
|
||||
WHERE b.tenant_id = ?
|
||||
GROUP BY b.id, b.original_filename, b.status, b.created_at, b.committed_at
|
||||
ORDER BY b.created_at DESC, b.id DESC
|
||||
LIMIT " . max(1, min(50, $limit))
|
||||
);
|
||||
$stmt->execute([$tenantId]);
|
||||
|
||||
return array_map(static function (array $row): array {
|
||||
return [
|
||||
'id' => (int)$row['id'],
|
||||
'original_filename' => (string)$row['original_filename'],
|
||||
'status' => (string)$row['status'],
|
||||
'created_at' => (string)$row['created_at'],
|
||||
'committed_at' => $row['committed_at'] !== null ? (string)$row['committed_at'] : null,
|
||||
'total_rows' => (int)$row['total_rows'],
|
||||
'matched_rows' => (int)$row['matched_rows'],
|
||||
'unresolved_rows' => (int)$row['unresolved_rows'],
|
||||
'imported_rows' => (int)$row['imported_rows'],
|
||||
'ignored_rows' => (int)$row['ignored_rows'],
|
||||
];
|
||||
}, $stmt->fetchAll());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{ok:bool, error?:string}
|
||||
*/
|
||||
function imports_assign_row(PDO $pdo, int $tenantId, int $batchId, int $rowId, int $participantId): array
|
||||
{
|
||||
$batch = imports_fetch_batch($pdo, $tenantId, $batchId);
|
||||
if ($batch === null || $batch['batch']['status'] !== 'previewed') {
|
||||
return ['ok' => false, 'error' => 'Dieser Import kann nicht mehr bearbeitet werden.'];
|
||||
}
|
||||
|
||||
$summaries = ledger_fetch_participant_summaries($pdo, $tenantId, ['participant_ids' => [$participantId]]);
|
||||
$participant = $summaries[0] ?? null;
|
||||
if ($participant === null || empty($participant['active'])) {
|
||||
return ['ok' => false, 'error' => 'Das gewählte Mitglied wurde nicht gefunden.'];
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
"UPDATE payment_import_rows r
|
||||
JOIN payment_import_batches b ON b.id = r.batch_id
|
||||
SET r.participant_id = ?, r.status = 'matched'
|
||||
WHERE r.id = ?
|
||||
AND r.batch_id = ?
|
||||
AND b.tenant_id = ?
|
||||
AND b.status = 'previewed'
|
||||
AND r.status IN ('unmatched', 'duplicate')"
|
||||
);
|
||||
$stmt->execute([$participantId, $rowId, $batchId, $tenantId]);
|
||||
|
||||
if ($stmt->rowCount() !== 1) {
|
||||
return ['ok' => false, 'error' => 'Diese Zeile kann nicht zugeordnet werden.'];
|
||||
}
|
||||
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{ok:bool, error?:string}
|
||||
*/
|
||||
function imports_ignore_row(PDO $pdo, int $tenantId, int $batchId, int $rowId): array
|
||||
{
|
||||
$stmt = $pdo->prepare(
|
||||
"UPDATE payment_import_rows r
|
||||
JOIN payment_import_batches b ON b.id = r.batch_id
|
||||
SET r.participant_id = NULL, r.status = 'ignored'
|
||||
WHERE r.id = ?
|
||||
AND r.batch_id = ?
|
||||
AND b.tenant_id = ?
|
||||
AND b.status = 'previewed'
|
||||
AND r.status IN ('unmatched', 'duplicate', 'invalid')"
|
||||
);
|
||||
$stmt->execute([$rowId, $batchId, $tenantId]);
|
||||
|
||||
if ($stmt->rowCount() !== 1) {
|
||||
return ['ok' => false, 'error' => 'Diese Zeile kann nicht ignoriert werden.'];
|
||||
}
|
||||
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
/**
|
||||
* Commits every 'matched' row of a previewed batch straight into the ledger.
|
||||
* Already-committed batches are rejected.
|
||||
|
||||
@@ -2293,3 +2293,145 @@ button,
|
||||
border-left: 5px solid #4caf50;
|
||||
color: #1b5e20;
|
||||
}
|
||||
|
||||
.hint-box.error {
|
||||
background: #fdeaea;
|
||||
border-left: 5px solid #c0392b;
|
||||
color: #641e16;
|
||||
}
|
||||
|
||||
input[type="submit"].alt,
|
||||
input[type="reset"].alt,
|
||||
input[type="button"].alt,
|
||||
button.alt,
|
||||
.button.alt {
|
||||
box-shadow: inset 0 0 0 2px #9fa3a6;
|
||||
color: #5f676d !important;
|
||||
}
|
||||
|
||||
.admin-summary-grid {
|
||||
display: grid;
|
||||
gap: 0.75em;
|
||||
grid-template-columns: repeat(auto-fit, minmax(8.5em, 1fr));
|
||||
margin: 1.25em 0 1.75em;
|
||||
}
|
||||
|
||||
.admin-summary-grid > div {
|
||||
border: solid 1px rgba(210, 215, 217, 0.85);
|
||||
border-radius: 6px;
|
||||
padding: 0.9em 1em;
|
||||
}
|
||||
|
||||
.admin-summary-grid strong {
|
||||
color: #38761d;
|
||||
display: block;
|
||||
font-family: "Roboto Slab", serif;
|
||||
font-size: 1.45em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.admin-summary-grid span {
|
||||
color: #7f888f;
|
||||
display: block;
|
||||
font-size: 0.85em;
|
||||
margin-top: 0.2em;
|
||||
}
|
||||
|
||||
.admin-filter-bar {
|
||||
align-items: flex-end;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75em;
|
||||
margin: 1.25em 0;
|
||||
}
|
||||
|
||||
.admin-filter-bar label {
|
||||
font-size: 0.85em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.admin-filter-bar input[type="search"],
|
||||
.admin-filter-bar select {
|
||||
min-width: 11em;
|
||||
}
|
||||
|
||||
.admin-table td,
|
||||
.admin-table th {
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
border-radius: 6px;
|
||||
display: inline-block;
|
||||
font-size: 0.78em;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
margin: 0 0.25em 0.25em 0;
|
||||
padding: 0.2em 0.55em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-badge.success {
|
||||
background: #e8f5e9;
|
||||
color: #1b5e20;
|
||||
}
|
||||
|
||||
.status-badge.warning {
|
||||
background: #fff4e5;
|
||||
color: #663c00;
|
||||
}
|
||||
|
||||
.status-badge.error {
|
||||
background: #fdeaea;
|
||||
color: #641e16;
|
||||
}
|
||||
|
||||
.status-badge.info {
|
||||
background: #e8f4ff;
|
||||
color: #0d3c61;
|
||||
}
|
||||
|
||||
.status-badge.muted {
|
||||
background: #edf0f2;
|
||||
color: #5f676d;
|
||||
}
|
||||
|
||||
.inline-admin-form {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5em;
|
||||
margin: 0 0 0.5em 0;
|
||||
}
|
||||
|
||||
.inline-admin-form select {
|
||||
min-width: 13em;
|
||||
}
|
||||
|
||||
.compact-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5em;
|
||||
margin: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.compact-actions li {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 736px) {
|
||||
.admin-filter-bar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.admin-filter-bar input[type="search"],
|
||||
.admin-filter-bar select,
|
||||
.admin-filter-bar button,
|
||||
.admin-filter-bar .button,
|
||||
.inline-admin-form select,
|
||||
.inline-admin-form button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
+192
-5
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
ob_start();
|
||||
|
||||
include "functions.php";
|
||||
require_once __DIR__ . "/app/ledger.php";
|
||||
require_once __DIR__ . "/app/imports.php";
|
||||
@@ -103,22 +105,88 @@ function csv_status_label(string $status): string
|
||||
'unmatched' => 'Mitglied nicht gefunden',
|
||||
'invalid' => 'Ungültige Zeile',
|
||||
'imported' => 'Importiert',
|
||||
'ignored' => 'Ignoriert',
|
||||
default => $status,
|
||||
};
|
||||
}
|
||||
|
||||
function csv_status_class(string $status): string
|
||||
{
|
||||
return match ($status) {
|
||||
'matched', 'imported' => 'success',
|
||||
'duplicate' => 'warning',
|
||||
'unmatched', 'invalid' => 'error',
|
||||
'ignored' => 'muted',
|
||||
default => 'muted',
|
||||
};
|
||||
}
|
||||
|
||||
function csv_redirect_to_batch(?int $batchId = null): void
|
||||
{
|
||||
$target = 'csvupload.php';
|
||||
if ($batchId !== null && $batchId > 0) {
|
||||
$target .= '?batch_id=' . urlencode((string)$batchId);
|
||||
}
|
||||
header('Location: ' . $target);
|
||||
exit;
|
||||
}
|
||||
|
||||
function csv_select_options(array $members, array $suggestions = [], int $selectedId = 0): string
|
||||
{
|
||||
$suggestedIds = [];
|
||||
foreach ($suggestions as $suggestion) {
|
||||
$suggestedIds[(int)$suggestion['participant_id']] = true;
|
||||
}
|
||||
|
||||
$html = '<option value="">- Mitglied wählen -</option>';
|
||||
if ($suggestions !== []) {
|
||||
$html .= '<optgroup label="Vorschläge">';
|
||||
foreach ($suggestions as $suggestion) {
|
||||
$id = (int)$suggestion['participant_id'];
|
||||
$html .= '<option value="' . $id . '"' . ($selectedId === $id ? ' selected' : '') . '>'
|
||||
. saas_html((string)$suggestion['display_name'])
|
||||
. '</option>';
|
||||
}
|
||||
$html .= '</optgroup>';
|
||||
}
|
||||
|
||||
$html .= '<optgroup label="Alle aktiven Mitglieder">';
|
||||
foreach ($members as $member) {
|
||||
$id = (int)$member['participant_id'];
|
||||
if (isset($suggestedIds[$id])) {
|
||||
continue;
|
||||
}
|
||||
$html .= '<option value="' . $id . '"' . ($selectedId === $id ? ' selected' : '') . '>'
|
||||
. saas_html((string)$member['display_name'])
|
||||
. '</option>';
|
||||
}
|
||||
$html .= '</optgroup>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
$meldung = null;
|
||||
$fehler = null;
|
||||
$vorschauBatchId = null;
|
||||
$vorschauBatchId = isset($_GET['batch_id']) ? (int)$_GET['batch_id'] : null;
|
||||
|
||||
if (isset($_SESSION['flash_csvupload']) && is_array($_SESSION['flash_csvupload'])) {
|
||||
$flash = $_SESSION['flash_csvupload'];
|
||||
unset($_SESSION['flash_csvupload']);
|
||||
$meldung = isset($flash['meldung']) ? (string)$flash['meldung'] : null;
|
||||
$fehler = isset($flash['fehler']) ? (string)$flash['fehler'] : null;
|
||||
}
|
||||
|
||||
if ($_SERVER["REQUEST_METHOD"] === "POST") {
|
||||
$aktion = $_POST['aktion'] ?? 'hochladen';
|
||||
$vorschauBatchId = (int)($_POST['batch_id'] ?? 0);
|
||||
|
||||
if ($aktion === 'hochladen' && isset($_FILES["csv_file"])) {
|
||||
[$csvFile, $uploadError] = csv_prepare_upload($_FILES["csv_file"]);
|
||||
|
||||
if ($uploadError !== null) {
|
||||
$fehler = $uploadError;
|
||||
$_SESSION['flash_csvupload'] = ['fehler' => $fehler];
|
||||
csv_redirect_to_batch();
|
||||
} else {
|
||||
try {
|
||||
$checksum = hash_file('sha256', $csvFile);
|
||||
@@ -160,6 +228,7 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
|
||||
|
||||
$pdo->commit();
|
||||
$vorschauBatchId = $batchId;
|
||||
$meldung = 'CSV-Datei wurde eingelesen. Bitte die Vorschau prüfen.';
|
||||
} catch (Throwable $e) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
@@ -168,9 +237,43 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
|
||||
} finally {
|
||||
@unlink($csvFile);
|
||||
}
|
||||
|
||||
$_SESSION['flash_csvupload'] = ['meldung' => $meldung, 'fehler' => $fehler];
|
||||
csv_redirect_to_batch($vorschauBatchId);
|
||||
}
|
||||
} elseif ($aktion === 'zeile_zuordnen') {
|
||||
$rowId = (int)($_POST['row_id'] ?? 0);
|
||||
$participantId = (int)($_POST['participant_id'] ?? 0);
|
||||
if ($vorschauBatchId <= 0 || $rowId <= 0 || $participantId <= 0) {
|
||||
$fehler = 'Bitte eine Importzeile und ein Mitglied auswählen.';
|
||||
} else {
|
||||
$result = imports_assign_row($pdo, $tenantId, $vorschauBatchId, $rowId, $participantId);
|
||||
if ($result['ok']) {
|
||||
$meldung = 'Importzeile wurde zugeordnet.';
|
||||
} else {
|
||||
$fehler = $result['error'] ?? 'Die Importzeile konnte nicht zugeordnet werden.';
|
||||
}
|
||||
}
|
||||
|
||||
$_SESSION['flash_csvupload'] = ['meldung' => $meldung, 'fehler' => $fehler];
|
||||
csv_redirect_to_batch($vorschauBatchId);
|
||||
} elseif ($aktion === 'zeile_ignorieren') {
|
||||
$rowId = (int)($_POST['row_id'] ?? 0);
|
||||
if ($vorschauBatchId <= 0 || $rowId <= 0) {
|
||||
$fehler = 'Bitte eine Importzeile auswählen.';
|
||||
} else {
|
||||
$result = imports_ignore_row($pdo, $tenantId, $vorschauBatchId, $rowId);
|
||||
if ($result['ok']) {
|
||||
$meldung = 'Importzeile wurde ignoriert.';
|
||||
} else {
|
||||
$fehler = $result['error'] ?? 'Die Importzeile konnte nicht ignoriert werden.';
|
||||
}
|
||||
}
|
||||
|
||||
$_SESSION['flash_csvupload'] = ['meldung' => $meldung, 'fehler' => $fehler];
|
||||
csv_redirect_to_batch($vorschauBatchId);
|
||||
} elseif ($aktion === 'importieren') {
|
||||
$batchId = (int)($_POST['batch_id'] ?? 0);
|
||||
$batchId = $vorschauBatchId;
|
||||
try {
|
||||
$ergebnis = imports_commit_batch($pdo, $tenantId, $batchId);
|
||||
app_audit_log($pdo, $tenantId, $saasUser['user_id'] ?? null, 'csv_import.committed', 'payment_import_batch', $batchId, ['imported' => $ergebnis['imported']]);
|
||||
@@ -179,10 +282,22 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
|
||||
$fehler = $e->getMessage();
|
||||
$vorschauBatchId = $batchId;
|
||||
}
|
||||
|
||||
$_SESSION['flash_csvupload'] = ['meldung' => $meldung, 'fehler' => $fehler];
|
||||
csv_redirect_to_batch($vorschauBatchId);
|
||||
}
|
||||
}
|
||||
|
||||
$vorschau = $vorschauBatchId !== null ? imports_fetch_batch($pdo, $tenantId, $vorschauBatchId) : null;
|
||||
$letzteBatches = imports_fetch_recent_batches($pdo, $tenantId, 8);
|
||||
$mitglieder = ledger_fetch_participant_summaries($pdo, $tenantId, ['active_only' => true]);
|
||||
$vorschauStatusZaehler = [];
|
||||
if ($vorschau !== null) {
|
||||
foreach ($vorschau['rows'] as $row) {
|
||||
$status = (string)$row['status'];
|
||||
$vorschauStatusZaehler[$status] = ($vorschauStatusZaehler[$status] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
?>
|
||||
|
||||
@@ -197,9 +312,15 @@ $vorschau = $vorschauBatchId !== null ? imports_fetch_batch($pdo, $tenantId, $vo
|
||||
|
||||
<?php if ($vorschau !== null): ?>
|
||||
<h3>Vorschau: <?php echo saas_html($vorschau['batch']['original_filename']); ?></h3>
|
||||
<p>Status: <?php echo saas_html($vorschau['batch']['status']); ?></p>
|
||||
<p>Status: <?php echo saas_html($vorschau['batch']['status']); ?> ·
|
||||
<?php echo (int)($vorschauStatusZaehler['matched'] ?? 0); ?> bereit ·
|
||||
<?php echo (int)($vorschauStatusZaehler['unmatched'] ?? 0); ?> ohne Zuordnung ·
|
||||
<?php echo (int)($vorschauStatusZaehler['duplicate'] ?? 0); ?> Duplikatverdacht ·
|
||||
<?php echo (int)($vorschauStatusZaehler['invalid'] ?? 0); ?> ungültig
|
||||
</p>
|
||||
|
||||
<table>
|
||||
<div class="table-wrapper">
|
||||
<table class="admin-table">
|
||||
<tr>
|
||||
<th>Zeile</th>
|
||||
<th>Name (CSV)</th>
|
||||
@@ -207,18 +328,58 @@ $vorschau = $vorschauBatchId !== null ? imports_fetch_batch($pdo, $tenantId, $vo
|
||||
<th>Betrag</th>
|
||||
<th>Datum</th>
|
||||
<th>Status</th>
|
||||
<th>Aktion</th>
|
||||
</tr>
|
||||
<?php foreach ($vorschau['rows'] as $row): ?>
|
||||
<?php
|
||||
$rowStatus = (string)$row['status'];
|
||||
$suggestions = in_array($rowStatus, ['unmatched', 'duplicate'], true)
|
||||
? imports_suggest_participants($pdo, $tenantId, (string)$row['raw_name'], null, 4)
|
||||
: [];
|
||||
$selectedId = count($suggestions) === 1 ? (int)$suggestions[0]['participant_id'] : 0;
|
||||
?>
|
||||
<tr>
|
||||
<td><?php echo (int)$row['row_num']; ?></td>
|
||||
<td><?php echo saas_html($row['raw_name']); ?></td>
|
||||
<td><?php echo saas_html($row['display_name'] ?? '—'); ?></td>
|
||||
<td><?php echo saas_html(number_format(((int)$row['amount_cents']) / 100, 2, ',', '.')); ?> €</td>
|
||||
<td><?php echo saas_html($row['booked_at']); ?></td>
|
||||
<td><?php echo saas_html(csv_status_label($row['status'])); ?></td>
|
||||
<td><span class="status-badge <?php echo saas_html(csv_status_class($rowStatus)); ?>"><?php echo saas_html(csv_status_label($rowStatus)); ?></span></td>
|
||||
<td>
|
||||
<?php if ($vorschau['batch']['status'] === 'previewed' && in_array($rowStatus, ['unmatched', 'duplicate'], true)): ?>
|
||||
<form method="post" action="csvupload.php" class="inline-admin-form"<?php echo $rowStatus === 'duplicate' ? ' onsubmit="return confirm(\'Diese Zeile wurde als Duplikat erkannt. Trotzdem als neue Einzahlung buchen?\');"' : ''; ?>>
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<input type="hidden" name="aktion" value="zeile_zuordnen">
|
||||
<input type="hidden" name="batch_id" value="<?php echo (int)$vorschau['batch']['id']; ?>">
|
||||
<input type="hidden" name="row_id" value="<?php echo (int)$row['id']; ?>">
|
||||
<select name="participant_id" required>
|
||||
<?php echo csv_select_options($mitglieder, $suggestions, $selectedId); ?>
|
||||
</select>
|
||||
<button type="submit">Zuordnen</button>
|
||||
</form>
|
||||
<form method="post" action="csvupload.php" class="inline-admin-form" onsubmit="return confirm('Diese Importzeile ignorieren?');">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<input type="hidden" name="aktion" value="zeile_ignorieren">
|
||||
<input type="hidden" name="batch_id" value="<?php echo (int)$vorschau['batch']['id']; ?>">
|
||||
<input type="hidden" name="row_id" value="<?php echo (int)$row['id']; ?>">
|
||||
<button type="submit" class="alt">Ignorieren</button>
|
||||
</form>
|
||||
<?php elseif ($vorschau['batch']['status'] === 'previewed' && $rowStatus === 'invalid'): ?>
|
||||
<form method="post" action="csvupload.php" class="inline-admin-form" onsubmit="return confirm('Diese ungültige Importzeile ignorieren?');">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<input type="hidden" name="aktion" value="zeile_ignorieren">
|
||||
<input type="hidden" name="batch_id" value="<?php echo (int)$vorschau['batch']['id']; ?>">
|
||||
<input type="hidden" name="row_id" value="<?php echo (int)$row['id']; ?>">
|
||||
<button type="submit" class="alt">Ignorieren</button>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
—
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<?php if ($vorschau['batch']['status'] === 'previewed'): ?>
|
||||
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>">
|
||||
@@ -231,6 +392,32 @@ $vorschau = $vorschauBatchId !== null ? imports_fetch_batch($pdo, $tenantId, $vo
|
||||
<br>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($letzteBatches !== []): ?>
|
||||
<h3>Letzte Importvorschauen</h3>
|
||||
<div class="table-wrapper">
|
||||
<table class="admin-table">
|
||||
<tr>
|
||||
<th>Datei</th>
|
||||
<th>Status</th>
|
||||
<th>Zeilen</th>
|
||||
<th>Bereit</th>
|
||||
<th>Offen</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
<?php foreach ($letzteBatches as $batch): ?>
|
||||
<tr>
|
||||
<td><?php echo saas_html($batch['original_filename']); ?><br><small><?php echo saas_html($batch['created_at']); ?></small></td>
|
||||
<td><?php echo saas_html($batch['status']); ?></td>
|
||||
<td><?php echo (int)$batch['total_rows']; ?></td>
|
||||
<td><?php echo (int)$batch['matched_rows'] + (int)$batch['imported_rows']; ?></td>
|
||||
<td><?php echo (int)$batch['unresolved_rows']; ?></td>
|
||||
<td><a class="button small" href="csvupload.php?batch_id=<?php echo (int)$batch['id']; ?>">Öffnen</a></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form action="csvupload.php" method="post" enctype="multipart/form-data">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<input type="hidden" name="aktion" value="hochladen">
|
||||
|
||||
+211
-12
@@ -33,6 +33,66 @@ if ($saasUser !== null && saas_user_has_role(['owner', 'admin'], $saasUser)) {
|
||||
$hasAccess = true;
|
||||
}
|
||||
|
||||
function mitglieder_parse_filters(array $source): array
|
||||
{
|
||||
$status = (string)($source['status'] ?? 'alle');
|
||||
if (!in_array($status, ['alle', 'aktiv', 'inaktiv'], true)) {
|
||||
$status = 'alle';
|
||||
}
|
||||
|
||||
$zugang = (string)($source['zugang'] ?? 'alle');
|
||||
if (!in_array($zugang, ['alle', 'mit_zugang', 'ohne_zugang', 'entzogen'], true)) {
|
||||
$zugang = 'alle';
|
||||
}
|
||||
|
||||
$paypal = (string)($source['paypal'] ?? 'alle');
|
||||
if (!in_array($paypal, ['alle', 'hinterlegt', 'fehlt', 'doppelt'], true)) {
|
||||
$paypal = 'alle';
|
||||
}
|
||||
|
||||
return [
|
||||
'q' => trim((string)($source['q'] ?? '')),
|
||||
'status' => $status,
|
||||
'zugang' => $zugang,
|
||||
'paypal' => $paypal,
|
||||
];
|
||||
}
|
||||
|
||||
function mitglieder_build_filter_query(array $filters): string
|
||||
{
|
||||
$params = [];
|
||||
foreach (['q', 'status', 'zugang', 'paypal'] as $key) {
|
||||
$value = (string)($filters[$key] ?? '');
|
||||
if ($value !== '' && !in_array($value, ['alle'], true)) {
|
||||
$params[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return http_build_query($params);
|
||||
}
|
||||
|
||||
function mitglieder_filter_fields(array $filters): string
|
||||
{
|
||||
return '<input type="hidden" name="filter_q" value="' . saas_html((string)$filters['q']) . '">'
|
||||
. '<input type="hidden" name="filter_status" value="' . saas_html((string)$filters['status']) . '">'
|
||||
. '<input type="hidden" name="filter_zugang" value="' . saas_html((string)$filters['zugang']) . '">'
|
||||
. '<input type="hidden" name="filter_paypal" value="' . saas_html((string)$filters['paypal']) . '">';
|
||||
}
|
||||
|
||||
function mitglieder_status_badge(bool $active): string
|
||||
{
|
||||
return '<span class="status-badge ' . ($active ? 'success' : 'muted') . '">' . ($active ? 'Aktiv' : 'Inaktiv') . '</span>';
|
||||
}
|
||||
|
||||
function mitglieder_access_label(array $mitglied, array $rollenLabels): string
|
||||
{
|
||||
if ($mitglied['membership_status'] === 'active') {
|
||||
return $rollenLabels[$mitglied['role']] ?? (string)$mitglied['role'];
|
||||
}
|
||||
|
||||
return $mitglied['membership_status'] === 'revoked' ? 'Zugang entzogen' : 'Kein Zugang';
|
||||
}
|
||||
|
||||
if($hasAccess){
|
||||
|
||||
$meldung = null;
|
||||
@@ -40,6 +100,17 @@ if($hasAccess){
|
||||
$einladungslink = null;
|
||||
$bearbeitenId = null;
|
||||
$actorUserId = $saasUser['user_id'] ?? null;
|
||||
$filterSource = $_SERVER['REQUEST_METHOD'] === 'POST'
|
||||
? [
|
||||
'q' => $_POST['filter_q'] ?? '',
|
||||
'status' => $_POST['filter_status'] ?? 'alle',
|
||||
'zugang' => $_POST['filter_zugang'] ?? 'alle',
|
||||
'paypal' => $_POST['filter_paypal'] ?? 'alle',
|
||||
]
|
||||
: $_GET;
|
||||
$filter = mitglieder_parse_filters($filterSource);
|
||||
$filterQuery = mitglieder_build_filter_query($filter);
|
||||
$filterSuffix = $filterQuery !== '' ? '?' . $filterQuery : '';
|
||||
|
||||
if (isset($_SESSION['flash_mitglieder']) && is_array($_SESSION['flash_mitglieder'])) {
|
||||
$flash = $_SESSION['flash_mitglieder'];
|
||||
@@ -224,7 +295,7 @@ if($hasAccess){
|
||||
'fehler' => $fehler,
|
||||
'einladungslink' => $einladungslink,
|
||||
];
|
||||
header('Location: mitarbeiterverwalten.php');
|
||||
header('Location: mitarbeiterverwalten.php' . $filterSuffix);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
@@ -235,6 +306,77 @@ if($hasAccess){
|
||||
$billingPlan = billing_plans()[$billingInfo['plan_code']] ?? null;
|
||||
$aktiveMitgliederAnzahl = billing_count_active_participants($pdo, $tenantId);
|
||||
$rollenLabels = ['owner' => 'Inhaber', 'admin' => 'Administrator', 'treasurer' => 'Kassenwart', 'member' => 'Mitglied', 'viewer' => 'Betrachter'];
|
||||
$paypalNameCounts = [];
|
||||
foreach ($mitglieder as $mitglied) {
|
||||
$paypalNorm = imports_normalize_match_text((string)($mitglied['paypal_name'] ?? ''));
|
||||
if ($paypalNorm !== '') {
|
||||
$paypalNameCounts[$paypalNorm] = ($paypalNameCounts[$paypalNorm] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
$mitgliederStats = [
|
||||
'gesamt' => count($mitglieder),
|
||||
'aktiv' => 0,
|
||||
'inaktiv' => 0,
|
||||
'mit_zugang' => 0,
|
||||
'ohne_zugang' => 0,
|
||||
'paypal_fehlt' => 0,
|
||||
'paypal_doppelt' => 0,
|
||||
];
|
||||
$gefilterteMitglieder = [];
|
||||
foreach ($mitglieder as $mitglied) {
|
||||
$isActive = (bool)$mitglied['active'];
|
||||
$hasAccessAccount = $mitglied['membership_status'] === 'active';
|
||||
$paypalName = trim((string)($mitglied['paypal_name'] ?? ''));
|
||||
$paypalNorm = imports_normalize_match_text($paypalName);
|
||||
$paypalDuplicate = $paypalNorm !== '' && ($paypalNameCounts[$paypalNorm] ?? 0) > 1;
|
||||
|
||||
$mitgliederStats[$isActive ? 'aktiv' : 'inaktiv']++;
|
||||
$mitgliederStats[$hasAccessAccount ? 'mit_zugang' : 'ohne_zugang']++;
|
||||
if ($paypalName === '') {
|
||||
$mitgliederStats['paypal_fehlt']++;
|
||||
}
|
||||
if ($paypalDuplicate) {
|
||||
$mitgliederStats['paypal_doppelt']++;
|
||||
}
|
||||
|
||||
$matches = true;
|
||||
$q = imports_normalize_match_text((string)$filter['q']);
|
||||
if ($q !== '') {
|
||||
$searchText = imports_normalize_match_text(
|
||||
(string)$mitglied['display_name'] . ' ' . (string)($mitglied['email'] ?? '') . ' ' . $paypalName
|
||||
);
|
||||
$matches = str_contains($searchText, $q);
|
||||
}
|
||||
if ($matches && $filter['status'] === 'aktiv') {
|
||||
$matches = $isActive;
|
||||
}
|
||||
if ($matches && $filter['status'] === 'inaktiv') {
|
||||
$matches = !$isActive;
|
||||
}
|
||||
if ($matches && $filter['zugang'] === 'mit_zugang') {
|
||||
$matches = $hasAccessAccount;
|
||||
}
|
||||
if ($matches && $filter['zugang'] === 'ohne_zugang') {
|
||||
$matches = $mitglied['membership_status'] === null || $mitglied['membership_status'] === 'pending';
|
||||
}
|
||||
if ($matches && $filter['zugang'] === 'entzogen') {
|
||||
$matches = $mitglied['membership_status'] === 'revoked';
|
||||
}
|
||||
if ($matches && $filter['paypal'] === 'hinterlegt') {
|
||||
$matches = $paypalName !== '';
|
||||
}
|
||||
if ($matches && $filter['paypal'] === 'fehlt') {
|
||||
$matches = $paypalName === '';
|
||||
}
|
||||
if ($matches && $filter['paypal'] === 'doppelt') {
|
||||
$matches = $paypalDuplicate;
|
||||
}
|
||||
|
||||
if ($matches) {
|
||||
$mitglied['paypal_duplicate'] = $paypalDuplicate;
|
||||
$gefilterteMitglieder[] = $mitglied;
|
||||
}
|
||||
}
|
||||
$bearbeitenMitglied = null;
|
||||
if ($bearbeitenId !== null) {
|
||||
foreach ($mitglieder as $m) {
|
||||
@@ -263,12 +405,21 @@ if($hasAccess){
|
||||
Limit erreicht – für weitere aktive Mitglieder ist ein <a href="mandant-einstellungen.php">Upgrade</a> nötig.
|
||||
<?php endif; ?></p>
|
||||
|
||||
<div class="admin-summary-grid">
|
||||
<div><strong><?php echo (int)$mitgliederStats['gesamt']; ?></strong><span>Mitglieder</span></div>
|
||||
<div><strong><?php echo (int)$mitgliederStats['aktiv']; ?></strong><span>aktiv</span></div>
|
||||
<div><strong><?php echo (int)$mitgliederStats['mit_zugang']; ?></strong><span>mit Zugang</span></div>
|
||||
<div><strong><?php echo (int)$mitgliederStats['paypal_fehlt']; ?></strong><span>ohne PayPal-Name</span></div>
|
||||
<div><strong><?php echo (int)$mitgliederStats['paypal_doppelt']; ?></strong><span>PayPal-Konflikte</span></div>
|
||||
</div>
|
||||
|
||||
<?php if ($bearbeitenMitglied !== null): ?>
|
||||
<h3>Bearbeiten von <?php echo saas_html($bearbeitenMitglied['display_name']); ?></h3>
|
||||
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>">
|
||||
<input type="hidden" name="aktion" value="bearbeitenspeichern">
|
||||
<input type="hidden" name="mitgliedID" value="<?php echo $bearbeitenMitglied['participant_id']; ?>">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<?php echo mitglieder_filter_fields($filter); ?>
|
||||
|
||||
<label for="name">Name:</label>
|
||||
<input type="text" name="name" id="name" value="<?php echo saas_html($bearbeitenMitglied['display_name']); ?>" required>
|
||||
@@ -292,6 +443,7 @@ if($hasAccess){
|
||||
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>">
|
||||
<input type="hidden" name="aktion" id="aktion" value="anlegen">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<?php echo mitglieder_filter_fields($filter); ?>
|
||||
|
||||
<label for="new-name">Name:</label>
|
||||
<input type="text" name="name" id="new-name" required>
|
||||
@@ -316,6 +468,7 @@ if($hasAccess){
|
||||
<p><a class="button" href="mitglieder-vorlage.php">Vorlage herunterladen</a></p>
|
||||
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>" enctype="multipart/form-data">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<?php echo mitglieder_filter_fields($filter); ?>
|
||||
<input type="hidden" name="aktion" value="csv_import">
|
||||
<label for="mitglieder_csv">CSV-Datei mit Mitgliedern:</label>
|
||||
<input type="file" name="mitglieder_csv" id="mitglieder_csv" accept=".csv" required>
|
||||
@@ -326,8 +479,38 @@ if($hasAccess){
|
||||
<p>Name und E-Mail dienen der Kaffeeliste und Benachrichtigungen. Ein Login-Zugang
|
||||
ist davon unabhängig und wird separat je Mitglied gewährt oder entzogen.</p>
|
||||
|
||||
<form method="get" action="mitarbeiterverwalten.php" class="admin-filter-bar">
|
||||
<label for="mitglieder-q">Suche</label>
|
||||
<input type="search" name="q" id="mitglieder-q" value="<?php echo saas_html((string)$filter['q']); ?>" placeholder="Name, E-Mail oder PayPal">
|
||||
<label for="mitglieder-status">Status</label>
|
||||
<select name="status" id="mitglieder-status">
|
||||
<option value="alle" <?php echo $filter['status'] === 'alle' ? 'selected' : ''; ?>>Alle</option>
|
||||
<option value="aktiv" <?php echo $filter['status'] === 'aktiv' ? 'selected' : ''; ?>>Aktiv</option>
|
||||
<option value="inaktiv" <?php echo $filter['status'] === 'inaktiv' ? 'selected' : ''; ?>>Inaktiv</option>
|
||||
</select>
|
||||
<label for="mitglieder-zugang">Zugang</label>
|
||||
<select name="zugang" id="mitglieder-zugang">
|
||||
<option value="alle" <?php echo $filter['zugang'] === 'alle' ? 'selected' : ''; ?>>Alle</option>
|
||||
<option value="mit_zugang" <?php echo $filter['zugang'] === 'mit_zugang' ? 'selected' : ''; ?>>Mit Zugang</option>
|
||||
<option value="ohne_zugang" <?php echo $filter['zugang'] === 'ohne_zugang' ? 'selected' : ''; ?>>Ohne Zugang</option>
|
||||
<option value="entzogen" <?php echo $filter['zugang'] === 'entzogen' ? 'selected' : ''; ?>>Entzogen</option>
|
||||
</select>
|
||||
<label for="mitglieder-paypal">PayPal</label>
|
||||
<select name="paypal" id="mitglieder-paypal">
|
||||
<option value="alle" <?php echo $filter['paypal'] === 'alle' ? 'selected' : ''; ?>>Alle</option>
|
||||
<option value="hinterlegt" <?php echo $filter['paypal'] === 'hinterlegt' ? 'selected' : ''; ?>>Hinterlegt</option>
|
||||
<option value="fehlt" <?php echo $filter['paypal'] === 'fehlt' ? 'selected' : ''; ?>>Fehlt</option>
|
||||
<option value="doppelt" <?php echo $filter['paypal'] === 'doppelt' ? 'selected' : ''; ?>>Konflikte</option>
|
||||
</select>
|
||||
<button type="submit">Filtern</button>
|
||||
<a class="button alt" href="mitarbeiterverwalten.php">Zurücksetzen</a>
|
||||
</form>
|
||||
|
||||
<p><?php echo count($gefilterteMitglieder); ?> von <?php echo count($mitglieder); ?> Mitgliedern angezeigt.</p>
|
||||
|
||||
<!-- Tabelle zur Anzeige und Bearbeitung von Mitgliedern -->
|
||||
<table class="table table-striped">
|
||||
<div class="table-wrapper">
|
||||
<table class="table table-striped admin-table">
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>E-Mail</th>
|
||||
@@ -336,22 +519,28 @@ if($hasAccess){
|
||||
<th>Zugang</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
<?php foreach ($mitglieder as $mitglied): ?>
|
||||
<?php foreach ($gefilterteMitglieder as $mitglied): ?>
|
||||
<tr>
|
||||
<td><?php echo saas_html($mitglied['display_name']); ?></td>
|
||||
<td><?php echo saas_html($mitglied['email'] ?? ''); ?></td>
|
||||
<td><?php echo saas_html($mitglied['paypal_name'] ?? ''); ?></td>
|
||||
<td><?php echo $mitglied['active'] ? '1' : '0'; ?></td>
|
||||
<td>
|
||||
<?php echo saas_html($mitglied['paypal_name'] ?? ''); ?>
|
||||
<?php if (!empty($mitglied['paypal_duplicate'])): ?>
|
||||
<br><span class="status-badge warning">Konflikt</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td><?php echo mitglieder_status_badge((bool)$mitglied['active']); ?></td>
|
||||
<td>
|
||||
<?php if ($mitglied['membership_status'] === 'active'): ?>
|
||||
<?php if ($mitglied['role'] === 'owner'): ?>
|
||||
Inhaber
|
||||
<span class="status-badge info">Inhaber</span>
|
||||
<?php else: ?>
|
||||
<?php echo saas_html($rollenLabels[$mitglied['role']] ?? $mitglied['role']); ?>
|
||||
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>" style="display:inline">
|
||||
<span class="status-badge success"><?php echo saas_html($rollenLabels[$mitglied['role']] ?? $mitglied['role']); ?></span>
|
||||
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>" class="inline-admin-form">
|
||||
<input type="hidden" name="aktion" value="zugang_gewaehren">
|
||||
<input type="hidden" name="mitgliedID" value="<?php echo $mitglied['participant_id']; ?>">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<?php echo mitglieder_filter_fields($filter); ?>
|
||||
<select name="rolle">
|
||||
<?php foreach (saas_grantable_roles() as $rolle): ?>
|
||||
<option value="<?php echo saas_html($rolle); ?>" <?php echo $rolle === $mitglied['role'] ? 'selected' : ''; ?>><?php echo saas_html($rollenLabels[$rolle] ?? $rolle); ?></option>
|
||||
@@ -359,20 +548,22 @@ if($hasAccess){
|
||||
</select>
|
||||
<button type="submit">Rolle ändern</button>
|
||||
</form>
|
||||
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>" style="display:inline">
|
||||
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>" class="inline-admin-form">
|
||||
<input type="hidden" name="aktion" value="zugang_entziehen">
|
||||
<input type="hidden" name="mitgliedID" value="<?php echo $mitglied['participant_id']; ?>">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<?php echo mitglieder_filter_fields($filter); ?>
|
||||
<button type="submit">Zugang entziehen</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
<?php else: ?>
|
||||
<?php echo $mitglied['membership_status'] === 'revoked' ? 'Zugang entzogen' : 'Kein Zugang'; ?>
|
||||
<span class="status-badge <?php echo $mitglied['membership_status'] === 'revoked' ? 'warning' : 'muted'; ?>"><?php echo saas_html(mitglieder_access_label($mitglied, $rollenLabels)); ?></span>
|
||||
<?php if ($mitglied['email'] !== null && $mitglied['email'] !== ''): ?>
|
||||
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>" style="display:inline">
|
||||
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>" class="inline-admin-form">
|
||||
<input type="hidden" name="aktion" value="zugang_gewaehren">
|
||||
<input type="hidden" name="mitgliedID" value="<?php echo $mitglied['participant_id']; ?>">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<?php echo mitglieder_filter_fields($filter); ?>
|
||||
<select name="rolle">
|
||||
<?php foreach (saas_grantable_roles() as $rolle): ?>
|
||||
<option value="<?php echo saas_html($rolle); ?>"><?php echo saas_html($rollenLabels[$rolle] ?? $rolle); ?></option>
|
||||
@@ -384,12 +575,13 @@ if($hasAccess){
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<ul class="actions">
|
||||
<ul class="actions compact-actions">
|
||||
<li>
|
||||
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>">
|
||||
<input type="hidden" name="aktion" value="bearbeiten">
|
||||
<input type="hidden" name="mitgliedID" value="<?php echo $mitglied['participant_id']; ?>">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<?php echo mitglieder_filter_fields($filter); ?>
|
||||
<button type="submit">Bearbeiten</button>
|
||||
</form>
|
||||
</li>
|
||||
@@ -399,6 +591,7 @@ if($hasAccess){
|
||||
<input type="hidden" name="aktion" value="deaktivieren">
|
||||
<input type="hidden" name="mitgliedID" value="<?php echo $mitglied['participant_id']; ?>">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<?php echo mitglieder_filter_fields($filter); ?>
|
||||
<button type="submit">Deaktivieren</button>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
@@ -406,6 +599,7 @@ if($hasAccess){
|
||||
<input type="hidden" name="aktion" value="aktivieren">
|
||||
<input type="hidden" name="mitgliedID" value="<?php echo $mitglied['participant_id']; ?>">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<?php echo mitglieder_filter_fields($filter); ?>
|
||||
<button type="submit">Aktivieren</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
@@ -416,6 +610,7 @@ if($hasAccess){
|
||||
<input type="hidden" name="aktion" value="anonymisieren">
|
||||
<input type="hidden" name="mitgliedID" value="<?php echo $mitglied['participant_id']; ?>">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<?php echo mitglieder_filter_fields($filter); ?>
|
||||
<button type="submit">Anonymisieren</button>
|
||||
</form>
|
||||
</li>
|
||||
@@ -424,7 +619,11 @@ if($hasAccess){
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php if ($gefilterteMitglieder === []): ?>
|
||||
<tr><td colspan="6">Keine Mitglieder für diese Filter gefunden.</td></tr>
|
||||
<?php endif; ?>
|
||||
</table>
|
||||
</div>
|
||||
<?php
|
||||
|
||||
}else{
|
||||
|
||||
+94
-12
@@ -21,6 +21,59 @@ if ($hasAccess && isset($_SESSION['flash_paypal'])) {
|
||||
unset($_SESSION['flash_paypal']);
|
||||
}
|
||||
|
||||
$suchbegriff = trim((string)($_SERVER['REQUEST_METHOD'] === 'POST' ? ($_POST['return_q'] ?? '') : ($_GET['q'] ?? '')));
|
||||
$returnQuery = $suchbegriff !== '' ? '?q=' . urlencode($suchbegriff) : '';
|
||||
|
||||
function paypal_select_options(array $members, array $suggestions = [], int $selectedId = 0): string
|
||||
{
|
||||
$suggestedIds = [];
|
||||
foreach ($suggestions as $suggestion) {
|
||||
$suggestedIds[(int)$suggestion['participant_id']] = true;
|
||||
}
|
||||
|
||||
$html = '<option value="">- Mitglied wählen -</option>';
|
||||
if ($suggestions !== []) {
|
||||
$html .= '<optgroup label="Vorschläge">';
|
||||
foreach ($suggestions as $suggestion) {
|
||||
$id = (int)$suggestion['participant_id'];
|
||||
$html .= '<option value="' . $id . '"' . ($selectedId === $id ? ' selected' : '') . '>'
|
||||
. saas_html((string)$suggestion['display_name'])
|
||||
. '</option>';
|
||||
}
|
||||
$html .= '</optgroup>';
|
||||
}
|
||||
|
||||
$html .= '<optgroup label="Alle aktiven Mitglieder">';
|
||||
foreach ($members as $member) {
|
||||
$id = (int)$member['participant_id'];
|
||||
if (isset($suggestedIds[$id])) {
|
||||
continue;
|
||||
}
|
||||
$html .= '<option value="' . $id . '"' . ($selectedId === $id ? ' selected' : '') . '>'
|
||||
. saas_html((string)$member['display_name'])
|
||||
. '</option>';
|
||||
}
|
||||
$html .= '</optgroup>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
function paypal_payment_matches_query(array $payment, string $query): bool
|
||||
{
|
||||
$query = imports_normalize_match_text($query);
|
||||
if ($query === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
$text = imports_normalize_match_text(
|
||||
(string)$payment['payer_name'] . ' '
|
||||
. (string)($payment['note'] ?? '') . ' '
|
||||
. (string)($payment['transaction_code'] ?? '')
|
||||
);
|
||||
|
||||
return str_contains($text, $query);
|
||||
}
|
||||
|
||||
if ($hasAccess && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$aktion = (string)($_POST['aktion'] ?? '');
|
||||
$paymentId = (int)($_POST['payment_id'] ?? 0);
|
||||
@@ -45,7 +98,7 @@ if ($hasAccess && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
|
||||
// Post-Redirect-Get gegen Doppelbuchung per Refresh.
|
||||
$_SESSION['flash_paypal'] = $flash;
|
||||
header('Location: paypal-zuordnung.php');
|
||||
header('Location: paypal-zuordnung.php' . $returnQuery);
|
||||
exit;
|
||||
}
|
||||
|
||||
@@ -58,6 +111,7 @@ if ($hasAccess) {
|
||||
$offene = paypal_fetch_unmatched($pdo, $tenantId);
|
||||
$mitglieder = ledger_fetch_participant_summaries($pdo, $tenantId, ['active_only' => true]);
|
||||
}
|
||||
$offeneGefiltert = array_values(array_filter($offene, static fn(array $payment): bool => paypal_payment_matches_query($payment, $suchbegriff)));
|
||||
|
||||
include "header.php";
|
||||
include "headerline.php";
|
||||
@@ -88,27 +142,53 @@ include "nav.php";
|
||||
<?php endif; ?>
|
||||
|
||||
<h3>Offene Zahlungen (<?php echo count($offene); ?>)</h3>
|
||||
<form method="get" action="paypal-zuordnung.php" class="admin-filter-bar">
|
||||
<label for="paypal-q">Suche</label>
|
||||
<input type="search" name="q" id="paypal-q" value="<?php echo saas_html($suchbegriff); ?>" placeholder="Zahler, Mitteilung oder Transaktion">
|
||||
<button type="submit">Filtern</button>
|
||||
<a class="button alt" href="paypal-zuordnung.php">Zurücksetzen</a>
|
||||
</form>
|
||||
<?php if ($offene === []): ?>
|
||||
<p>Keine offenen Zahlungen. Alles automatisch zugeordnet. 🎉</p>
|
||||
<p>Keine offenen Zahlungen. Alles automatisch zugeordnet.</p>
|
||||
<?php elseif ($offeneGefiltert === []): ?>
|
||||
<p>Keine offenen Zahlungen für diesen Filter gefunden.</p>
|
||||
<?php else: ?>
|
||||
<table>
|
||||
<tr><th>Eingang</th><th>Zahler (PayPal)</th><th>Mitteilung</th><th>Betrag</th><th>Zuordnen</th><th></th></tr>
|
||||
<?php foreach ($offene as $z): ?>
|
||||
<p><?php echo count($offeneGefiltert); ?> von <?php echo count($offene); ?> offenen Zahlungen angezeigt.</p>
|
||||
<div class="table-wrapper">
|
||||
<table class="admin-table">
|
||||
<tr><th>Eingang</th><th>Zahlung</th><th>Betrag</th><th>Vorschläge</th><th>Zuordnen</th><th></th></tr>
|
||||
<?php foreach ($offeneGefiltert as $z): ?>
|
||||
<?php
|
||||
$suggestions = imports_suggest_participants($pdo, $tenantId, (string)$z['payer_name'], (string)($z['note'] ?? ''), 4);
|
||||
$selectedId = count($suggestions) === 1 ? (int)$suggestions[0]['participant_id'] : 0;
|
||||
?>
|
||||
<tr>
|
||||
<td><?php echo saas_html((string)($z['paid_at'] ?? $z['created_at'])); ?></td>
|
||||
<td><?php echo saas_html($z['payer_name']); ?></td>
|
||||
<td><?php echo saas_html((string)($z['note'] ?? '')); ?></td>
|
||||
<td>
|
||||
<strong><?php echo saas_html($z['payer_name']); ?></strong>
|
||||
<?php if (($z['note'] ?? '') !== null && trim((string)$z['note']) !== ''): ?>
|
||||
<br><small><?php echo saas_html((string)$z['note']); ?></small>
|
||||
<?php endif; ?>
|
||||
<br><small>Transaktion: <?php echo saas_html((string)$z['transaction_code']); ?></small>
|
||||
</td>
|
||||
<td><?php echo saas_html(saas_format_money_cents((int)$z['net_cents'])); ?> €</td>
|
||||
<td>
|
||||
<form method="post" action="paypal-zuordnung.php" style="display:flex;gap:0.5em;align-items:center;margin:0">
|
||||
<?php if ($suggestions === []): ?>
|
||||
<span class="status-badge muted">Kein Vorschlag</span>
|
||||
<?php else: ?>
|
||||
<?php foreach ($suggestions as $suggestion): ?>
|
||||
<span class="status-badge info"><?php echo saas_html((string)$suggestion['display_name']); ?></span>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<form method="post" action="paypal-zuordnung.php" class="inline-admin-form">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<input type="hidden" name="aktion" value="zuordnen">
|
||||
<input type="hidden" name="payment_id" value="<?php echo (int)$z['id']; ?>">
|
||||
<input type="hidden" name="return_q" value="<?php echo saas_html($suchbegriff); ?>">
|
||||
<select name="participant_id" required>
|
||||
<option value="">– Mitglied wählen –</option>
|
||||
<?php foreach ($mitglieder as $m): ?>
|
||||
<option value="<?php echo (int)$m['participant_id']; ?>"><?php echo saas_html($m['display_name']); ?></option>
|
||||
<?php endforeach; ?>
|
||||
<?php echo paypal_select_options($mitglieder, $suggestions, $selectedId); ?>
|
||||
</select>
|
||||
<button type="submit">Buchen</button>
|
||||
</form>
|
||||
@@ -118,12 +198,14 @@ include "nav.php";
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<input type="hidden" name="aktion" value="ignorieren">
|
||||
<input type="hidden" name="payment_id" value="<?php echo (int)$z['id']; ?>">
|
||||
<input type="hidden" name="return_q" value="<?php echo saas_html($suchbegriff); ?>">
|
||||
<button type="submit" class="alt">Ignorieren</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php endif; ?>
|
||||
|
||||
@@ -43,6 +43,12 @@ function payment_matching_insert_participant(PDO $pdo, int $tenantId, string $di
|
||||
function payment_matching_cleanup(PDO $pdo, array $tenantIds): void
|
||||
{
|
||||
foreach ($tenantIds as $tenantId) {
|
||||
$pdo->prepare(
|
||||
'DELETE r FROM payment_import_rows r
|
||||
JOIN payment_import_batches b ON b.id = r.batch_id
|
||||
WHERE b.tenant_id = ?'
|
||||
)->execute([$tenantId]);
|
||||
$pdo->prepare('DELETE FROM payment_import_batches WHERE tenant_id = ?')->execute([$tenantId]);
|
||||
$pdo->prepare('DELETE FROM participants WHERE tenant_id = ?')->execute([$tenantId]);
|
||||
$pdo->prepare('DELETE FROM tenants WHERE id = ?')->execute([$tenantId]);
|
||||
}
|
||||
@@ -139,6 +145,64 @@ try {
|
||||
$failures,
|
||||
$passes
|
||||
);
|
||||
|
||||
$suggestions = imports_suggest_participants($pdo, $tenantId, "paypal unique {$suffix}", null, 3);
|
||||
payment_matching_assert(
|
||||
'Vorschlaege enthalten den passenden Teilnehmer',
|
||||
$suggestions !== [] && $suggestions[0]['participant_id'] === $uniqueId,
|
||||
$failures,
|
||||
$passes
|
||||
);
|
||||
|
||||
$batchId = imports_create_batch($pdo, $tenantId, 'matching-test.csv', hash('sha256', $suffix), null);
|
||||
$unmatchedRowId = imports_store_row(
|
||||
$pdo,
|
||||
$batchId,
|
||||
1,
|
||||
null,
|
||||
"PayPal Unique {$suffix}",
|
||||
500,
|
||||
date('Y-m-d H:i:s'),
|
||||
'unmatched',
|
||||
['row' => 1]
|
||||
);
|
||||
$invalidRowId = imports_store_row(
|
||||
$pdo,
|
||||
$batchId,
|
||||
2,
|
||||
null,
|
||||
"Kaputt {$suffix}",
|
||||
0,
|
||||
date('Y-m-d H:i:s'),
|
||||
'invalid',
|
||||
['row' => 2]
|
||||
);
|
||||
|
||||
$assignResult = imports_assign_row($pdo, $tenantId, $batchId, $unmatchedRowId, $uniqueId);
|
||||
$stmt = $pdo->prepare('SELECT participant_id, status FROM payment_import_rows WHERE id = ?');
|
||||
$stmt->execute([$unmatchedRowId]);
|
||||
$assignedRow = $stmt->fetch();
|
||||
payment_matching_assert(
|
||||
'unmatched CSV-Zeile kann manuell zugeordnet werden',
|
||||
$assignResult['ok'] === true
|
||||
&& (int)$assignedRow['participant_id'] === $uniqueId
|
||||
&& (string)$assignedRow['status'] === 'matched',
|
||||
$failures,
|
||||
$passes
|
||||
);
|
||||
|
||||
$ignoreResult = imports_ignore_row($pdo, $tenantId, $batchId, $invalidRowId);
|
||||
$stmt = $pdo->prepare('SELECT participant_id, status FROM payment_import_rows WHERE id = ?');
|
||||
$stmt->execute([$invalidRowId]);
|
||||
$ignoredRow = $stmt->fetch();
|
||||
payment_matching_assert(
|
||||
'ungueltige CSV-Zeile kann ignoriert werden',
|
||||
$ignoreResult['ok'] === true
|
||||
&& $ignoredRow['participant_id'] === null
|
||||
&& (string)$ignoredRow['status'] === 'ignored',
|
||||
$failures,
|
||||
$passes
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
|
||||
Reference in New Issue
Block a user