Files
deskhub/DeskHub-Webseite/staff/import_osticket.php
T
2026-10-19 18:06:00 +00:00

396 lines
24 KiB
PHP

<?php
require_once __DIR__ . '/../includes/bootstrap.php';
require_once __DIR__ . '/../includes/osticket_import_passes.php';
$staff = require_admin();
$pdo = get_db();
if (!empty($_POST['action'])) {
ob_start();
error_reporting(0);
@ini_set('display_errors', '0');
register_shutdown_function(function () {
$err = error_get_last();
if ($err && in_array($err['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR])) {
while (ob_get_level() > 0) ob_end_clean();
header('Content-Type: application/json');
echo json_encode(['ok' => false, 'error' => $err['message'] . ' in ' . basename($err['file']) . ':' . $err['line']]);
}
});
}
@ini_set('memory_limit', '1024M');
@set_time_limit(300);
function import_json(array $data): never
{
while (ob_get_level() > 0) ob_end_clean();
header('Content-Type: application/json');
echo json_encode($data, JSON_INVALID_UTF8_SUBSTITUTE);
exit;
}
function import_ajax_guard(): void
{
header('Content-Type: application/json');
if (!hash_equals($_SESSION['csrf_token'] ?? '', (string) ($_POST['csrf_token'] ?? ''))) {
// Ist eine Anfrage größer als post_max_size, verwirft PHP alle Formulardaten: dann fehlt auch das Token
import_json(['ok' => false, 'error' => 'Die Sitzung ist abgelaufen oder der Upload-Teil war größer als der Server erlaubt (post_max_size ' . ini_get('post_max_size') . '). Bitte die Seite neu laden und erneut versuchen.']);
}
}
$stateDir = osticket_state_dir();
/** PHP-Größenangabe ("2M", "128K", "1G") in Bytes. */
function import_ini_bytes(string $v): int
{
$v = trim($v);
if ($v === '' || $v === '-1') return PHP_INT_MAX;
$n = (float) $v;
return (int) match (strtolower(substr($v, -1))) { 'g' => $n * 1073741824, 'm' => $n * 1048576, 'k' => $n * 1024, default => $n };
}
/** Größe eines Upload-Teils: so groß wie der Server erlaubt (höchstens 5 MB, mit Reserve), damit der Upload auch bei kleinen Limits (z. B. 2 MB) klappt. */
function import_chunk_size(): int
{
$limit = min(import_ini_bytes((string) ini_get('upload_max_filesize')), import_ini_bytes((string) ini_get('post_max_size')));
$size = (int) ($limit * 0.8);
$size = intdiv($size, 65536) * 65536;
return max(131072, min(5 * 1048576, $size));
}
/** Verständliche Meldung zu einem PHP-Upload-Fehlercode. */
function import_upload_error(?array $file): string
{
$code = $file['error'] ?? UPLOAD_ERR_NO_FILE;
$limit = ini_get('upload_max_filesize') . ' / post_max_size ' . ini_get('post_max_size');
return match ($code) {
UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => 'Der Server lehnt den Upload-Teil ab, er ist größer als erlaubt (upload_max_filesize ' . $limit . '). Laden Sie die Seite neu und versuchen Sie es erneut, oder legen Sie die Datei per FTP in den DeskHub-Ordner (Option 1).',
UPLOAD_ERR_PARTIAL => 'Der Upload-Teil kam nur unvollständig an (Verbindung unterbrochen). Bitte erneut versuchen.',
UPLOAD_ERR_NO_FILE => 'Es kam kein Upload-Teil an.',
UPLOAD_ERR_NO_TMP_DIR => 'Auf dem Server fehlt der temporäre Ordner für Uploads (upload_tmp_dir). Bitte den Hoster fragen oder die Datei per FTP hochladen (Option 1).',
UPLOAD_ERR_CANT_WRITE => 'Der Server konnte den Upload-Teil nicht speichern (Speicherplatz oder Schreibrechte). Bitte die Datei per FTP hochladen (Option 1).',
default => 'Upload fehlgeschlagen (PHP-Fehlercode ' . $code . ').',
};
}
// ── Chunked Upload ─────────────────────────────────────────────────────────
if (($_POST['action'] ?? '') === 'upload_chunk') {
import_ajax_guard();
$chunk = $_FILES['chunk'] ?? null;
$idx = (int) ($_POST['chunk_index'] ?? 0);
$total = (int) ($_POST['total_chunks'] ?? 1);
$tmpFile = $stateDir . '/upload.sql';
if (!$chunk || $chunk['error'] !== UPLOAD_ERR_OK) import_json(['ok' => false, 'error' => import_upload_error($chunk)]);
$fh = @fopen($tmpFile, $idx === 0 ? 'wb' : 'ab');
if (!$fh) import_json(['ok' => false, 'error' => 'Die Datei konnte nicht gespeichert werden: Der Import-Ordner ist nicht beschreibbar (' . $stateDir . ').']);
$written = fwrite($fh, (string) file_get_contents($chunk['tmp_name']));
fclose($fh);
if ($written === false || $written < (int) filesize($chunk['tmp_name'])) import_json(['ok' => false, 'error' => 'Die Datei konnte nicht vollständig gespeichert werden (Speicherplatz voll?).']);
$done = ($idx + 1) >= $total;
if ($done) {
$_SESSION['import_sql_path'] = $tmpFile;
$_SESSION['import_filename'] = basename((string) ($_POST['filename'] ?? 'import.sql'));
@unlink($stateDir . '/state.json');
@unlink($stateDir . '/entries.jsonl');
@unlink($stateDir . '/entry_map.jsonl');
}
import_json(['ok' => true, 'done' => $done, 'chunk' => $idx]);
}
// ── Dateipfad ──────────────────────────────────────────────────────────────
if (($_POST['action'] ?? '') === 'use_path') {
verify_csrf();
$filename = basename(trim($_POST['sql_filename'] ?? ''));
$fullPath = realpath(__DIR__ . '/../' . $filename);
$rootDir = realpath(__DIR__ . '/..');
if ($filename === '') {
flash('error', 'Bitte den Dateinamen der SQL-Datei eintragen (das graue Beispiel im Feld ist nur ein Platzhalter).'); redirect('/staff/import_osticket.php');
}
if (!$fullPath || strpos($fullPath, $rootDir) !== 0 || !is_file($fullPath)) {
flash('error', 'Datei nicht gefunden: ' . $filename . '. Sie muss im DeskHub-Hauptordner auf dem Server liegen (dort, wo auch index.php liegt).'); redirect('/staff/import_osticket.php');
}
$_SESSION['import_sql_path'] = $fullPath;
$_SESSION['import_filename'] = $filename;
foreach (['state.json', 'entries.jsonl', 'entry_map.jsonl'] as $f) @unlink($stateDir . '/' . $f);
redirect('/staff/import_osticket.php?step=analyze');
}
// ── Analyse ────────────────────────────────────────────────────────────────
if (($_POST['action'] ?? '') === 'analyze_chunk') {
import_ajax_guard();
$tmp = $_SESSION['import_sql_path'] ?? '';
if (!$tmp || !file_exists($tmp)) import_json(['ok' => false, 'error' => 'Datei fehlt']);
try {
$res = osticket_analyze_step($tmp, $stateDir, (int) ($_POST['byte_offset'] ?? 0));
} catch (Throwable $e) {
import_json(['ok' => false, 'error' => $e->getMessage()]);
}
import_json(['ok' => true, 'done' => $res['done'], 'byte_offset' => $res['offset'], 'file_size' => filesize($tmp), 'counts' => $res['counts']]);
}
// ── Import starten ─────────────────────────────────────────────────────────
if (($_POST['action'] ?? '') === 'start') {
verify_csrf();
$allowed = ['users', 'structure', 'tickets', 'threads', 'attachments'];
$_SESSION['import_what'] = array_values(array_intersect($allowed, (array) ($_POST['import'] ?? [])));
redirect('/staff/import_osticket.php?step=run');
}
// ── Reset ──────────────────────────────────────────────────────────────────
if (($_POST['action'] ?? '') === 'reset_import') {
import_ajax_guard();
$type = $_POST['reset_type'] ?? 'all';
if ($type === 'threads_only') {
$pdo->exec('DELETE FROM ticket_attachments');
$pdo->exec('DELETE FROM ticket_threads');
} else {
$pdo->exec('DELETE FROM ticket_attachments');
$pdo->exec('DELETE FROM ticket_threads');
$pdo->exec('DELETE FROM ticket_ratings');
$pdo->exec('DELETE FROM tickets');
$pdo->exec('DELETE FROM users WHERE id NOT IN (SELECT DISTINCT user_id FROM tickets WHERE user_id IS NOT NULL)');
}
@unlink($stateDir . '/entry_map.jsonl');
audit_log($staff['id'], 'Import-Reset: ' . $type);
import_json(['ok' => true]);
}
// ── Import-Schritt ─────────────────────────────────────────────────────────
if (($_POST['action'] ?? '') === 'import_chunk') {
import_ajax_guard();
$pass = (string) ($_POST['pass'] ?? '');
$offset = (int) ($_POST['offset'] ?? 0);
if (!in_array($pass, ['users', 'structure', 'tickets', 'threads', 'attachments'], true)) import_json(['ok' => false, 'error' => 'Unbekannter Durchgang']);
if ($pass === 'threads' && $offset === 0) @unlink($stateDir . '/entry_map.jsonl');
try {
$res = osticket_import_step($pdo, $stateDir, $pass, $offset, (string) ($_SESSION['import_sql_path'] ?? ''));
} catch (Throwable $e) {
import_json(['ok' => false, 'error' => $e->getMessage()]);
}
$importWhat = $_SESSION['import_what'] ?? [];
if ($res['done'] && $pass === end($importWhat)) {
$tmp = $_SESSION['import_sql_path'] ?? '';
if ($tmp && file_exists($tmp) && str_starts_with($tmp, $stateDir)) @unlink($tmp);
osticket_state_clear($stateDir);
unset($_SESSION['import_sql_path'], $_SESSION['import_what'], $_SESSION['import_filename']);
audit_log($staff['id'], 'osTicket Import abgeschlossen');
}
import_json(['ok' => true] + $res);
}
// ── Seite ──────────────────────────────────────────────────────────────────
$step = $_GET['step'] ?? '1';
$sqlPath = $_SESSION['import_sql_path'] ?? '';
$filename = $_SESSION['import_filename'] ?? '';
$fileSize = $sqlPath && file_exists($sqlPath) ? filesize($sqlPath) : 0;
$pageTitle = 'osTicket Import';
$activePage = 'settings';
require __DIR__ . '/_header.php';
?>
<div class="topbar">
<h1>osTicket Import</h1>
<a href="/staff/settings.php" class="btn secondary">← Einstellungen</a>
</div>
<div style="display:flex;gap:0;margin-bottom:24px;border-bottom:2px solid var(--border)">
<?php foreach(['1'=>'Hochladen','analyze'=>'Analysieren','2'=>'Optionen','run'=>'Import'] as $s=>$l): ?>
<div style="padding:10px 18px;font-size:.84rem;font-weight:<?=$step===$s?'700':'400'?>;color:<?=$step===$s?'var(--primary)':'var(--muted)'?>;border-bottom:2px solid <?=$step===$s?'var(--primary)':'transparent'?>;margin-bottom:-2px"><?=$l?></div>
<?php endforeach; ?>
</div>
<?php if ($step === '1'): ?>
<div class="card" style="max-width:620px">
<h3>SQL-Datei hochladen oder Pfad angeben</h3>
<p class="muted" style="font-size:.85rem">Unterstützt werden SQL-Dumps von osTicket 1.10 bis 1.18 (phpMyAdmin-Export oder <code>mysqldump</code>, auch mit anderem Tabellenpräfix als <code>ost_</code>). Importiert werden Kunden, Abteilungen, Hilfethemen, Tickets, Nachrichten und Anhänge.</p>
<p style="font-weight:600;font-size:.88rem;margin-bottom:8px">Option 1: Datei in den DeskHub-Ordner legen (für große Dateien empfohlen)</p>
<form method="post" style="display:flex;gap:8px;margin-bottom:20px">
<?= csrf_field() ?>
<input type="hidden" name="action" value="use_path">
<input type="text" name="sql_filename" placeholder="helpdesk.sql" style="flex:1">
<button type="submit" class="btn">Verwenden →</button>
</form>
<hr style="border:none;border-top:1px solid var(--border);margin-bottom:20px">
<p style="font-weight:600;font-size:.88rem;margin-bottom:8px">Option 2: Direkt hochladen (in Teilen von <?= round(import_chunk_size() / 1048576, 1) ?> MB, passend zum Limit dieses Servers)</p>
<input type="file" id="sql-file-input" accept=".sql" style="margin-bottom:10px">
<div id="upload-progress" style="display:none;margin-bottom:10px">
<div style="background:var(--border);border-radius:20px;height:10px;overflow:hidden;margin-bottom:5px">
<div id="upload-bar" style="background:var(--primary);height:10px;width:0%;border-radius:20px;transition:width .2s"></div>
</div>
<div id="upload-status" class="muted" style="font-size:.82rem"></div>
</div>
<button onclick="startChunkedUpload()" class="btn secondary" id="upload-btn">Hochladen →</button>
<script>
var csrf = <?= json_encode(csrf_token()) ?>;
function startChunkedUpload() {
var file = document.getElementById('sql-file-input').files[0];
if (!file) { alert('Bitte eine Datei auswaehlen.'); return; }
var btn = document.getElementById('upload-btn');
btn.disabled = true;
document.getElementById('upload-progress').style.display = 'block';
var chunkSize = <?= import_chunk_size() ?>, totalChunks = Math.ceil(file.size/chunkSize), idx = 0;
function next() {
var blob = file.slice(idx*chunkSize, (idx+1)*chunkSize);
var fd = new FormData();
fd.append('action','upload_chunk'); fd.append('csrf_token',csrf);
fd.append('chunk',blob,file.name); fd.append('chunk_index',idx);
fd.append('total_chunks',totalChunks); fd.append('filename',file.name);
fetch('/staff/import_osticket.php',{method:'POST',body:fd})
.then(r=>r.text()).then(function(t){
var d; try { d = JSON.parse(t); } catch(e) { d = {ok:false,error:'Der Server hat den Upload-Teil nicht angenommen (vermutlich ist er größer als das Limit post_max_size ' + <?= json_encode((string) ini_get('post_max_size')) ?> + '). Laden Sie die Seite neu und versuchen Sie es erneut, oder legen Sie die Datei per FTP in den DeskHub-Ordner (Option 1).'}; }
if(!d.ok){alert('Fehler: '+d.error);btn.disabled=false;document.getElementById('upload-bar').style.background='#dc2626';return;}
idx++;
var pct=Math.round(idx/totalChunks*100);
document.getElementById('upload-bar').style.width=pct+'%';
document.getElementById('upload-status').textContent=pct+'% - '+Math.round(Math.min(idx*chunkSize,file.size)/1048576)+' von '+Math.round(file.size/1048576)+' MB';
if(d.done){
document.getElementById('upload-bar').style.background='#22c55e';
setTimeout(()=>{window.location.href='/staff/import_osticket.php?step=analyze';},400);
} else { next(); }
}).catch(e=>{alert('Fehler: '+e);btn.disabled=false;});
}
next();
}
</script>
</div>
<?php elseif ($step === 'analyze'): ?>
<div class="card" style="max-width:620px">
<h3>Analysiere: <?= e($filename) ?> (<?= round($fileSize/1024/1024,1) ?> MB)</h3>
<p class="muted" style="font-size:.85rem;margin-bottom:14px">Liest die Datei in 4-MB-Blöcken. Bei sehr großen Dateien (Anhänge, Sitzungsdaten) kann das einige Minuten dauern.</p>
<div style="background:var(--border);border-radius:20px;height:12px;overflow:hidden;margin-bottom:10px">
<div id="analyze-bar" style="background:var(--primary);height:12px;width:0%;border-radius:20px;transition:width .2s"></div>
</div>
<div id="analyze-status" class="muted" style="font-size:.82rem;margin-bottom:14px">Starte...</div>
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-bottom:16px">
<?php foreach(['users'=>'Kunden','tickets'=>'Tickets','threads'=>'Nachrichten','departments'=>'Abteilungen','topics'=>'Hilfethemen','attachments'=>'Anhänge'] as $k=>$l): ?>
<div style="text-align:center;background:var(--bg-soft);border-radius:8px;padding:10px">
<div id="cnt-<?=$k?>" style="font-size:1.5rem;font-weight:800;color:var(--primary)">0</div>
<div class="muted" style="font-size:.75rem"><?=$l?></div>
</div>
<?php endforeach; ?>
</div>
<a id="next-btn" href="/staff/import_osticket.php?step=2" class="btn" style="display:none">Weiter →</a>
</div>
<script>
var fileSize=<?=(int)$fileSize?>,byteOffset=0,csrf=<?=json_encode(csrf_token())?>;
function analyzeChunk(){
var fd=new FormData();
fd.append('action','analyze_chunk');fd.append('csrf_token',csrf);fd.append('byte_offset',byteOffset);
fetch('/staff/import_osticket.php',{method:'POST',body:fd})
.then(r=>r.json()).then(d=>{
if(!d.ok){alert('Fehler: '+d.error);return;}
byteOffset=d.byte_offset;
var pct=fileSize>0?Math.min(100,Math.round(d.byte_offset/fileSize*100)):100;
document.getElementById('analyze-bar').style.width=pct+'%';
document.getElementById('analyze-status').textContent=pct+'% - '+Math.round(d.byte_offset/1024/1024)+' MB';
for(var k in d.counts){var el=document.getElementById('cnt-'+k);if(el)el.textContent=Number(d.counts[k]).toLocaleString();}
if(d.done){
document.getElementById('analyze-bar').style.background='#22c55e';
document.getElementById('analyze-status').textContent='Analyse abgeschlossen!';
document.getElementById('next-btn').style.display='inline-flex';
} else { setTimeout(analyzeChunk,10); }
}).catch(e=>{console.error(e);alert('Netzwerkfehler: '+e);});
}
analyzeChunk();
</script>
<?php elseif ($step === '2'): ?>
<?php $ad = osticket_state_load($stateDir); $counts = osticket_counts($ad); ?>
<div class="card" style="max-width:620px">
<h3>Was soll importiert werden?</h3>
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-bottom:18px">
<?php foreach(['users'=>'Kunden','tickets'=>'Tickets','threads'=>'Nachrichten','departments'=>'Abteilungen','topics'=>'Hilfethemen','attachments'=>'Anhänge'] as $k=>$l): ?>
<div style="text-align:center;background:var(--bg-soft);border-radius:8px;padding:12px">
<div style="font-size:1.5rem;font-weight:800;color:var(--primary)"><?= number_format((int) $counts[$k], 0, ',', '.') ?></div>
<div class="muted" style="font-size:.75rem"><?=$l?></div>
</div>
<?php endforeach; ?>
</div>
<form method="post">
<?= csrf_field() ?>
<input type="hidden" name="action" value="start">
<?php foreach([
'users' => ['Kunden', 'Name und E-Mail-Adresse'],
'structure' => ['Abteilungen und Hilfethemen', 'Fehlende werden angelegt, gleichnamige vorhandene wiederverwendet. Tickets landen damit in der richtigen Abteilung.'],
'tickets' => ['Tickets', 'Betreff, Nummer, Status, Priorität, Abteilung, Hilfethema, zugewiesener Mitarbeiter (über gleiche E-Mail-Adresse), Zeitstempel. Gelöschte Tickets werden ausgelassen.'],
'threads' => ['Nachrichten', 'Kundennachrichten, Antworten, interne Notizen und Mitlesende'],
'attachments' => ['Anhänge (' . round($counts['attachment_bytes'] / 1048576, 1) . ' MB)', 'Dateien zu den Nachrichten, bis ' . round(OSTICKET_ATTACHMENT_MAX_BYTES / 1048576) . ' MB je Datei. Setzt die Nachrichten voraus.'],
] as $v => [$lb, $desc]): ?>
<label style="display:flex;align-items:flex-start;gap:10px;margin-bottom:10px;cursor:pointer;font-weight:400">
<input type="checkbox" name="import[]" value="<?=$v?>" <?= $v === 'attachments' && !$counts['attachments'] ? '' : 'checked' ?> style="width:auto;margin-top:2px">
<div><strong><?=$lb?></strong><div class="muted" style="font-size:.78rem"><?=$desc?></div></div>
</label>
<?php endforeach; ?>
<p class="muted" style="font-size:.78rem">Der Import kann gefahrlos wiederholt werden: Vorhandene Kunden und Ticketnummern werden übersprungen, bereits importierte Nachrichten nicht doppelt angelegt. Vorher ein Backup erstellen ist trotzdem empfehlenswert.</p>
<div class="btn-row" style="margin-top:16px;flex-wrap:wrap;gap:8px">
<button type="submit" class="btn">Import starten →</button>
<a href="/staff/import_osticket.php" class="btn secondary">Neu hochladen</a>
<button type="button" onclick="resetImport('threads_only')" class="btn secondary" style="border-color:#f59e0b;color:#92400e">Nur Nachrichten löschen</button>
<button type="button" onclick="resetImport('all')" class="btn secondary" style="border-color:var(--danger);color:var(--danger)">Alle Tickets löschen</button>
</div>
</form>
<script>
function resetImport(type){
if(!confirm(type==='threads_only'?'Alle Nachrichten und Anhänge löschen?':'ALLE Tickets, Nachrichten und Kunden ohne Ticket in DeskHub löschen?')) return;
var fd=new FormData();
fd.append('action','reset_import');fd.append('reset_type',type);fd.append('csrf_token',<?=json_encode(csrf_token())?>);
fetch('/staff/import_osticket.php',{method:'POST',body:fd}).then(r=>r.json()).then(d=>{if(d.ok){alert('Gelöscht.');location.reload();}});
}
</script>
</div>
<?php elseif ($step === 'run'): ?>
<?php $importWhat = $_SESSION['import_what'] ?? []; ?>
<div class="card" style="max-width:620px">
<h3>Import läuft...</h3>
<p class="muted" style="font-size:.85rem;margin-bottom:18px">Seite nicht schließen.</p>
<?php foreach(['users'=>'Kunden','structure'=>'Abteilungen und Hilfethemen','tickets'=>'Tickets','threads'=>'Nachrichten','attachments'=>'Anhänge'] as $p=>$l): if(!in_array($p,$importWhat)) continue; ?>
<div style="margin-bottom:14px">
<div style="display:flex;justify-content:space-between;margin-bottom:4px;font-size:.84rem"><span><?=$l?></span><span id="lbl-<?=$p?>" class="muted">Wartend...</span></div>
<div style="background:var(--border);border-radius:20px;height:10px;overflow:hidden">
<div id="bar-<?=$p?>" style="background:var(--primary);height:10px;width:0%;border-radius:20px;transition:width .3s"></div>
</div>
</div>
<?php endforeach; ?>
<div id="result" style="display:none;margin-top:20px"></div>
</div>
<script>
var passes=<?=json_encode(array_values($importWhat))?>,csrf=<?=json_encode(csrf_token())?>;
var totals={},skipped=0,passIdx=0,offset=0,notes={};
function runChunk(){
if(passIdx>=passes.length){showResult();return;}
var pass=passes[passIdx];
var fd=new FormData();
fd.append('action','import_chunk');fd.append('csrf_token',csrf);fd.append('pass',pass);fd.append('offset',offset);
fetch('/staff/import_osticket.php',{method:'POST',body:fd})
.then(r=>r.json()).then(d=>{
if(!d.ok){alert('Fehler: '+d.error);return;}
totals[pass]=(totals[pass]||0)+d.inserted;
skipped+=d.skipped||0;
offset=d.next_offset;
for(var k in (d.notes||{})) notes[pass+k]=d.notes[k];
var bar=document.getElementById('bar-'+pass),lbl=document.getElementById('lbl-'+pass);
if(bar)bar.style.width=d.done?'100%':Math.min(98,Math.max(5,(bar.dataset.p=(Number(bar.dataset.p||0)+1))))+'%';
if(lbl)lbl.textContent=(totals[pass]||0).toLocaleString()+' importiert';
if(d.done){
if(bar)bar.style.background='#22c55e';
if(lbl)lbl.textContent='✅ '+(totals[pass]||0).toLocaleString();
passIdx++;offset=0;setTimeout(runChunk,100);
} else { setTimeout(runChunk,30); }
}).catch(e=>alert('Netzwerkfehler: '+e));
}
function esc(s){var d=document.createElement('div');d.textContent=s;return d.innerHTML;}
function showResult(){
var r=document.getElementById('result');r.style.display='block';
var n=Object.keys(notes).map(function(k){return '<li>'+esc(notes[k])+'</li>';}).join('');
r.innerHTML='<div class="flash success">Fertig! Kunden: <strong>'+(totals.users||0).toLocaleString()+'</strong>, Tickets: <strong>'+(totals.tickets||0).toLocaleString()+'</strong>, Nachrichten: <strong>'+(totals.threads||0).toLocaleString()+'</strong>, Anhänge: <strong>'+(totals.attachments||0).toLocaleString()+'</strong>, Übersprungen: <strong>'+skipped.toLocaleString()+'</strong></div>'
+(n?'<ul style="font-size:.84rem;color:var(--muted)">'+n+'</ul>':'')
+'<div class="btn-row"><a href="/staff/tickets.php" class="btn">Tickets ansehen</a><a href="/staff/import_osticket.php" class="btn secondary">Erneut importieren</a></div>';
}
runChunk();
</script>
<?php endif; ?>
<?php require __DIR__ . '/_footer.php'; ?>