Funktions-Schalter je Mandant (app/features.php, tenant_features): der Betreiber schaltet FAQ, Selbsteintrag, PDF, PayPal-Eingang, CSV-Import, Mailversand, Jahresabschluss, Datenexport und eigenes Design pro Mandant frei. Gesperrte Funktionen verschwinden aus Menue und Schaltflaechen, ihre Seiten weisen Aufrufe und POSTs ab. Das Back-Office ist jetzt fuer Platform-Admins im Menue verlinkt statt nur per URL erreichbar. Eigenes Design je Mandant: Akzentfarbe und Logo in den Mandant- Einstellungen, eingebettet ueber app/branding.php; das Logo liegt geschuetzt in var/tenant_logos und wird nur ueber tenant-logo-anzeigen.php an den eigenen Mandanten ausgeliefert. Weniger Startinformationen: Startpaket neuer Mandanten auf zwei Beispielfragen gekuerzt, Anleitung von ~1400 auf ~750 Woerter gestrafft und um Abschnitte zu gesperrten Funktionen bereinigt. Vorder-/Rueckseite erst bei mehr als 50 Personen (vorher schon ab 50). Die Schwelle liegt jetzt gemeinsam in app/ledger.php und gilt auch fuer die Vorder-/Rueckseiten-Auswahl beim Erfassen, wo sie bisher unabhaengig von der Teamgroesse angeboten wurde. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
441 lines
15 KiB
PHP
441 lines
15 KiB
PHP
<?php
|
|
|
|
ob_start();
|
|
|
|
include "functions.php";
|
|
require_once __DIR__ . "/app/ledger.php";
|
|
require_once __DIR__ . "/app/imports.php";
|
|
require_once __DIR__ . "/app/audit.php";
|
|
require_once __DIR__ . "/app/features.php";
|
|
app_require_csrf();
|
|
include "header.php";
|
|
include "headerline.php";
|
|
include "nav.php";
|
|
|
|
|
|
|
|
?>
|
|
<!-- Banner -->
|
|
<section id="banner">
|
|
<div class="content">
|
|
|
|
<?php
|
|
|
|
$pdo = app_db_pdo();
|
|
$saasUser = saas_current_user($pdo);
|
|
$tenantId = 0;
|
|
$hasAccess = false;
|
|
|
|
if ($saasUser !== null && saas_user_has_role(['owner', 'admin', 'treasurer'], $saasUser)) {
|
|
$tenantId = (int)$saasUser['tenant_id'];
|
|
$hasAccess = true;
|
|
}
|
|
|
|
if (!$hasAccess) {
|
|
echo "<h2>Kein Zugriff</h2>";
|
|
include "footer.php";
|
|
exit;
|
|
}
|
|
|
|
if (!app_feature_enabled($pdo, $tenantId, 'csv_import')) {
|
|
echo app_feature_notice_html('csv_import');
|
|
include "footer.php";
|
|
exit;
|
|
}
|
|
|
|
function csv_upload_dir(): string
|
|
{
|
|
return APP_ROOT . '/var/uploads';
|
|
}
|
|
|
|
/**
|
|
* @return array{0: ?string, 1: ?string} [Pfad, Fehlermeldung]
|
|
*/
|
|
function csv_prepare_upload(array $file): array
|
|
{
|
|
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
|
return [null, 'Fehler beim Hochladen der Datei.'];
|
|
}
|
|
|
|
if (($file['size'] ?? 0) <= 0 || $file['size'] > 5 * 1024 * 1024) {
|
|
return [null, 'Die CSV-Datei ist leer oder größer als 5 MB.'];
|
|
}
|
|
|
|
$originalName = (string)($file['name'] ?? '');
|
|
$extension = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
|
|
if ($extension !== 'csv') {
|
|
return [null, 'Es sind nur CSV-Dateien erlaubt.'];
|
|
}
|
|
|
|
$tmpName = (string)($file['tmp_name'] ?? '');
|
|
if (!is_uploaded_file($tmpName)) {
|
|
return [null, 'Die hochgeladene Datei konnte nicht verifiziert werden.'];
|
|
}
|
|
|
|
if (function_exists('finfo_open')) {
|
|
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
|
$mime = $finfo ? finfo_file($finfo, $tmpName) : false;
|
|
if ($finfo) {
|
|
finfo_close($finfo);
|
|
}
|
|
|
|
$allowedMimeTypes = [
|
|
'text/plain', 'text/csv', 'text/x-csv', 'application/csv',
|
|
'application/vnd.ms-excel', 'application/octet-stream',
|
|
];
|
|
if (is_string($mime) && !in_array($mime, $allowedMimeTypes, true)) {
|
|
return [null, 'Der Dateityp wurde nicht als CSV erkannt.'];
|
|
}
|
|
}
|
|
|
|
$uploadDir = csv_upload_dir();
|
|
if (!is_dir($uploadDir)) {
|
|
@mkdir($uploadDir, 0700, true);
|
|
}
|
|
if (!is_dir($uploadDir) || !is_writable($uploadDir)) {
|
|
return [null, 'Der Upload-Ordner ist nicht beschreibbar.'];
|
|
}
|
|
|
|
$targetFile = $uploadDir . '/paypal_' . date('Ymd_His') . '_' . bin2hex(random_bytes(8)) . '.csv';
|
|
if (!move_uploaded_file($tmpName, $targetFile)) {
|
|
return [null, 'Die CSV-Datei konnte nicht gespeichert werden.'];
|
|
}
|
|
|
|
return [$targetFile, null];
|
|
}
|
|
|
|
function csv_status_label(string $status): string
|
|
{
|
|
return match ($status) {
|
|
'matched' => 'Wird importiert',
|
|
'duplicate' => 'Bereits vorhanden',
|
|
'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 = 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);
|
|
$rows = imports_parse_csv($csvFile);
|
|
|
|
$pdo->beginTransaction();
|
|
$batchId = imports_create_batch(
|
|
$pdo,
|
|
$tenantId,
|
|
basename((string)$_FILES["csv_file"]["name"]),
|
|
(string)$checksum,
|
|
$saasUser['user_id'] ?? null
|
|
);
|
|
|
|
foreach ($rows as $row) {
|
|
$amountCents = imports_normalize_amount($row['amount_raw']);
|
|
$timestamp = $row['date_raw'] !== '' ? strtotime($row['date_raw']) : false;
|
|
|
|
if ($amountCents === null || $timestamp === false) {
|
|
imports_store_row($pdo, $batchId, $row['row_number'], null, $row['raw_name'], $amountCents ?? 0, date('Y-m-d H:i:s', $timestamp ?: time()), 'invalid', $row['raw']);
|
|
continue;
|
|
}
|
|
|
|
$bookedAt = date('Y-m-d H:i:s', $timestamp);
|
|
$participant = imports_find_participant($pdo, $tenantId, $row['raw_name']);
|
|
|
|
if ($participant === null) {
|
|
imports_store_row($pdo, $batchId, $row['row_number'], null, $row['raw_name'], $amountCents, $bookedAt, 'unmatched', $row['raw']);
|
|
continue;
|
|
}
|
|
|
|
if (imports_is_duplicate($pdo, $tenantId, $participant['participant_id'], $amountCents, date('Y-m-d', $timestamp))) {
|
|
imports_store_row($pdo, $batchId, $row['row_number'], $participant['participant_id'], $row['raw_name'], $amountCents, $bookedAt, 'duplicate', $row['raw']);
|
|
continue;
|
|
}
|
|
|
|
imports_store_row($pdo, $batchId, $row['row_number'], $participant['participant_id'], $row['raw_name'], $amountCents, $bookedAt, 'matched', $row['raw']);
|
|
}
|
|
|
|
$pdo->commit();
|
|
$vorschauBatchId = $batchId;
|
|
$meldung = 'CSV-Datei wurde eingelesen. Bitte die Vorschau prüfen.';
|
|
} catch (Throwable $e) {
|
|
if ($pdo->inTransaction()) {
|
|
$pdo->rollBack();
|
|
}
|
|
$fehler = 'Die CSV-Datei konnte nicht verarbeitet werden.';
|
|
} 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 = $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']]);
|
|
$meldung = $ergebnis['imported'] . ' Einzahlungen wurden importiert.';
|
|
} catch (Throwable $e) {
|
|
$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;
|
|
}
|
|
}
|
|
|
|
?>
|
|
|
|
<h2>CSV-Import</h2>
|
|
|
|
<?php if ($meldung !== null): ?>
|
|
<div class="hint-box success"><p><?php echo saas_html($meldung); ?></p></div>
|
|
<?php endif; ?>
|
|
<?php if ($fehler !== null): ?>
|
|
<div class="hint-box error"><p><?php echo saas_html($fehler); ?></p></div>
|
|
<?php endif; ?>
|
|
|
|
<?php if ($vorschau !== null): ?>
|
|
<h3>Vorschau: <?php echo saas_html($vorschau['batch']['original_filename']); ?></h3>
|
|
<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>
|
|
|
|
<div class="table-wrapper">
|
|
<table class="admin-table">
|
|
<tr>
|
|
<th>Zeile</th>
|
|
<th>Name (CSV)</th>
|
|
<th>Zugeordnetes Mitglied</th>
|
|
<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><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"]); ?>">
|
|
<?php echo app_csrf_field(); ?>
|
|
<input type="hidden" name="aktion" value="importieren">
|
|
<input type="hidden" name="batch_id" value="<?php echo (int)$vorschau['batch']['id']; ?>">
|
|
<button type="submit">Import bestätigen</button>
|
|
</form>
|
|
<?php endif; ?>
|
|
<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">
|
|
<label for="csv_file">CSV-Datei auswählen:</label>
|
|
<input type="file" name="csv_file" accept=".csv" required>
|
|
<button type="submit">Datei hochladen</button>
|
|
</form>
|
|
|
|
</div>
|
|
</section>
|
|
|
|
|
|
<?php include "footer.php"; ?>
|