Files
skrift-programme/Webseite/skrift-job-manager/includes/admin-job-manager.php
Lucas Orth 0341c4eaea Initiale Ablage der umsortierten Skrift-Programme
Struktur: Docker/ (skrift-backend, signature-service), Webseite/
(skrift-configurator, skrift-job-manager), Produktion/ (skrift-produktion,
AutoRecover). Aufgeräumt: signature-to-svg entfernt, Backend-PayPal-Service
entfernt (PayPal läuft client-seitig im Configurator), Dev-/Test-Artefakte
und Deployment-Dokus bereinigt, .dockerignore repariert. CLAUDE.md mit
Architektur ergänzt. Secrets (.env, config.json) via .gitignore ausgeschlossen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 12:21:08 +02:00

2163 lines
105 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* Kundenauftrag-Verwaltung – Admin-Seite und REST-Endpunkte
*/
if (!defined('ABSPATH')) { exit; }
final class Skrift_Job_Manager {
const JOBS_KEY = 'skrift_konfigurator_job_templates';
const SETTINGS_KEY = 'skrift_job_manager_settings';
/** Auftragsstatus */
const STATUSES = [
'waiting' => 'Warten auf Daten',
'waiting_routine' => 'Warten auf Daten Routine',
'text_proposal' => 'Textvorschlag hinterlegt',
'submitted' => 'Daten ubermittelt',
'error' => 'Daten fehlerhaft',
'printing' => 'Druck abgeschlossen',
'shipped' => 'Versendet',
];
/** Sub-Auftragsstatus (ohne Routine-spezifische Status) */
const SUB_STATUSES = [
'waiting' => 'Warten auf Daten',
'submitted' => 'Daten ubermittelt',
'error' => 'Daten fehlerhaft',
'printing' => 'Druck abgeschlossen',
'shipped' => 'Versendet',
];
/** Formate (identisch mit Frontend) */
const FORMATS = [
'A4' => 'A4 (Hochformat, max. 18 Zeilen)',
'A6 Hochformat' => 'A6 Hochformat (max. 9 Zeilen)',
'A6 Querformat' => 'A6 Querformat (max. 6 Zeilen)',
];
public function __construct() {
add_action('admin_menu', [$this, 'add_admin_menu']);
add_action('rest_api_init', [$this, 'register_rest_routes']);
add_action('admin_post_skrift_job_create', [$this, 'handle_create_job']);
add_action('admin_post_skrift_job_delete', [$this, 'handle_delete_job']);
add_action('admin_post_skrift_job_trash', [$this, 'handle_trash_job']);
add_action('admin_post_skrift_job_restore', [$this, 'handle_restore_job']);
add_action('admin_post_skrift_job_purge', [$this, 'handle_purge_job']);
add_action('admin_post_skrift_job_bulk', [$this, 'handle_bulk_job']);
add_action('admin_post_skrift_job_settings', [$this, 'handle_save_settings']);
add_action('admin_post_skrift_job_update_status', [$this, 'handle_update_status']);
add_action('admin_post_skrift_sub_order_update_status', [$this, 'handle_update_sub_order_status']);
add_action('admin_post_skrift_sub_order_delete', [$this, 'handle_delete_sub_order']);
add_action('phpmailer_init', [$this, 'configure_smtp']);
add_action('skrift_cleanup_old_jobs', [$this, 'cleanup_old_jobs']);
// Cron-Job einplanen falls noch nicht vorhanden
if (!wp_next_scheduled('skrift_cleanup_old_jobs')) {
wp_schedule_event(time(), 'daily', 'skrift_cleanup_old_jobs');
}
}
// ─── Plugin-Einstellungen ────────────────────────────────────────────────────
public static function get_plugin_settings(): array {
return wp_parse_args(get_option(self::SETTINGS_KEY, []), [
'notification_email' => '',
'redirect_url' => '',
'portal_url' => '',
'smtp' => [
'host' => '',
'port' => 465,
'username' => '',
'password' => '',
'from_email' => '',
'from_name' => 'Skrift Handschriftmarketing',
],
'mail_templates' => [
'waiting' => [
'subject' => 'Ihr Auftrag {{order_id}} ist bereit - bitte Daten hochladen',
'body' => "Guten Tag,\n\nIhr Auftrag {{order_id}} wurde angelegt. Bitte laden Sie Ihre Daten uber den folgenden Link hoch:\n\n{{link}}\n\nBei Fragen stehen wir Ihnen gerne zur Verfugung.\n\nMit freundlichen Grussen\nIhr Skrift-Team",
],
'waiting_routine' => [
'subject' => 'Ihr Auftrag {{order_id}} - Daten jederzeit hochladbar',
'body' => "Guten Tag,\n\nIhr Auftrag {{order_id}} wurde als Routine-Auftrag angelegt. Sie konnen uber den folgenden Link jederzeit neue Datensatze hochladen:\n\n{{link}}\n\nJeder Upload wird als separater Unterauftrag erfasst und bearbeitet.\n\nMit freundlichen Grussen\nIhr Skrift-Team",
],
'text_proposal' => [
'subject' => 'Ihr Textvorschlag zu Auftrag {{order_id}} ist bereit',
'body' => "Guten Tag,\n\nWir haben einen Brieftext-Vorschlag fur Ihren Auftrag {{order_id}} erstellt.\n\nBitte rufen Sie Ihren Auftrag uber folgenden Link auf, pruefen Sie den Text und passen Sie ihn bei Bedarf an:\n\n{{link}}\n\nMit freundlichen Grussen\nIhr Skrift-Team",
],
'submitted' => [
'subject' => 'Ihre Daten zu Auftrag {{order_id}} sind eingegangen',
'body' => "Guten Tag,\n\nVielen Dank! Wir haben Ihre Daten zu Auftrag {{order_id}} erfolgreich erhalten und machen uns nun an die Arbeit.\n\nMit freundlichen Grussen\nIhr Skrift-Team",
],
'error' => [
'subject' => 'Hinweis zu Auftrag {{order_id}} - Bitte Daten korrigieren',
'body' => "Guten Tag,\n\nBei der Prufung Ihrer Daten zu Auftrag {{order_id}} ist ein Problem aufgetreten:\n\n{{reason}}\n\nBitte korrigieren Sie Ihre Daten und laden Sie diese erneut hoch:\n\n{{link}}\n\nMit freundlichen Grussen\nIhr Skrift-Team",
],
'printing' => [
'subject' => 'Auftrag {{order_id}} druckfertig - Ihre Freigabe wird benotigt',
'body' => "Guten Tag,\n\nIhre Briefe zu Auftrag {{order_id}} sind fertig gedruckt und bereit zum Versand.\n\nBitte erteilen Sie die Versandfreigabe in Ihrem Auftragsportal:\n\n{{link}}\n\nMit freundlichen Grussen\nIhr Skrift-Team",
],
'shipped' => [
'subject' => 'Auftrag {{order_id}} wurde versendet',
'body' => "Guten Tag,\n\nIhre Briefe zu Auftrag {{order_id}} wurden versendet.\n\nVielen Dank fur Ihren Auftrag!\n\nMit freundlichen Grussen\nIhr Skrift-Team",
],
],
]);
}
// ─── Admin-Menu ──────────────────────────────────────────────────────────────
public function add_admin_menu(): void {
add_menu_page(
'Skrift Auftraege',
'Skrift Auftraege',
'manage_options',
'skrift-auftraege',
[$this, 'render_page'],
'dashicons-edit-page',
30
);
}
// ─── REST-Routen ─────────────────────────────────────────────────────────────
public function register_rest_routes(): void {
register_rest_route('skrift/v1', '/job/verify', [
'methods' => 'GET',
'callback' => [$this, 'rest_verify_job'],
'permission_callback' => '__return_true',
]);
register_rest_route('skrift/v1', '/job/template', [
'methods' => 'GET',
'callback' => [$this, 'rest_download_template'],
'permission_callback' => '__return_true',
]);
register_rest_route('skrift/v1', '/job/notify', [
'methods' => 'POST',
'callback' => [$this, 'rest_notify_job'],
'permission_callback' => [$this, 'rest_nonce_permission'],
]);
register_rest_route('skrift/v1', '/job/customer-email', [
'methods' => 'POST',
'callback' => [$this, 'rest_update_customer_email'],
'permission_callback' => [$this, 'rest_nonce_permission'],
]);
register_rest_route('skrift/v1', '/job/approve-shipping', [
'methods' => 'POST',
'callback' => [$this, 'rest_approve_shipping'],
'permission_callback' => '__return_true',
]);
register_rest_route('skrift/v1', '/job/upload-file', [
'methods' => 'POST',
'callback' => [$this, 'rest_upload_file'],
'permission_callback' => [$this, 'rest_nonce_permission'],
]);
register_rest_route('skrift/v1', '/job/upload-signature', [
'methods' => 'POST',
'callback' => [$this, 'rest_upload_signature'],
'permission_callback' => [$this, 'rest_nonce_permission'],
]);
register_rest_route('skrift/v1', '/job/sub-order/notify', [
'methods' => 'POST',
'callback' => [$this, 'rest_notify_sub_order'],
'permission_callback' => [$this, 'rest_nonce_permission'],
]);
register_rest_route('skrift/v1', '/job/register-order', [
'methods' => 'POST',
'callback' => [$this, 'rest_register_order'],
'permission_callback' => [$this, 'rest_nonce_permission'],
]);
register_rest_route('skrift/v1', '/job/forward-files', [
'methods' => 'POST',
'callback' => [$this, 'rest_forward_files'],
'permission_callback' => [$this, 'rest_nonce_permission'],
]);
}
// ─── REST: Auftrag im Docker-Backend registrieren (gedruckte Briefe) ────────
public function rest_register_order($request) {
$body = $request->get_json_params();
if (empty($body['orderNumber'])) {
return new WP_Error('invalid_request', 'orderNumber fehlt', ['status' => 400]);
}
if (class_exists('Skrift_Konfigurator_Admin_Settings')) {
$settings = Skrift_Konfigurator_Admin_Settings::get_settings();
$api_url = rtrim($settings['backend_connection']['api_url'] ?? '', '/');
$api_token = $settings['backend_connection']['api_token'] ?? '';
} else {
$raw = get_option('skrift_konfigurator_settings', []);
$api_url = rtrim($raw['backend_connection']['api_url'] ?? '', '/');
$api_token = $raw['backend_connection']['api_token'] ?? '';
}
if (empty($api_url)) {
return new WP_Error('no_backend', 'Backend-URL nicht konfiguriert', ['status' => 500]);
}
$response = wp_remote_post(
$api_url . '/api/order/register',
[
'headers' => [
'Content-Type' => 'application/json',
'X-API-Token' => $api_token,
],
'body' => wp_json_encode($body),
'timeout' => 15,
'data_format' => 'body',
]
);
if (is_wp_error($response)) {
return new WP_Error('backend_error', $response->get_error_message(), ['status' => 502]);
}
$status = wp_remote_retrieve_response_code($response);
$data = json_decode(wp_remote_retrieve_body($response), true);
if ($status >= 400) {
return new WP_Error('backend_error', $data['message'] ?? 'Backend-Fehler', ['status' => $status]);
}
return rest_ensure_response($data);
}
// ─── REST: Hochgeladene Auftragsdateien in den Backend-Auftragsordner kopieren ──
// Adresse/Platzhalter-Excel, Briefdokument und Brief-Excel liegen bereits lokal
// (rest_upload_file) und werden hier zusätzlich ans Docker-Backend weitergereicht,
// damit sie im selben Auftragsordner wie die generierten Umschläge liegen.
public function rest_forward_files($request) {
$body = $request->get_json_params();
$job_id = sanitize_text_field($body['jobId'] ?? '');
$order_number = sanitize_text_field($body['orderNumber'] ?? '');
$sub_dir = sanitize_file_name($body['subDir'] ?? '');
if (empty($job_id) || empty($order_number)) {
return new WP_Error('invalid_request', 'jobId und orderNumber sind erforderlich', ['status' => 400]);
}
if (class_exists('Skrift_Konfigurator_Admin_Settings')) {
$settings = Skrift_Konfigurator_Admin_Settings::get_settings();
$api_url = rtrim($settings['backend_connection']['api_url'] ?? '', '/');
$api_token = $settings['backend_connection']['api_token'] ?? '';
} else {
$raw = get_option('skrift_konfigurator_settings', []);
$api_url = rtrim($raw['backend_connection']['api_url'] ?? '', '/');
$api_token = $raw['backend_connection']['api_token'] ?? '';
}
if (empty($api_url)) {
return new WP_Error('no_backend', 'Backend-URL nicht konfiguriert', ['status' => 500]);
}
$dir = self::get_job_dir($job_id) . (!empty($sub_dir) ? '/' . $sub_dir : '');
if (!is_dir($dir)) {
return new WP_REST_Response(['success' => true, 'forwarded' => []], 200);
}
// fileType (Frontend) => lokaler Dateiname-Präfix (siehe rest_upload_file)
$file_types = [
'adresse' => 'adresse',
'platzhalter' => 'platzhalter',
'brief' => 'brief',
'brief_excel' => 'brief_platzhalter',
];
$forwarded = [];
$errors = [];
foreach ($file_types as $type => $prefix) {
$matches = glob($dir . '/' . $prefix . '.*');
if (empty($matches)) continue;
$file_path = $matches[0];
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_file($finfo, $file_path);
finfo_close($finfo);
$boundary = wp_generate_password(24, false);
$filename = basename($file_path);
$mp = "--{$boundary}\r\n";
$mp .= "Content-Disposition: form-data; name=\"orderNumber\"\r\n\r\n{$order_number}\r\n";
$mp .= "--{$boundary}\r\n";
$mp .= "Content-Disposition: form-data; name=\"fileType\"\r\n\r\n{$type}\r\n";
$mp .= "--{$boundary}\r\n";
$mp .= "Content-Disposition: form-data; name=\"file\"; filename=\"" . $filename . "\"\r\n";
$mp .= "Content-Type: " . ($mime ?: 'application/octet-stream') . "\r\n\r\n";
$mp .= file_get_contents($file_path) . "\r\n";
$mp .= "--{$boundary}--\r\n";
$response = wp_remote_post($api_url . '/api/order/files', [
'headers' => [
'Content-Type' => 'multipart/form-data; boundary=' . $boundary,
'X-API-Token' => $api_token,
],
'body' => $mp,
// Große Brief-PDFs (mehrere Dutzend MB) brauchen länger als 30s für die Weiterleitung.
'timeout' => 180,
]);
if (is_wp_error($response)) {
$errors[] = "{$type}: " . $response->get_error_message();
continue;
}
$status = wp_remote_retrieve_response_code($response);
if ($status >= 400) {
$errors[] = "{$type}: HTTP {$status}";
continue;
}
$forwarded[] = $type;
}
return new WP_REST_Response(['success' => true, 'forwarded' => $forwarded, 'errors' => $errors], 200);
}
// ─── REST: Nonce-Prüfung für schreibende Endpunkte ───────────────────────────
public function rest_nonce_permission($request) {
$nonce = $request->get_header('X-WP-Nonce');
if ($nonce && wp_verify_nonce($nonce, 'wp_rest')) {
return true;
}
return new WP_Error('rest_forbidden', 'Ungültiger oder fehlender Nonce.', ['status' => 403]);
}
// ─── REST: Auftrag verifizieren ──────────────────────────────────────────────
public function rest_verify_job($request) {
$job_id = sanitize_text_field($request->get_param('id') ?? '');
if (empty($job_id)) {
return new WP_Error('missing_id', 'Auftragsnummer fehlt', ['status' => 400]);
}
$jobs = self::get_jobs();
if (!isset($jobs[$job_id])) {
return new WP_Error('not_found', 'Auftrag nicht gefunden', ['status' => 404]);
}
$job = $jobs[$job_id];
// Unteraufträge aufbereiten
$sub_orders_raw = $job['sub_orders'] ?? [];
$sub_orders_out = array_values(array_map(function($sub) {
return [
'id' => $sub['id'],
'status' => $sub['status'] ?? 'waiting',
'statusReason' => $sub['status_reason'] ?? '',
'statusUpdatedAt' => $sub['status_updated_at'] ?? '',
'createdAt' => $sub['created_at'] ?? '',
'orderNumber' => $sub['order_number'] ?? '',
'quantity' => (int) ($sub['quantity'] ?? 0),
];
}, $sub_orders_raw));
return new WP_REST_Response([
'success' => true,
'job' => [
'id' => $job['id'],
'produkt' => $job['produkt'],
'format' => $job['format'],
'menge' => (int) $job['menge'],
'kuvert' => $job['kuvert'],
'textservice' => (bool) $job['textservice'],
'signatur' => (int) ($job['signatur'] ?? 0),
'redirect_url' => $job['redirect_url'] ?? '',
'status' => $job['status'],
'statusReason' => $job['status_reason'] ?? '',
'statusUpdatedAt' => $job['status_updated_at'] ?? '',
'customerEmail' => $job['customer_email'] ?? '',
'proposedText' => $job['proposed_text'] ?? '',
'shippingToken' => ($job['status'] === 'printing') ? ($job['shipping_token'] ?? '') : '',
'subOrders' => $sub_orders_out,
],
], 200);
}
// ─── REST: Kunden-E-Mail aktualisieren ───────────────────────────────────────
public function rest_update_customer_email($request) {
$job_id = sanitize_text_field($request->get_param('jobId') ?? '');
$email = sanitize_email($request->get_param('email') ?? '');
$jobs = self::get_jobs();
if (empty($job_id) || !isset($jobs[$job_id])) {
return new WP_Error('not_found', 'Auftrag nicht gefunden', ['status' => 404]);
}
if (!empty($email) && !is_email($email)) {
return new WP_Error('invalid_email', 'Ungultige E-Mail-Adresse', ['status' => 400]);
}
$jobs[$job_id]['customer_email'] = $email;
update_option(self::JOBS_KEY, $jobs);
return new WP_REST_Response(['success' => true], 200);
}
// ─── REST: Versandfreigabe ───────────────────────────────────────────────────
public function rest_approve_shipping($request) {
$job_id = sanitize_text_field($request->get_param('jobId') ?? '');
$token = sanitize_text_field($request->get_param('token') ?? '');
$jobs = self::get_jobs();
if (empty($job_id) || !isset($jobs[$job_id])) {
return new WP_Error('not_found', 'Auftrag nicht gefunden', ['status' => 404]);
}
$job = $jobs[$job_id];
if (empty($token) || $token !== ($job['shipping_token'] ?? '')) {
return new WP_Error('invalid_token', 'Ungultiger Freigabe-Token', ['status' => 403]);
}
if ($job['status'] !== 'printing') {
return new WP_REST_Response(['success' => true, 'message' => 'Freigabe bereits erteilt.'], 200);
}
// Versandfreigabe-Timestamp speichern
$jobs[$job_id]['shipping_approved_at'] = current_time('mysql');
update_option(self::JOBS_KEY, $jobs);
// Admin benachrichtigen
$plugin_settings = self::get_plugin_settings();
$to_admin = !empty($plugin_settings['notification_email'])
? sanitize_email($plugin_settings['notification_email'])
: get_option('admin_email');
$subject = "[Skrift] Versandfreigabe erteilt: {$job_id}";
$message = "Der Kunde hat die Versandfreigabe fur Auftrag {$job_id} erteilt.\n\nBitte veranlassen Sie den Versand und setzen Sie den Status auf 'Versendet'.";
wp_mail($to_admin, $subject, $message, ['Content-Type: text/plain; charset=UTF-8']);
return new WP_REST_Response(['success' => true, 'message' => 'Versandfreigabe wurde erteilt. Wir wurden informiert.'], 200);
}
// ─── REST: Datei-Upload (Adressdaten / Platzhalter) ─────────────────────────
public function rest_upload_file($request) {
$job_id = sanitize_text_field($request->get_param('jobId') ?? '');
$file_type = sanitize_text_field($request->get_param('fileType') ?? '');
$sub_dir = sanitize_file_name($request->get_param('subDir') ?? '');
$jobs = self::get_jobs();
if (empty($job_id) || !isset($jobs[$job_id])) {
return new WP_Error('not_found', 'Auftrag nicht gefunden', ['status' => 404]);
}
$allowed_types = ['adresse', 'platzhalter', 'brief', 'brief_excel'];
if (!in_array($file_type, $allowed_types, true)) {
return new WP_Error('invalid_type', 'Ungültiger Dateityp', ['status' => 400]);
}
$files = $request->get_file_params();
$file = $files['file'] ?? null;
if (!$file || $file['error'] !== UPLOAD_ERR_OK) {
return new WP_Error('upload_error', 'Datei-Upload fehlgeschlagen', ['status' => 400]);
}
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_file($finfo, $file['tmp_name']);
finfo_close($finfo);
// MIME-Typen je nach Dateityp. Die Prüfung akzeptiert, wenn ENTWEDER der erkannte
// MIME-Typ ODER die Dateiendung erlaubt ist. finfo liefert je nach Server für die
// gleiche Datei unterschiedliche Werte (z.B. docx = application/zip, manche PDFs =
// application/octet-stream). Ohne diese Endungs-Fallback-Prüfung wurden gültige
// PDF-/DOC-/DOCX-Uploads still abgelehnt und kamen nie im Docker-Backend an.
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
if ($file_type === 'brief') {
$allowed_mimes = [
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/zip', // docx wird oft als zip erkannt
'application/octet-stream', // generischer Fallback mancher Server
];
$allowed_exts = ['pdf', 'doc', 'docx'];
if (!in_array($ext, $allowed_exts, true) && !in_array($mime, $allowed_mimes, true)) {
return new WP_Error('invalid_mime', 'Nur .pdf, .doc oder .docx erlaubt', ['status' => 400]);
}
if (!in_array($ext, $allowed_exts, true)) { $ext = 'pdf'; }
} else {
// Tabellendateien (adresse, platzhalter, brief_excel)
$allowed_mimes = [
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-excel',
'text/csv',
'text/plain',
'application/csv',
'application/zip', // xlsx wird oft als zip erkannt
'application/octet-stream',
];
$allowed_exts = ['xlsx', 'xls', 'csv'];
if (!in_array($ext, $allowed_exts, true) && !in_array($mime, $allowed_mimes, true)) {
return new WP_Error('invalid_mime', 'Nur .xlsx, .xls oder .csv erlaubt', ['status' => 400]);
}
if (!in_array($ext, $allowed_exts, true)) { $ext = 'xlsx'; }
}
// Unteraufträge: eigenes Unterverzeichnis je Bestellnummer, damit Uploads eines
// neuen Unterauftrags die Dateien des vorherigen nicht überschreiben.
$dir = self::get_job_dir($job_id) . (!empty($sub_dir) ? '/' . $sub_dir : '');
if (!wp_mkdir_p($dir)) {
return new WP_Error('dir_error', 'Verzeichnis konnte nicht erstellt werden', ['status' => 500]);
}
self::protect_directory($dir);
$filename = ($file_type === 'brief_excel' ? 'brief_platzhalter' : $file_type) . '.' . $ext;
$dest_path = $dir . '/' . $filename;
if (!move_uploaded_file($file['tmp_name'], $dest_path)) {
return new WP_Error('save_error', 'Datei konnte nicht gespeichert werden', ['status' => 500]);
}
return new WP_REST_Response(['success' => true, 'saved' => $filename], 200);
}
// ─── REST: Signatur hochladen ────────────────────────────────────────────────
public function rest_upload_signature($request) {
$job_id = sanitize_text_field($request->get_param('jobId') ?? '');
$index = max(0, (int) ($request->get_param('index') ?? 0));
$svg = $request->get_param('svg') ?? '';
$jobs = self::get_jobs();
if (empty($job_id) || !isset($jobs[$job_id])) {
return new WP_Error('not_found', 'Auftrag nicht gefunden', ['status' => 404]);
}
if (empty($svg) || strpos(trim($svg), '<svg') !== 0) {
return new WP_Error('invalid_svg', 'Ungültige SVG-Daten', ['status' => 400]);
}
// Nur SVG-Tags erlauben – rudimentäre Bereinigung
if (preg_match('/<script/i', $svg) || preg_match('/javascript:/i', $svg)) {
return new WP_Error('invalid_svg', 'Unsichere SVG-Inhalte', ['status' => 400]);
}
$dir = self::get_job_dir($job_id);
if (!wp_mkdir_p($dir)) {
return new WP_Error('dir_error', 'Verzeichnis konnte nicht erstellt werden', ['status' => 500]);
}
self::protect_directory($dir);
$filename = 'signatur-' . $index . '.svg';
file_put_contents($dir . '/' . $filename, $svg);
return new WP_REST_Response(['success' => true, 'saved' => $filename], 200);
}
// ─── REST: Unterauftrag anlegen und Admin benachrichtigen ────────────────────
public function rest_notify_sub_order($request) {
$job_id = sanitize_text_field($request->get_param('jobId') ?? '');
$order_number = sanitize_text_field($request->get_param('orderNumber') ?? '');
$quantity = max(0, (int) ($request->get_param('quantity') ?? 0));
$count_de = $request->get_param('countDE');
$count_abroad = $request->get_param('countAbroad');
$brief_text = sanitize_textarea_field($request->get_param('briefText') ?? '');
$jobs = self::get_jobs();
if (empty($job_id) || !isset($jobs[$job_id])) {
return new WP_Error('not_found', 'Auftrag nicht gefunden', ['status' => 404]);
}
if ($jobs[$job_id]['status'] !== 'waiting_routine') {
return new WP_Error('invalid_status', 'Unteraufträge nur bei Status „Warten auf Daten Routine" möglich', ['status' => 400]);
}
// Sub-Order-ID generieren: PARENT-01, -02, …
$existing = $jobs[$job_id]['sub_orders'] ?? [];
$next_num = str_pad(count($existing) + 1, 2, '0', STR_PAD_LEFT);
$sub_id = $job_id . '-' . $next_num;
$sub_order = [
'id' => $sub_id,
'status' => 'submitted',
'status_reason' => '',
'status_updated_at'=> current_time('mysql'),
'order_number' => $order_number,
'quantity' => $quantity,
'created_at' => current_time('mysql'),
];
$jobs[$job_id]['sub_orders'][] = $sub_order;
update_option(self::JOBS_KEY, $jobs);
// Brieftext speichern
if (!empty($brief_text)) {
$dir = self::get_job_dir($job_id) . '/' . sanitize_file_name($sub_id);
wp_mkdir_p($dir);
self::protect_directory($dir);
file_put_contents($dir . '/brieftext.txt', $brief_text);
}
// Admin benachrichtigen
$plugin_settings = self::get_plugin_settings();
$to = !empty($plugin_settings['notification_email'])
? sanitize_email($plugin_settings['notification_email'])
: get_option('admin_email');
$job = $jobs[$job_id];
$lines = [
'Ein neuer Unterauftrag wurde übermittelt.',
'',
'------------------------------------',
"Hauptauftrag: {$job_id}",
"Unterauftrag-ID: {$sub_id}",
"Bestellnummer: {$order_number}",
"Produkt: {$job['produkt']}",
"Menge: {$quantity}",
];
if ($count_de !== null && $count_abroad !== null) {
$lines[] = " Deutschland: {$count_de}";
$lines[] = " Drittlaender: {$count_abroad}";
}
$lines[] = '------------------------------------';
$lines[] = 'Übermittelt am: ' . date_i18n('d.m.Y') . ' um ' . date_i18n('H:i') . ' Uhr';
$lines[] = '';
$lines[] = '-- Skrift Auftragsmanager';
$sent = wp_mail($to, "[Skrift] Neuer Unterauftrag: {$sub_id}", implode("\r\n", $lines), ['Content-Type: text/plain; charset=UTF-8']);
// Kunden-E-Mail senden
$customer_email = sanitize_email($job['customer_email'] ?? '');
if (!empty($customer_email) && is_email($customer_email)) {
$settings = self::get_plugin_settings();
$templates = $settings['mail_templates'] ?? [];
$template = $templates['submitted'] ?? null;
if ($template && !empty($template['subject'])) {
$portal_url = !empty($settings['portal_url']) ? $settings['portal_url'] : site_url('/');
$order_link = add_query_arg('nr', rawurlencode($job_id), $portal_url);
$subject = str_replace('{{order_id}}', $sub_id, $template['subject']);
$body_raw = str_replace(['{{order_id}}', '{{link}}'], [$sub_id, esc_url($order_link)], $template['body']);
$html_body = nl2br(htmlspecialchars($body_raw, ENT_QUOTES, 'UTF-8'));
$html = '<!DOCTYPE html><html><head><meta charset="UTF-8"></head><body style="font-family:Arial,sans-serif;font-size:14px;line-height:1.6;color:#333;max-width:560px;margin:0 auto;padding:24px;">'
. '<div style="border-bottom:2px solid #1e3a6a;padding-bottom:12px;margin-bottom:20px;"><strong style="font-size:20px;color:#1e3a6a;">SKRIFT</strong></div>'
. $html_body . '</body></html>';
wp_mail($customer_email, $subject, $html, ['Content-Type: text/html; charset=UTF-8', 'Cc: hello@skrift.de']);
}
}
return new WP_REST_Response(['success' => true, 'subOrderId' => $sub_id, 'mailSent' => $sent], 200);
}
// ─── Admin: Unterauftrag-Status aktualisieren ─────────────────────────────────
public function handle_update_sub_order_status(): void {
if (!current_user_can('manage_options')) wp_die('Keine Berechtigung.');
check_admin_referer('skrift_sub_order_update_status');
$job_id = sanitize_text_field($_POST['job_id'] ?? '');
$sub_id = sanitize_text_field($_POST['sub_id'] ?? '');
$new_status= sanitize_text_field($_POST['new_status'] ?? '');
$reason = sanitize_textarea_field($_POST['status_reason'] ?? '');
if (!array_key_exists($new_status, self::SUB_STATUSES)) {
wp_redirect(add_query_arg(['sk_notice' => 'error', 'sk_tab' => 'auftraege'], admin_url('admin.php?page=skrift-auftraege')));
exit;
}
$jobs = self::get_jobs();
if (!isset($jobs[$job_id])) {
wp_redirect(add_query_arg(['sk_notice' => 'error', 'sk_tab' => 'auftraege'], admin_url('admin.php?page=skrift-auftraege')));
exit;
}
$sub_orders = &$jobs[$job_id]['sub_orders'];
foreach ($sub_orders as &$sub) {
if ($sub['id'] === $sub_id) {
$sub['status'] = $new_status;
$sub['status_reason'] = $reason;
$sub['status_updated_at'] = current_time('mysql');
break;
}
}
unset($sub);
update_option(self::JOBS_KEY, $jobs);
wp_redirect(add_query_arg(['sk_notice' => 'status_updated', 'sk_tab' => 'auftraege'], admin_url('admin.php?page=skrift-auftraege')));
exit;
}
// ─── Admin: Unterauftrag löschen ───────────────────────────────────────────
public function handle_delete_sub_order(): void {
if (!current_user_can('manage_options')) wp_die('Keine Berechtigung.');
check_admin_referer('skrift_sub_order_delete');
$job_id = sanitize_text_field($_POST['job_id'] ?? '');
$sub_id = sanitize_text_field($_POST['sub_id'] ?? '');
$jobs = self::get_jobs();
if (!isset($jobs[$job_id])) {
wp_redirect(add_query_arg(['sk_notice' => 'error', 'sk_tab' => 'auftraege'], admin_url('admin.php?page=skrift-auftraege')));
exit;
}
$jobs[$job_id]['sub_orders'] = array_values(array_filter(
$jobs[$job_id]['sub_orders'] ?? [],
fn($s) => $s['id'] !== $sub_id
));
update_option(self::JOBS_KEY, $jobs);
wp_redirect(add_query_arg(['sk_notice' => 'sub_deleted', 'sk_tab' => 'auftraege'], admin_url('admin.php?page=skrift-auftraege')));
exit;
}
// ─── REST: Admin-Benachrichtigung ────────────────────────────────────────────
public function rest_notify_job($request) {
$job_id = sanitize_text_field($request->get_param('jobId') ?? '');
$order_number = sanitize_text_field($request->get_param('orderNumber') ?? '');
$quantity = max(0, (int) ($request->get_param('quantity') ?? 0));
$count_de = $request->get_param('countDE');
$count_abroad = $request->get_param('countAbroad');
$brief_text = sanitize_textarea_field($request->get_param('briefText') ?? '');
$jobs = self::get_jobs();
if (empty($job_id) || !isset($jobs[$job_id])) {
return new WP_Error('not_found', 'Auftrag nicht gefunden', ['status' => 404]);
}
$job = $jobs[$job_id];
$plugin_settings = self::get_plugin_settings();
$to = !empty($plugin_settings['notification_email'])
? sanitize_email($plugin_settings['notification_email'])
: get_option('admin_email');
$kuvert_labels = [
'keine' => 'Keine',
'adresse' => 'Adresse',
'individueller_text' => 'Individueller Text',
];
$kuvert_label = $kuvert_labels[$job['kuvert']] ?? $job['kuvert'];
$lines = [];
$lines[] = 'Ein neuer Kundenauftrag wurde erfolgreich ubermittelt.';
$lines[] = '';
$lines[] = '------------------------------------';
$lines[] = "Auftragsnummer: {$job_id}";
$lines[] = "Bestellnummer: {$order_number}";
$lines[] = "Produkt: {$job['produkt']}";
if (!empty($job['format'])) { $lines[] = "Format: {$job['format']}"; }
$lines[] = "Kuvert: {$kuvert_label}";
$lines[] = '------------------------------------';
$lines[] = "Gesamtmenge: {$quantity}";
if ($count_de !== null && $count_abroad !== null) {
$lines[] = " Deutschland: {$count_de}";
$lines[] = " Drittlaender: {$count_abroad}";
} else {
$lines[] = ' (Laenderaufschluesselung nicht verfugbar)';
}
$lines[] = '------------------------------------';
$lines[] = 'Ubermittelt am: ' . date_i18n('d.m.Y') . ' um ' . date_i18n('H:i') . ' Uhr';
$lines[] = '';
$lines[] = '-- Skrift Auftragsmanager';
$sent = wp_mail($to, "[Skrift] Neuer Auftrag: {$job_id}", implode("\r\n", $lines), ['Content-Type: text/plain; charset=UTF-8']);
// Brieftext als Textdatei speichern
if (!empty($brief_text)) {
$dir = self::get_job_dir($job_id);
wp_mkdir_p($dir);
self::protect_directory($dir);
file_put_contents($dir . '/brieftext.txt', $brief_text);
}
// Bestellnummer im Auftrag speichern
$jobs = self::get_jobs();
if (isset($jobs[$job_id])) {
$jobs[$job_id]['order_number'] = $order_number;
update_option(self::JOBS_KEY, $jobs);
}
// Status auf 'submitted' setzen und Kunden-E-Mail senden
self::update_status($job_id, 'submitted');
return new WP_REST_Response(['success' => true, 'mailSent' => $sent], 200);
}
// ─── REST: Excel-Template herunterladen ──────────────────────────────────────
public function rest_download_template($request) {
$type = sanitize_text_field($request->get_param('type') ?? '');
$menge = max(1, (int) ($request->get_param('menge') ?? 10));
switch ($type) {
case 'adresse-standard':
$headers = ['Vorname', 'Nachname', 'Strasse', 'Hausnummer', 'PLZ', 'Ort', 'Land'];
$example = ['Max', 'Mustermann', 'Musterstrasse', '1', '12345', 'Berlin', 'Deutschland'];
$filename = 'adressliste-standard.xlsx';
break;
case 'adresse-frei':
$headers = ['Zeile 1', 'Zeile 2', 'Zeile 3', 'Zeile 4', 'Zeile 5'];
$example = ['Firma Muster GmbH', 'Frau Maxima Mustermann', 'Musterstrasse 1', '12345 Berlin', 'Deutschland'];
$filename = 'adressliste-freitext.xlsx';
break;
case 'individueller-text':
$headers = ['Kuverttext'];
$example = ['Sehr geehrte Damen und Herren, herzliche Grusse aus dem Hause Skrift'];
$filename = 'kuvert-individueller-text.xlsx';
break;
default:
return new WP_Error('invalid_type', 'Ungultiger Template-Typ', ['status' => 400]);
}
$rows = [$headers, $example];
for ($i = 1; $i < $menge; $i++) { $rows[] = array_fill(0, count($headers), ''); }
$xlsx_data = self::create_xlsx($rows);
header('Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
header('Content-Disposition: attachment; filename="' . $filename . '"');
header('Content-Length: ' . strlen($xlsx_data));
header('Cache-Control: no-cache, no-store, must-revalidate');
echo $xlsx_data; // phpcs:ignore
exit;
}
// ─── Admin: Seite rendern ────────────────────────────────────────────────────
public function render_page(): void {
if (!current_user_can('manage_options')) return;
$jobs = self::get_jobs();
$global = self::get_plugin_settings();
$notice = $_GET['sk_notice'] ?? '';
$active_tab = isset($_GET['sk_tab']) ? sanitize_text_field($_GET['sk_tab']) : 'auftraege';
$base_url = admin_url('admin.php?page=skrift-auftraege');
?>
<style>
.sk-job-wrap { max-width: 1200px; }
.sk-tab-nav { display:flex; gap:0; margin:0 0 20px; border-bottom:1px solid #ccd0d4; }
.sk-tab-nav a { padding:8px 16px; text-decoration:none; color:#23282d; border:1px solid transparent; border-bottom:none; margin-bottom:-1px; }
.sk-tab-nav a.active { background:#fff; border-color:#ccd0d4; color:#000; font-weight:600; }
.sk-job-section { background:#fff; padding:20px; margin:0 0 20px; border:1px solid #ccd0d4; box-shadow:0 1px 1px rgba(0,0,0,.04); }
.sk-job-section h2 { margin-top:0; padding-bottom:10px; border-bottom:1px solid #eee; }
.sk-job-grid { display:grid; grid-template-columns:1fr 1fr; gap:12px 24px; }
.sk-field-row { margin-bottom:0; }
.sk-field-row label { display:block; font-weight:600; margin-bottom:4px; font-size:13px; }
.sk-field-row input:not([type="checkbox"]), .sk-field-row select, .sk-field-row textarea { width:100%; box-sizing:border-box; }
.sk-field-row--full { grid-column:1/-1; }
.sk-job-table { width:100%; border-collapse:collapse; margin-top:10px; font-size:13px; }
.sk-job-table th, .sk-job-table td { padding:8px 10px; text-align:left; border-bottom:1px solid #ddd; vertical-align:top; }
.sk-job-table th { background:#f0f0f1; font-weight:600; white-space:nowrap; }
.sk-job-table tr:hover { background:#f9f9f9; }
.sk-badge { display:inline-block; padding:2px 8px; border-radius:12px; font-size:11px; font-weight:600; white-space:nowrap; }
.sk-badge-waiting { background:#fff3cd; color:#856404; }
.sk-badge-waiting-routine { background:#ffe0b2; color:#7c4a00; }
.sk-badge-text-proposal { background:#e8d5f5; color:#5b21b6; }
.sk-badge-submitted { background:#d1ecf1; color:#0c5460; }
.sk-badge-error { background:#f8d7da; color:#721c24; }
.sk-badge-printing { background:#d4edda; color:#155724; }
.sk-badge-shipped { background:#cce5ff; color:#004085; }
.sk-copy-btn { cursor:pointer; background:none; border:none; color:#0073aa; font-size:12px; padding:0 4px; }
.sk-copy-btn:hover { text-decoration:underline; }
.sk-status-form { display:flex; flex-direction:column; gap:4px; min-width:160px; }
.sk-status-row { display:flex; gap:4px; align-items:center; }
.sk-mail-template-wrap { border:1px solid #ddd; padding:16px; margin-bottom:16px; background:#fafafa; }
.sk-mail-template-wrap h3 { margin:0 0 12px; font-size:14px; }
.sk-mail-template-wrap .sk-mt-badge { font-size:11px; padding:2px 8px; border-radius:12px; font-weight:600; margin-left:8px; }
.sk-email-cell { font-size:12px; color:#555; max-width:140px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.sk-email-cell.empty { color:#bbb; font-style:italic; }
.sk-sub-status-form { display:flex; gap:4px; align-items:center; }
/* ─ Todo-Hervorhebung ─ */
tr.sk-todo { background:#f0fdf4 !important; }
tr.sk-todo:hover { background:#dcfce7 !important; }
/* ─ Aufklapp-Button ─ */
.sk-expand-btn { background:none; border:none; cursor:pointer; font-size:12px; padding:0 3px 0 0; color:#888; line-height:1; transition:transform .15s; display:inline-block; vertical-align:middle; }
.sk-expand-btn.is-open { transform:rotate(90deg); }
/* ─ Aufgeklappte Kind-Zeilen ─ */
.sk-child-row td { padding:3px 8px 3px 38px !important; font-size:11px; color:#555; background:#f8f9fa; border-bottom:none !important; }
.sk-child-row.sk-child-last td { border-bottom:2px solid #ddd !important; }
/* ─ Standalone Sub-Aufträge ─ */
.sk-standalone-sub td { font-size:12px; background:#f7f7f7; }
.sk-standalone-sub:hover td { background:#f0f0f0 !important; }
/* ─ Bulk-Action Leiste ─ */
.sk-bulk-bar { display:flex; align-items:center; gap:8px; margin-bottom:14px; padding:8px 12px; background:#f0f0f1; border:1px solid #ddd; border-radius:4px; }
/* ─ Status-Gruppen ─ */
.sk-job-group { border:1px solid #ddd; margin-bottom:10px; border-radius:4px; overflow:hidden; }
.sk-group-summary { padding:10px 16px; cursor:pointer; display:flex; align-items:center; gap:10px; background:#f8f9fa; font-weight:600; font-size:14px; list-style:none; user-select:none; }
.sk-group-summary::-webkit-details-marker { display:none; }
details[open] > .sk-group-summary { border-bottom:1px solid #ddd; }
.sk-group-count { background:#6c757d; color:#fff; border-radius:10px; padding:1px 8px; font-size:11px; font-weight:700; margin-left:auto; }
/* ─ Papierkorb-Icon ─ */
.sk-trash-btn { padding:3px 5px !important; min-height:0 !important; background:none !important; border:none !important; box-shadow:none !important; color:#999 !important; }
.sk-trash-btn:hover { color:#dc3232 !important; background:none !important; }
</style>
<script>
function skiftShowReason(sel) {
var jobId = sel.dataset.jobId;
var extraRow = document.getElementById('sk-extra-' + jobId);
var errorDiv = document.getElementById('sk-extra-error-' + jobId);
var proposalDiv = document.getElementById('sk-extra-proposal-' + jobId);
var showError = sel.value === 'error';
var showProposal = sel.value === 'text_proposal';
var showExtra = showError || showProposal;
if (extraRow) extraRow.style.display = showExtra ? '' : 'none';
if (errorDiv) errorDiv.style.display = showError ? '' : 'none';
if (proposalDiv) proposalDiv.style.display = showProposal ? '' : 'none';
}
function skiftShowSubReason(sel, subId) {
// Kein Erweiterungs-Row bei Sub-Aufträgen notwendig – Fehlergrund wird nach Speichern angezeigt
}
function skiftCreateStatusChange(sel) {
var proposalWrap = document.getElementById('sk-create-proposal-wrap');
var errorWrap = document.getElementById('sk-create-error-wrap');
if (proposalWrap) proposalWrap.style.display = sel.value === 'text_proposal' ? '' : 'none';
if (errorWrap) errorWrap.style.display = sel.value === 'error' ? '' : 'none';
}
// ─ Aufklappen / Todo-Hervorhebung ─────────────────────────────────────────
document.addEventListener('DOMContentLoaded', function() {
var SEEN_KEY = 'sk_seen_jobs';
function getSeen() {
try { return JSON.parse(localStorage.getItem(SEEN_KEY) || '[]'); } catch(e) { return []; }
}
function markSeen(id) {
var seen = getSeen();
if (seen.indexOf(id) === -1) { seen.push(id); localStorage.setItem(SEEN_KEY, JSON.stringify(seen)); }
}
var seen = getSeen();
// Todo-Hervorhebung: grün wenn noch nicht gesehen
document.querySelectorAll('[data-sk-todo="1"]').forEach(function(row) {
var id = row.dataset.skTodoId;
if (seen.indexOf(id) === -1) {
row.classList.add('sk-todo');
}
row.addEventListener('click', function(e) {
if (e.target.closest('form, input, select, button, a')) return;
markSeen(id);
row.classList.remove('sk-todo');
});
});
// Expand-Buttons für Unteraufträge
document.querySelectorAll('.sk-expand-btn').forEach(function(btn) {
btn.addEventListener('click', function(e) {
e.stopPropagation();
var target = document.getElementById(btn.dataset.target);
if (!target) return;
var open = target.style.display !== 'none';
target.style.display = open ? 'none' : '';
btn.classList.toggle('is-open', !open);
});
});
});
</script>
<div class="wrap sk-job-wrap">
<h1>Skrift Kundenauftraege</h1>
<?php if ($notice === 'created') : ?>
<div class="notice notice-success is-dismissible"><p>Auftrag erfolgreich angelegt.</p></div>
<?php elseif ($notice === 'deleted') : ?>
<div class="notice notice-success is-dismissible"><p>Auftrag geloscht.</p></div>
<?php elseif ($notice === 'trashed') : ?>
<div class="notice notice-success is-dismissible"><p>Auftrag in den Papierkorb verschoben.</p></div>
<?php elseif ($notice === 'restored') : ?>
<div class="notice notice-success is-dismissible"><p>Auftrag wiederhergestellt.</p></div>
<?php elseif ($notice === 'purged') : ?>
<div class="notice notice-success is-dismissible"><p>Auftrag endgültig gelöscht.</p></div>
<?php elseif ($notice === 'bulk_done') : ?>
<div class="notice notice-success is-dismissible"><p>Massenoperation abgeschlossen.</p></div>
<?php elseif ($notice === 'sub_deleted') : ?>
<div class="notice notice-success is-dismissible"><p>Unterauftrag gelöscht.</p></div>
<?php elseif ($notice === 'duplicate') : ?>
<div class="notice notice-error is-dismissible"><p>Diese Auftragsnummer existiert bereits.</p></div>
<?php elseif ($notice === 'settings_saved') : ?>
<div class="notice notice-success is-dismissible"><p>Einstellungen gespeichert.</p></div>
<?php elseif ($notice === 'status_updated') : ?>
<div class="notice notice-success is-dismissible"><p>Status aktualisiert.</p></div>
<?php endif; ?>
<!-- Tabs -->
<div class="sk-tab-nav">
<a href="<?php echo esc_url(add_query_arg('sk_tab', 'auftraege', $base_url)); ?>"
class="<?php echo $active_tab === 'auftraege' ? 'active' : ''; ?>">Auftraege (<?php echo count($jobs); ?>)</a>
<a href="<?php echo esc_url(add_query_arg('sk_tab', 'einstellungen', $base_url)); ?>"
class="<?php echo $active_tab === 'einstellungen' ? 'active' : ''; ?>">Einstellungen</a>
</div>
<?php if ($active_tab === 'auftraege') : ?>
<!-- Neuen Auftrag anlegen -->
<div class="sk-job-section">
<h2>Neuen Auftrag anlegen</h2>
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
<input type="hidden" name="action" value="skrift_job_create">
<?php wp_nonce_field('skrift_job_create'); ?>
<div class="sk-job-grid">
<div class="sk-field-row">
<label for="job_id">Auftragsnummer <span style="color:red">*</span></label>
<input type="text" id="job_id" name="job_id" placeholder="z.B. AUF-2026-001" required>
</div>
<div class="sk-field-row">
<label for="job_customer_email">E-Mail des Kunden</label>
<input type="email" id="job_customer_email" name="job_customer_email" placeholder="kunde@beispiel.de">
</div>
<div class="sk-field-row">
<label for="job_produkt">Produktart <span style="color:red">*</span></label>
<select id="job_produkt" name="job_produkt" required>
<option value="">– bitte wahlen –</option>
<option value="Handgeschriebene Briefe">Handgeschriebene Briefe</option>
<option value="Handgeschriebene Postkarten">Handgeschriebene Postkarten</option>
<option value="Gedruckte Briefe">Gedruckte Briefe</option>
</select>
</div>
<div class="sk-field-row">
<label for="job_format">Format</label>
<select id="job_format" name="job_format">
<option value="">– bitte wahlen –</option>
<?php foreach (self::FORMATS as $val => $lbl) : ?>
<option value="<?php echo esc_attr($val); ?>"><?php echo esc_html($lbl); ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="sk-field-row">
<label for="job_menge">Menge</label>
<input type="number" id="job_menge" name="job_menge" min="1" value="1">
</div>
<div class="sk-field-row">
<label for="job_kuvert">Kuvertbeschriftung</label>
<select id="job_kuvert" name="job_kuvert">
<option value="keine">Keine</option>
<option value="adresse">Adresse</option>
<option value="individueller_text">Individueller Text</option>
</select>
</div>
<div class="sk-field-row">
<label for="job_textservice">Textservice</label>
<select id="job_textservice" name="job_textservice">
<option value="0">Nein – Kunde liefert den Text</option>
<option value="1">Ja – Skrift schreibt den Text</option>
</select>
</div>
<div class="sk-field-row">
<label for="job_signatur">Signaturfelder</label>
<select id="job_signatur" name="job_signatur">
<option value="0">Keine Signatur</option>
<option value="1">1 Signatur</option>
<option value="2">2 Signaturen</option>
<option value="3">3 Signaturen</option>
</select>
</div>
<div class="sk-field-row sk-field-row--full">
<label for="job_initial_status">Anfangsstatus</label>
<select id="job_initial_status" name="job_initial_status" onchange="skiftCreateStatusChange(this)" style="max-width:calc(50% - 12px);">
<?php foreach (self::STATUSES as $skey => $slbl) : ?>
<option value="<?php echo esc_attr($skey); ?>" <?php selected('waiting', $skey); ?>><?php echo esc_html($slbl); ?></option>
<?php endforeach; ?>
</select>
</div>
<div id="sk-create-proposal-wrap" class="sk-field-row sk-field-row--full" style="display:none;">
<label for="job_initial_proposed_text">Textvorschlag</label>
<textarea id="job_initial_proposed_text" name="job_initial_proposed_text" rows="5"
placeholder="Brieftext-Vorschlag für den Kunden…"
style="font-family:monospace;"></textarea>
</div>
<div id="sk-create-error-wrap" class="sk-field-row sk-field-row--full" style="display:none;">
<label for="job_initial_reason">Fehlergrund</label>
<textarea id="job_initial_reason" name="job_initial_reason" rows="3"
placeholder="Beschreiben Sie den Fehler für den Kunden…"></textarea>
</div>
</div>
<div style="margin-top:20px;">
<?php submit_button('Auftrag anlegen', 'primary', 'submit', false); ?>
</div>
</form>
</div>
<!-- Bestehende Auftraege -->
<div class="sk-job-section">
<h2>Bestehende Auftraege</h2>
<?php
$active_jobs = array_filter($jobs, fn($j) => empty($j['deleted_at']));
$trashed_jobs = array_filter($jobs, fn($j) => !empty($j['deleted_at']));
$kuvert_labels = ['keine' => 'Keine', 'adresse' => 'Adresse', 'individueller_text' => 'Ind. Text'];
?>
<!-- Bulk-Action Form (hält nur Checkboxen + Leiste) -->
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" id="sk-bulk-form">
<input type="hidden" name="action" value="skrift_job_bulk">
<?php wp_nonce_field('skrift_job_bulk'); ?>
<div class="sk-bulk-bar">
<label style="display:flex;align-items:center;gap:5px;cursor:pointer;white-space:nowrap;">
<input type="checkbox" id="sk-select-all"
onchange="document.querySelectorAll('.sk-job-cb').forEach(cb=>cb.checked=this.checked)">
Alle
</label>
<select name="bulk_action" style="font-size:13px;">
<option value="">Aktion wählen</option>
<option value="trash">In Papierkorb verschieben</option>
</select>
<button type="submit" class="button button-small">Anwenden</button>
<span style="margin-left:auto;font-size:12px;color:#666;"><?php echo count($active_jobs); ?> Aufträge aktiv</span>
</div>
</form>
<?php if (empty($active_jobs) && empty($trashed_jobs)) : ?>
<p style="color:#666;">Noch keine Auftraege angelegt.</p>
<?php else : ?>
<?php
// Sub-Aufträge aller aktiven Jobs flach sammeln
$all_subs = [];
foreach ($active_jobs as $pjob) {
foreach ($pjob['sub_orders'] ?? [] as $sub) {
$all_subs[] = ['sub' => $sub, 'parent_id' => $pjob['id'], 'parent_job' => $pjob];
}
}
$status_groups = [
'pending' => [
'label' => 'Warten auf Daten / Offen',
'statuses' => ['waiting', 'waiting_routine', 'text_proposal', 'error'],
'sub_statuses' => ['waiting', 'error'],
'open' => true,
],
'submitted' => [
'label' => 'Daten übermittelt',
'statuses' => ['submitted'],
'sub_statuses' => ['submitted'],
'open' => true,
],
'printing' => [
'label' => 'Druck abgeschlossen',
'statuses' => ['printing'],
'sub_statuses' => ['printing'],
'open' => true,
],
'shipped' => [
'label' => 'Versandt',
'statuses' => ['shipped'],
'sub_statuses' => ['shipped'],
'open' => false,
],
];
foreach ($status_groups as $group_key => $group) :
$group_jobs = array_values(array_filter($active_jobs, fn($j) => in_array($j['status'] ?? 'waiting', $group['statuses'], true)));
$group_subs = array_values(array_filter($all_subs, fn($s) => in_array($s['sub']['status'] ?? 'waiting', $group['sub_statuses'], true)));
$gcount = count($group_jobs) + count($group_subs);
?>
<details <?php echo ($gcount > 0 && $group['open']) ? 'open' : ''; ?> class="sk-job-group" id="sk-group-<?php echo esc_attr($group_key); ?>">
<summary class="sk-group-summary">
<?php echo esc_html($group['label']); ?>
<span class="sk-group-count"><?php echo $gcount; ?></span>
</summary>
<?php if ($gcount === 0) : ?>
<p style="color:#999;padding:10px 16px;margin:0;font-size:13px;">Keine Aufträge in dieser Kategorie.</p>
<?php else : ?>
<table class="sk-job-table">
<thead>
<tr>
<th style="width:28px;"></th>
<th>Auftragsnummer</th>
<th>Produkt / Format</th>
<th>Menge</th>
<th>Kuvert</th>
<th>Sig.</th>
<th>E-Mail Kunde</th>
<th>Angelegt</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
<?php foreach ($group_jobs as $job) :
$status = $job['status'] ?? 'waiting';
$cust_email = $job['customer_email'] ?? '';
$sub_orders = $job['sub_orders'] ?? [];
$has_subs = !empty($sub_orders);
$is_todo = in_array($status, ['submitted']) || !empty($job['shipping_approved_at']);
?>
<tr data-sk-todo-id="<?php echo esc_attr($job['id']); ?>"
<?php echo $is_todo ? 'data-sk-todo="1"' : ''; ?>>
<td><input type="checkbox" form="sk-bulk-form" class="sk-job-cb" name="job_ids[]" value="<?php echo esc_attr($job['id']); ?>"></td>
<td>
<?php if ($has_subs) : ?>
<button class="sk-expand-btn" data-target="sk-ch-<?php echo esc_attr($job['id']); ?>" title="Unteraufträge anzeigen">▶</button>
<?php endif; ?>
<strong><?php echo esc_html($job['id']); ?></strong>
<button class="sk-copy-btn"
onclick="navigator.clipboard.writeText('<?php echo esc_js($job['id']); ?>').then(()=>this.textContent='ok').catch(()=>{})"
title="Kopieren">[kopieren]</button>
</td>
<td>
<?php echo esc_html($job['produkt']); ?>
<?php if (!empty($job['format'])) : ?>
<br><small style="color:#666;"><?php echo esc_html($job['format']); ?></small>
<?php endif; ?>
</td>
<td><?php echo esc_html($job['menge']); ?></td>
<td><?php echo esc_html($kuvert_labels[$job['kuvert']] ?? $job['kuvert']); ?></td>
<td><?php echo $job['signatur'] > 0 ? esc_html($job['signatur']) : '–'; ?></td>
<td>
<?php if ($cust_email) : ?>
<span class="sk-email-cell" title="<?php echo esc_attr($cust_email); ?>"><?php echo esc_html($cust_email); ?></span>
<?php else : ?>
<span class="sk-email-cell empty">–</span>
<?php endif; ?>
</td>
<td style="white-space:nowrap;"><?php echo esc_html(date_i18n('d.m.Y H:i', strtotime($job['created_at']))); ?></td>
<td>
<form id="sk-sf-<?php echo esc_attr($job['id']); ?>"
method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>"
class="sk-status-form">
<input type="hidden" name="action" value="skrift_job_update_status">
<input type="hidden" name="job_id" value="<?php echo esc_attr($job['id']); ?>">
<?php wp_nonce_field('skrift_job_update_status'); ?>
<div class="sk-status-row">
<select name="new_status"
onchange="skiftShowReason(this)"
data-job-id="<?php echo esc_attr($job['id']); ?>"
style="font-size:12px;max-width:150px;">
<?php foreach (self::STATUSES as $skey => $slbl) : ?>
<option value="<?php echo esc_attr($skey); ?>" <?php selected($status, $skey); ?>><?php echo esc_html($slbl); ?></option>
<?php endforeach; ?>
</select>
<button type="submit" class="button button-primary">Anwenden</button>
</div>
</form>
<?php
if (!empty($job['shipping_approved_at'])) : ?>
<div style="margin-top:4px;font-size:11px;color:#666;">
Versandfreigabe am <?php echo esc_html(date_i18n('d.m.Y H:i', strtotime($job['shipping_approved_at']))); ?>
</div>
<?php else :
$first_sub_date = null;
foreach ($sub_orders as $sub) {
if (!empty($sub['created_at'])) { $first_sub_date = $sub['created_at']; break; }
}
if ($first_sub_date) : ?>
<div style="margin-top:4px;font-size:11px;color:#666;">
Übermittelt am <?php echo esc_html(date_i18n('d.m.Y H:i', strtotime($first_sub_date))); ?>
</div>
<?php endif;
endif; ?>
</td>
<td>
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>"
onsubmit="return confirm('Auftrag <?php echo esc_js($job['id']); ?> in den Papierkorb?')">
<input type="hidden" name="action" value="skrift_job_trash">
<input type="hidden" name="job_id" value="<?php echo esc_attr($job['id']); ?>">
<?php wp_nonce_field('skrift_job_trash'); ?>
<button type="submit" class="button sk-trash-btn" title="In Papierkorb">
<span class="dashicons dashicons-trash" style="font-size:16px;width:16px;height:16px;line-height:1.3;"></span>
</button>
</form>
</td>
</tr>
<!-- Aufklapp-Zeile fur Fehlergrund / Textvorschlag -->
<tr id="sk-extra-<?php echo esc_attr($job['id']); ?>"
style="<?php echo ($status === 'error' || $status === 'text_proposal') ? '' : 'display:none;'; ?>">
<td colspan="10" style="padding:0 10px 14px;background:#fafafa;border-top:none;">
<div id="sk-extra-error-<?php echo esc_attr($job['id']); ?>"
style="<?php echo $status === 'error' ? '' : 'display:none;'; ?>">
<label style="font-size:12px;font-weight:600;margin:8px 0 4px;display:block;color:#721c24;">Fehlergrund fur den Kunden:</label>
<textarea name="status_reason"
form="sk-sf-<?php echo esc_attr($job['id']); ?>"
rows="3"
placeholder="Beschreiben Sie den Fehler…"
style="width:100%;font-size:13px;padding:8px;box-sizing:border-box;resize:vertical;border:1px solid #f5c6cb;border-radius:3px;background:#fff;"><?php echo esc_textarea($job['status_reason'] ?? ''); ?></textarea>
</div>
<div id="sk-extra-proposal-<?php echo esc_attr($job['id']); ?>"
style="<?php echo $status === 'text_proposal' ? '' : 'display:none;'; ?>">
<label style="font-size:12px;font-weight:600;margin:8px 0 4px;display:block;color:#5b21b6;">Textvorschlag fur den Kunden:</label>
<textarea name="proposed_text"
form="sk-sf-<?php echo esc_attr($job['id']); ?>"
rows="6"
placeholder="Brieftext-Vorschlag fur den Kunden eingeben…"
style="width:100%;font-size:13px;padding:8px;box-sizing:border-box;resize:vertical;font-family:monospace;border:1px solid #d8b4fe;border-radius:3px;background:#fff;"><?php echo esc_textarea($job['proposed_text'] ?? ''); ?></textarea>
</div>
</td>
</tr>
<?php if ($has_subs) : ?>
<tr class="sk-child-rows" id="sk-ch-<?php echo esc_attr($job['id']); ?>" style="display:none;">
<td colspan="10" style="padding:0;background:#f4f6f8;border-top:none;">
<?php
$sub_status_labels = self::SUB_STATUSES;
foreach ($sub_orders as $idx => $sub) :
$slbl = $sub_status_labels[$sub['status'] ?? 'waiting'] ?? ($sub['status'] ?? '');
$sdate = !empty($sub['created_at']) ? date_i18n('d.m.Y H:i', strtotime($sub['created_at'])) : '';
?>
<div style="display:flex;align-items:center;gap:16px;padding:4px 12px 4px 38px;font-size:12px;color:#444;<?php echo $idx > 0 ? 'border-top:1px solid #e8e8e8;' : ''; ?>">
<span>↳ <strong><?php echo esc_html($sub['id']); ?></strong></span>
<span style="color:#666;"><?php echo esc_html($slbl); ?></span>
<?php if ($sdate) : ?><span style="color:#999;"><?php echo esc_html($sdate); ?></span><?php endif; ?>
</div>
<?php endforeach; ?>
</td>
</tr>
<?php endif; ?>
<?php endforeach; ?>
<?php foreach ($group_subs as $entry) :
$sub = $entry['sub'];
$parent_id = $entry['parent_id'];
$pjob = $entry['parent_job'];
$sub_status = $sub['status'] ?? 'waiting';
$is_sub_todo = in_array($sub_status, ['submitted']);
$sub_email = $pjob['customer_email'] ?? '';
$sub_form_id = 'sk-sf-sub-' . esc_attr($sub['id']);
?>
<tr data-sk-todo-id="<?php echo esc_attr($sub['id']); ?>"
<?php echo $is_sub_todo ? 'data-sk-todo="1"' : ''; ?>>
<td><input type="checkbox" form="sk-bulk-form" class="sk-job-cb" name="sub_ids[]" value="<?php echo esc_attr($parent_id . ':' . $sub['id']); ?>"></td>
<td>
<strong><?php echo esc_html($sub['id']); ?></strong>
<button class="sk-copy-btn"
onclick="navigator.clipboard.writeText('<?php echo esc_js($sub['id']); ?>').then(()=>this.textContent='ok').catch(()=>{})"
title="Kopieren">[kopieren]</button>
<br><small style="color:#888;font-size:11px;">↳ <?php echo esc_html($parent_id); ?></small>
</td>
<td>
<?php echo esc_html($pjob['produkt'] ?? '–'); ?>
<?php if (!empty($pjob['format'])) : ?>
<br><small style="color:#666;"><?php echo esc_html($pjob['format']); ?></small>
<?php endif; ?>
</td>
<td><?php echo esc_html($sub['quantity'] ?? '–'); ?></td>
<td><?php echo esc_html($kuvert_labels[$pjob['kuvert'] ?? ''] ?? ($pjob['kuvert'] ?? '–')); ?></td>
<td><?php echo ($pjob['signatur'] ?? 0) > 0 ? esc_html($pjob['signatur']) : '–'; ?></td>
<td>
<?php if ($sub_email) : ?>
<span class="sk-email-cell" title="<?php echo esc_attr($sub_email); ?>"><?php echo esc_html($sub_email); ?></span>
<?php else : ?>
<span class="sk-email-cell empty">–</span>
<?php endif; ?>
</td>
<td style="white-space:nowrap;">
<?php echo !empty($sub['created_at']) ? esc_html(date_i18n('d.m.Y H:i', strtotime($sub['created_at']))) : '–'; ?>
</td>
<td>
<form id="<?php echo $sub_form_id; ?>"
method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>"
class="sk-status-form">
<input type="hidden" name="action" value="skrift_sub_order_update_status">
<input type="hidden" name="job_id" value="<?php echo esc_attr($parent_id); ?>">
<input type="hidden" name="sub_id" value="<?php echo esc_attr($sub['id']); ?>">
<?php wp_nonce_field('skrift_sub_order_update_status'); ?>
<div class="sk-status-row">
<select name="new_status"
onchange="skiftShowSubReason(this,'<?php echo esc_js($sub['id']); ?>')"
style="font-size:12px;max-width:150px;">
<?php foreach (self::SUB_STATUSES as $skey => $slbl) : ?>
<option value="<?php echo esc_attr($skey); ?>" <?php selected($sub_status, $skey); ?>><?php echo esc_html($slbl); ?></option>
<?php endforeach; ?>
</select>
<button type="submit" class="button button-primary">Anwenden</button>
</div>
</form>
</td>
<td>
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>"
onsubmit="return confirm('Unterauftrag <?php echo esc_js($sub['id']); ?> löschen?')">
<input type="hidden" name="action" value="skrift_sub_order_delete">
<input type="hidden" name="job_id" value="<?php echo esc_attr($parent_id); ?>">
<input type="hidden" name="sub_id" value="<?php echo esc_attr($sub['id']); ?>">
<?php wp_nonce_field('skrift_sub_order_delete'); ?>
<button type="submit" class="button sk-trash-btn" title="Löschen">
<span class="dashicons dashicons-trash" style="font-size:16px;width:16px;height:16px;line-height:1.3;"></span>
</button>
</form>
</td>
</tr>
<?php if ($sub_status === 'error' && !empty($sub['status_reason'])) : ?>
<tr style="background:#fff5f5;">
<td colspan="10" style="padding:4px 10px 10px 38px;">
<label style="font-size:12px;font-weight:600;color:#721c24;">Fehlergrund:</label>
<div style="font-size:12px;color:#721c24;"><?php echo esc_html($sub['status_reason']); ?></div>
</td>
</tr>
<?php endif; ?>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</details>
<?php endforeach; ?>
<!-- Papierkorb -->
<?php if (!empty($trashed_jobs)) : ?>
<details id="sk-trash-group" style="margin-top:16px;border:1px solid #ddd;border-radius:4px;overflow:hidden;">
<summary class="sk-group-summary" style="background:#fff5f5;">
<span class="dashicons dashicons-trash" style="color:#dc2626;font-size:16px;width:16px;height:16px;line-height:1.4;margin-right:2px;"></span>
Papierkorb
<span class="sk-group-count" style="background:#dc2626;"><?php echo count($trashed_jobs); ?></span>
</summary>
<table class="sk-job-table">
<thead>
<tr>
<th>Auftragsnummer</th>
<th>Produkt</th>
<th>Gelöscht am</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
<?php foreach ($trashed_jobs as $job) : ?>
<tr>
<td><strong><?php echo esc_html($job['id']); ?></strong></td>
<td><?php echo esc_html($job['produkt']); ?></td>
<td><?php echo esc_html(!empty($job['deleted_at']) ? date_i18n('d.m.Y H:i', strtotime($job['deleted_at'])) : '–'); ?></td>
<td style="display:flex;gap:6px;flex-wrap:wrap;">
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
<input type="hidden" name="action" value="skrift_job_restore">
<input type="hidden" name="job_id" value="<?php echo esc_attr($job['id']); ?>">
<?php wp_nonce_field('skrift_job_restore'); ?>
<button type="submit" class="button button-small">↩ Wiederherstellen</button>
</form>
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>"
onsubmit="return confirm('Auftrag <?php echo esc_js($job['id']); ?> endgültig löschen?')">
<input type="hidden" name="action" value="skrift_job_purge">
<input type="hidden" name="job_id" value="<?php echo esc_attr($job['id']); ?>">
<?php wp_nonce_field('skrift_job_purge'); ?>
<button type="submit" class="button button-small button-link-delete">Endgültig löschen</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</details>
<?php endif; ?>
<?php endif; ?>
</div>
<?php elseif ($active_tab === 'einstellungen') : ?>
<!-- Globale Einstellungen -->
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
<input type="hidden" name="action" value="skrift_job_settings">
<?php wp_nonce_field('skrift_job_settings'); ?>
<div class="sk-job-section">
<h2>Globale Einstellungen</h2>
<div class="sk-job-grid">
<div class="sk-field-row">
<label for="job_global_notification_email">Benachrichtigungs-E-Mail (Admin)</label>
<input type="email" id="job_global_notification_email" name="job_global_notification_email"
value="<?php echo esc_attr($global['notification_email']); ?>"
placeholder="<?php echo esc_attr(get_option('admin_email')); ?>">
<small>Eingehende Auftraege werden hier gemeldet. Leer = Admin-E-Mail.</small>
</div>
<div class="sk-field-row">
<label for="job_global_portal_url">Kunden-Portal-URL</label>
<input type="url" id="job_global_portal_url" name="job_global_portal_url"
value="<?php echo esc_attr($global['portal_url']); ?>"
placeholder="https://example.com/auftrag/">
<small>Seite mit dem Shortcode [skrift_kundenauftrag] – wird in E-Mails verlinkt.</small>
</div>
<div class="sk-field-row">
<label for="job_global_redirect_url">Weiterleitungs-URL nach Abschluss</label>
<input type="url" id="job_global_redirect_url" name="job_global_redirect_url"
value="<?php echo esc_attr($global['redirect_url']); ?>"
placeholder="https://example.com/danke">
<small>Kunde wird nach Datenubergabe hierhin weitergeleitet (mit ?orderNumber=…).</small>
</div>
</div>
</div>
<!-- SMTP-Einstellungen -->
<div class="sk-job-section">
<h2>SMTP-Einstellungen</h2>
<p style="color:#666;margin-top:-5px;">
Ausgehender Mailversand fur Kunden-E-Mails. Wenn kein Host angegeben ist, verwendet WordPress den Server-Standard (oft unzuverlassig).
</p>
<div class="sk-job-grid">
<div class="sk-field-row">
<label for="smtp_host">Postausgangsserver (Host)</label>
<input type="text" id="smtp_host" name="smtp[host]"
value="<?php echo esc_attr($global['smtp']['host'] ?? ''); ?>"
placeholder="z.B. mxe977.netcup.net">
</div>
<div class="sk-field-row">
<label for="smtp_port">Port</label>
<input type="number" id="smtp_port" name="smtp[port]"
value="<?php echo esc_attr($global['smtp']['port'] ?? 465); ?>"
min="1" max="65535" style="max-width:120px;">
<small>465 = SSL/TLS &nbsp;·&nbsp; 587 = STARTTLS</small>
</div>
<div class="sk-field-row">
<label for="smtp_username">Benutzername</label>
<input type="text" id="smtp_username" name="smtp[username]"
value="<?php echo esc_attr($global['smtp']['username'] ?? ''); ?>"
placeholder="noreply@example.com">
</div>
<div class="sk-field-row">
<label for="smtp_password">Passwort</label>
<input type="password" id="smtp_password" name="smtp[password]"
value="<?php echo esc_attr($global['smtp']['password'] ?? ''); ?>">
</div>
<div class="sk-field-row">
<label for="smtp_from_email">Absender-E-Mail (optional)</label>
<input type="email" id="smtp_from_email" name="smtp[from_email]"
value="<?php echo esc_attr($global['smtp']['from_email'] ?? ''); ?>"
placeholder="noreply@example.com">
<small>Leer = SMTP-Benutzername wird als Absenderadresse genutzt.</small>
</div>
<div class="sk-field-row">
<label for="smtp_from_name">Absender-Name</label>
<input type="text" id="smtp_from_name" name="smtp[from_name]"
value="<?php echo esc_attr($global['smtp']['from_name'] ?? 'Skrift Handschriftmarketing'); ?>">
</div>
</div>
</div>
<!-- Mail-Templates -->
<div class="sk-job-section">
<h2>E-Mail-Vorlagen an Kunden</h2>
<p style="color:#666;margin-top:-5px;">
Verfugbare Platzhalter: <code>{{order_id}}</code>, <code>{{link}}</code>,
<code>{{reason}}</code> (nur "Fehlerhaft"), <code>{{approve_link_button}}</code> (nur "Druck abgeschlossen").<br>
Farbige Hervorhebung: <code>[rot]Ihr Text hier[/rot]</code> &rarr; erscheint rot &amp; fett im E-Mail.
</p>
<?php
$templates = $global['mail_templates'] ?? [];
$tpl_labels = [
'waiting' => ['label' => 'Warten auf Daten', 'badge' => 'sk-badge-waiting', 'note' => 'Wird beim Anlegen des Auftrags gesendet (wenn E-Mail hinterlegt)'],
'waiting_routine' => ['label' => 'Warten auf Daten Routine','badge' => 'sk-badge-waiting-routine', 'note' => 'Wird gesendet wenn Status auf Routine gesetzt wird'],
'text_proposal' => ['label' => 'Textvorschlag hinterlegt','badge' => 'sk-badge-text-proposal', 'note' => 'Wird gesendet wenn Sie einen Textvorschlag hinterlegen'],
'submitted' => ['label' => 'Daten ubermittelt', 'badge' => 'sk-badge-submitted', 'note' => 'Wird nach Datenubergabe durch den Kunden gesendet'],
'error' => ['label' => 'Daten fehlerhaft', 'badge' => 'sk-badge-error', 'note' => 'Wird gesendet wenn Sie den Status auf Fehlerhaft setzen'],
'printing' => ['label' => 'Druck abgeschlossen', 'badge' => 'sk-badge-printing', 'note' => 'Enthalt Versandfreigabe-Button ({{approve_link_button}})'],
'shipped' => ['label' => 'Versendet', 'badge' => 'sk-badge-shipped', 'note' => 'Abschluss-Benachrichtigung'],
];
foreach ($tpl_labels as $tkey => $tinfo) :
$tpl = $templates[$tkey] ?? ['subject' => '', 'body' => ''];
?>
<div class="sk-mail-template-wrap">
<h3>
<span class="sk-badge <?php echo esc_attr($tinfo['badge']); ?>"><?php echo esc_html($tinfo['label']); ?></span>
<small style="font-weight:normal;color:#666;margin-left:8px;"><?php echo esc_html($tinfo['note']); ?></small>
</h3>
<div class="sk-field-row">
<label>Betreff</label>
<input type="text" name="mail_templates[<?php echo esc_attr($tkey); ?>][subject]"
value="<?php echo esc_attr($tpl['subject']); ?>">
</div>
<div class="sk-field-row">
<label>Text</label>
<textarea name="mail_templates[<?php echo esc_attr($tkey); ?>][body]"
rows="6" style="font-family:monospace;"><?php echo esc_textarea($tpl['body']); ?></textarea>
</div>
</div>
<?php endforeach; ?>
</div>
<?php submit_button('Einstellungen speichern', 'primary', 'submit', false); ?>
</form>
<?php endif; ?>
</div>
<?php
}
// ─── Admin: Auftrag erstellen ────────────────────────────────────────────────
public function handle_create_job(): void {
if (!current_user_can('manage_options')) wp_die('Keine Berechtigung.');
check_admin_referer('skrift_job_create');
$job_id = sanitize_text_field($_POST['job_id'] ?? '');
$produkt = sanitize_text_field($_POST['job_produkt'] ?? '');
$format = sanitize_text_field($_POST['job_format'] ?? '');
$menge = max(1, (int) ($_POST['job_menge'] ?? 1));
$kuvert = sanitize_text_field($_POST['job_kuvert'] ?? 'keine');
$textservice = ($_POST['job_textservice'] ?? '0') === '1';
$signatur = max(0, min(3, (int) ($_POST['job_signatur'] ?? 0)));
$customer_email = sanitize_email($_POST['job_customer_email'] ?? '');
$initial_status = sanitize_text_field($_POST['job_initial_status'] ?? 'waiting');
$initial_proposed_text = sanitize_textarea_field($_POST['job_initial_proposed_text'] ?? '');
$initial_reason = sanitize_textarea_field($_POST['job_initial_reason'] ?? '');
if (!array_key_exists($initial_status, self::STATUSES)) {
$initial_status = 'waiting';
}
$jobs = self::get_jobs();
if (isset($jobs[$job_id])) {
wp_redirect(add_query_arg(['sk_notice' => 'duplicate', 'sk_tab' => 'auftraege'], admin_url('admin.php?page=skrift-auftraege')));
exit;
}
$jobs[$job_id] = [
'id' => $job_id,
'produkt' => $produkt,
'format' => $format,
'menge' => $menge,
'kuvert' => $kuvert,
'textservice' => $textservice,
'signatur' => $signatur,
'sub_orders' => [],
'customer_email' => $customer_email,
'status' => $initial_status,
'status_reason' => ($initial_status === 'error') ? $initial_reason : '',
'status_updated_at' => current_time('mysql'),
'shipping_token' => ($initial_status === 'printing') ? wp_generate_password(32, false) : '',
'proposed_text' => ($initial_status === 'text_proposal') ? $initial_proposed_text : '',
'print_approved_at' => ($initial_status === 'submitted') ? current_time('mysql') : '',
'shipping_approved_at' => '',
'created_at' => current_time('mysql'),
];
update_option(self::JOBS_KEY, $jobs);
// Kunden-E-Mail senden (fur den gewahlten Anfangsstatus)
if (!empty($customer_email) && is_email($customer_email)) {
self::send_customer_email($job_id, $initial_status, $initial_reason);
}
wp_redirect(add_query_arg(['sk_notice' => 'created', 'sk_tab' => 'auftraege'], admin_url('admin.php?page=skrift-auftraege')));
exit;
}
// ─── Admin: Status aktualisieren ─────────────────────────────────────────────
public function handle_update_status(): void {
if (!current_user_can('manage_options')) wp_die('Keine Berechtigung.');
check_admin_referer('skrift_job_update_status');
$job_id = sanitize_text_field($_POST['job_id'] ?? '');
$new_status = sanitize_text_field($_POST['new_status'] ?? '');
$reason = sanitize_textarea_field($_POST['status_reason'] ?? '');
$proposed_text = sanitize_textarea_field($_POST['proposed_text'] ?? '');
if (!array_key_exists($new_status, self::STATUSES)) {
wp_redirect(add_query_arg(['sk_notice' => 'error', 'sk_tab' => 'auftraege'], admin_url('admin.php?page=skrift-auftraege')));
exit;
}
self::update_status($job_id, $new_status, $reason, $proposed_text);
wp_redirect(add_query_arg(['sk_notice' => 'status_updated', 'sk_tab' => 'auftraege'], admin_url('admin.php?page=skrift-auftraege')));
exit;
}
// ─── Admin: Auftrag loschen ───────────────────────────────────────────────────
public function handle_delete_job(): void {
if (!current_user_can('manage_options')) wp_die('Keine Berechtigung.');
check_admin_referer('skrift_job_delete');
$job_id = sanitize_text_field($_POST['job_id'] ?? '');
$jobs = self::get_jobs();
unset($jobs[$job_id]);
update_option(self::JOBS_KEY, $jobs);
wp_redirect(add_query_arg(['sk_notice' => 'deleted', 'sk_tab' => 'auftraege'], admin_url('admin.php?page=skrift-auftraege')));
exit;
}
// ─── Admin: Auftrag in Papierkorb verschieben ────────────────────────────────
public function handle_trash_job(): void {
if (!current_user_can('manage_options')) wp_die('Keine Berechtigung.');
check_admin_referer('skrift_job_trash');
$job_id = sanitize_text_field($_POST['job_id'] ?? '');
$jobs = self::get_jobs();
if (isset($jobs[$job_id])) {
$jobs[$job_id]['deleted_at'] = current_time('mysql');
update_option(self::JOBS_KEY, $jobs);
}
wp_redirect(add_query_arg(['sk_notice' => 'trashed', 'sk_tab' => 'auftraege'], admin_url('admin.php?page=skrift-auftraege')));
exit;
}
// ─── Admin: Auftrag aus Papierkorb wiederherstellen ──────────────────────────
public function handle_restore_job(): void {
if (!current_user_can('manage_options')) wp_die('Keine Berechtigung.');
check_admin_referer('skrift_job_restore');
$job_id = sanitize_text_field($_POST['job_id'] ?? '');
$jobs = self::get_jobs();
if (isset($jobs[$job_id])) {
$jobs[$job_id]['deleted_at'] = null;
update_option(self::JOBS_KEY, $jobs);
}
wp_redirect(add_query_arg(['sk_notice' => 'restored', 'sk_tab' => 'auftraege'], admin_url('admin.php?page=skrift-auftraege')));
exit;
}
// ─── Admin: Auftrag endgültig löschen ────────────────────────────────────────
public function handle_purge_job(): void {
if (!current_user_can('manage_options')) wp_die('Keine Berechtigung.');
check_admin_referer('skrift_job_purge');
$job_id = sanitize_text_field($_POST['job_id'] ?? '');
$jobs = self::get_jobs();
$dir = self::get_job_dir($job_id);
if (is_dir($dir)) {
self::delete_directory($dir);
}
unset($jobs[$job_id]);
update_option(self::JOBS_KEY, $jobs);
wp_redirect(add_query_arg(['sk_notice' => 'purged', 'sk_tab' => 'auftraege'], admin_url('admin.php?page=skrift-auftraege')));
exit;
}
// ─── Admin: Massenoperation ───────────────────────────────────────────────────
public function handle_bulk_job(): void {
if (!current_user_can('manage_options')) wp_die('Keine Berechtigung.');
check_admin_referer('skrift_job_bulk');
$action = sanitize_text_field($_POST['bulk_action'] ?? '');
$job_ids = array_map('sanitize_text_field', (array) ($_POST['job_ids'] ?? []));
$sub_ids = array_map('sanitize_text_field', (array) ($_POST['sub_ids'] ?? []));
$jobs = self::get_jobs();
// Oberaufträge
foreach ($job_ids as $job_id) {
if (!isset($jobs[$job_id])) continue;
if ($action === 'trash') {
$jobs[$job_id]['deleted_at'] = current_time('mysql');
}
}
// Unteraufträge (Format: "parent_id:sub_id")
foreach ($sub_ids as $entry) {
$parts = explode(':', $entry, 2);
if (count($parts) !== 2) continue;
[$parent_id, $sub_id] = $parts;
if (!isset($jobs[$parent_id])) continue;
if ($action === 'trash') {
$jobs[$parent_id]['sub_orders'] = array_values(array_filter(
$jobs[$parent_id]['sub_orders'] ?? [],
fn($s) => $s['id'] !== $sub_id
));
}
}
update_option(self::JOBS_KEY, $jobs);
wp_redirect(add_query_arg(['sk_notice' => 'bulk_done', 'sk_tab' => 'auftraege'], admin_url('admin.php?page=skrift-auftraege')));
exit;
}
// ─── Admin: Globale Einstellungen speichern ──────────────────────────────────
public function handle_save_settings(): void {
if (!current_user_can('manage_options')) wp_die('Keine Berechtigung.');
check_admin_referer('skrift_job_settings');
$raw_templates = $_POST['mail_templates'] ?? [];
$templates = [];
foreach (array_keys(self::STATUSES) as $skey) {
$templates[$skey] = [
'subject' => sanitize_text_field($raw_templates[$skey]['subject'] ?? ''),
'body' => sanitize_textarea_field($raw_templates[$skey]['body'] ?? ''),
];
}
$smtp_raw = $_POST['smtp'] ?? [];
$smtp = [
'host' => sanitize_text_field($smtp_raw['host'] ?? ''),
'port' => max(1, (int) ($smtp_raw['port'] ?? 465)),
'username' => sanitize_text_field($smtp_raw['username'] ?? ''),
'password' => sanitize_text_field($smtp_raw['password'] ?? ''),
'from_email' => sanitize_email($smtp_raw['from_email'] ?? ''),
'from_name' => sanitize_text_field($smtp_raw['from_name'] ?? 'Skrift Handschriftmarketing'),
];
update_option(self::SETTINGS_KEY, [
'notification_email' => sanitize_email($_POST['job_global_notification_email'] ?? ''),
'portal_url' => esc_url_raw($_POST['job_global_portal_url'] ?? ''),
'redirect_url' => esc_url_raw($_POST['job_global_redirect_url'] ?? ''),
'smtp' => $smtp,
'mail_templates' => $templates,
]);
wp_redirect(add_query_arg(['sk_notice' => 'settings_saved', 'sk_tab' => 'einstellungen'], admin_url('admin.php?page=skrift-auftraege')));
exit;
}
// ─── Hilfsmethoden ───────────────────────────────────────────────────────────
public static function get_jobs(): array {
$jobs = get_option(self::JOBS_KEY, []);
foreach ($jobs as &$job) {
// Backward-Kompatibilitat: alte Status-Werte normalisieren
if (!isset($job['status'])) $job['status'] = 'waiting';
if ($job['status'] === 'open') $job['status'] = 'waiting';
if ($job['status'] === 'completed') $job['status'] = 'submitted';
if (!isset($job['customer_email'])) $job['customer_email'] = '';
if (!isset($job['status_reason'])) $job['status_reason'] = '';
if (!isset($job['status_updated_at'])) $job['status_updated_at'] = '';
if (!isset($job['shipping_token'])) $job['shipping_token'] = '';
if (!isset($job['proposed_text'])) $job['proposed_text'] = '';
if (!isset($job['print_approved_at'])) $job['print_approved_at'] = '';
if (!isset($job['shipping_approved_at'])) $job['shipping_approved_at'] = '';
if (!isset($job['signatur'])) $job['signatur'] = 0;
if (!isset($job['sub_orders'])) $job['sub_orders'] = [];
if (!isset($job['order_number'])) $job['order_number'] = '';
}
return $jobs;
}
/**
* Status eines Auftrags aktualisieren und Kunden-E-Mail senden.
*/
public static function update_status(string $job_id, string $new_status, string $reason = '', string $proposed_text = ''): bool {
$jobs = self::get_jobs();
if (!isset($jobs[$job_id])) return false;
$jobs[$job_id]['status'] = $new_status;
$jobs[$job_id]['status_reason'] = $reason;
$jobs[$job_id]['status_updated_at'] = current_time('mysql');
// Textvorschlag speichern
if ($new_status === 'text_proposal') {
$jobs[$job_id]['proposed_text'] = $proposed_text;
}
// Druckfreigabe-Timestamp: Kunde hat Daten ubermittelt → Druck kann starten
if ($new_status === 'submitted' && empty($jobs[$job_id]['print_approved_at'])) {
$jobs[$job_id]['print_approved_at'] = current_time('mysql');
}
// Shipping-Token fur Versandfreigabe
if ($new_status === 'printing' && empty($jobs[$job_id]['shipping_token'])) {
$jobs[$job_id]['shipping_token'] = wp_generate_password(32, false);
}
update_option(self::JOBS_KEY, $jobs);
// Kunden-E-Mail versenden
self::send_customer_email($job_id, $new_status, $reason);
return true;
}
/** Backward-Compat-Alias */
public static function mark_completed(string $job_id): void {
self::update_status($job_id, 'submitted');
}
/**
* SMTP-Versand via phpmailer_init konfigurieren.
*/
public function configure_smtp($phpmailer): void {
$settings = self::get_plugin_settings();
$smtp = $settings['smtp'] ?? [];
if (empty($smtp['host'])) return;
$port = (int) ($smtp['port'] ?? 465);
$phpmailer->isSMTP();
$phpmailer->Host = $smtp['host'];
$phpmailer->SMTPAuth = true;
$phpmailer->Username = $smtp['username'];
$phpmailer->Password = $smtp['password'];
$phpmailer->SMTPSecure = ($port === 587) ? 'tls' : 'ssl'; // 587=STARTTLS, 465=SSL/TLS
$phpmailer->Port = $port;
$phpmailer->CharSet = 'UTF-8';
$fromEmail = !empty($smtp['from_email']) ? $smtp['from_email'] : $smtp['username'];
$fromName = !empty($smtp['from_name']) ? $smtp['from_name'] : 'Skrift';
$phpmailer->setFrom($fromEmail, $fromName);
}
/**
* Kunden-E-Mail anhand eines Templates senden.
*/
private static function send_customer_email(string $job_id, string $status, string $reason = ''): void {
$jobs = self::get_jobs();
if (!isset($jobs[$job_id])) return;
$job = $jobs[$job_id];
$to = sanitize_email($job['customer_email'] ?? '');
if (empty($to) || !is_email($to)) return;
$settings = self::get_plugin_settings();
$templates = $settings['mail_templates'] ?? [];
$template = $templates[$status] ?? null;
if (!$template || empty($template['subject'])) return;
// Portal-URL aufbauen
$portal_url = !empty($settings['portal_url']) ? $settings['portal_url'] : site_url('/');
$order_link = add_query_arg('nr', rawurlencode($job_id), $portal_url);
// Versandfreigabe-Link (nur fur 'printing')
$approve_link = '';
$approve_link_button = '';
if ($status === 'printing' && !empty($job['shipping_token'])) {
$approve_link = add_query_arg([
'nr' => rawurlencode($job_id),
'sk_approve' => rawurlencode($job['shipping_token']),
], $portal_url);
$approve_link_button = '<a href="' . esc_url($approve_link) . '" '
. 'style="display:inline-block;padding:12px 28px;background:#1e3a6a;color:#ffffff;text-decoration:none;'
. 'border-radius:4px;font-weight:600;font-size:14px;margin:8px 0;">'
. 'Versandfreigabe erteilen</a>';
}
// Template-Text: {{approve_link_button}} erst durch Platzhalter ersetzen (vor htmlspecialchars)
$body_raw = $template['body'];
$body_raw = str_replace('{{approve_link_button}}', '[__APPROVE_BTN__]', $body_raw);
// Restliche Platzhalter ersetzen
$body_raw = str_replace(
['{{order_id}}', '{{link}}', '{{reason}}', '{{approve_link}}'],
[esc_html($job_id), esc_url($order_link), esc_html($reason), esc_url($approve_link ?: $order_link)],
$body_raw
);
$subject = str_replace('{{order_id}}', $job_id, $template['subject']);
// HTML-Body bauen
$html_body = nl2br(htmlspecialchars($body_raw, ENT_QUOTES, 'UTF-8'));
// Approve-Button als echtes HTML einfugen
$html_body = str_replace('[__APPROVE_BTN__]', $approve_link_button, $html_body);
// [rot]...[/rot] Tags als roten, fetten Text rendern (direkt im Template schreibbar)
$html_body = preg_replace(
'/\[rot\](.*?)\[\/rot\]/s',
'<span style="color:#dc2626;font-weight:700;">$1</span>',
$html_body
);
$html = '<!DOCTYPE html><html><head><meta charset="UTF-8"></head><body '
. 'style="font-family:Arial,sans-serif;font-size:14px;line-height:1.6;color:#333;max-width:560px;margin:0 auto;padding:24px;">'
. '<div style="border-bottom:2px solid #1e3a6a;padding-bottom:12px;margin-bottom:20px;">'
. '<strong style="font-size:20px;color:#1e3a6a;letter-spacing:1px;">SKRIFT</strong>'
. '</div>'
. $html_body
. '<div style="margin-top:32px;padding-top:16px;border-top:1px solid #eee;font-size:12px;color:#999;">'
. 'Skrift Handschrift-Service | Diese E-Mail wurde automatisch generiert.'
. '</div></body></html>';
wp_mail($to, $subject, $html, ['Content-Type: text/html; charset=UTF-8', 'Cc: hello@skrift.de']);
}
// ─── Datei-Verwaltung ────────────────────────────────────────────────────────
/**
* Gibt das Verzeichnis für Auftragsdateien zurück (kein Trailing-Slash).
*/
public static function get_job_dir(string $job_id): string {
$upload = wp_upload_dir();
return $upload['basedir'] . '/skrift-auftraege/' . sanitize_file_name($job_id);
}
/**
* Legt eine .htaccess an, die direkten HTTP-Zugriff auf das Verzeichnis sperrt.
*/
private static function protect_directory(string $dir): void {
$htaccess = $dir . '/.htaccess';
if (!file_exists($htaccess)) {
file_put_contents($htaccess, "Deny from all\n<FilesMatch \"^$\">\n Order allow,deny\n Allow from all\n</FilesMatch>\n");
}
}
/**
* Löscht ein Verzeichnis mit allem Inhalt rekursiv.
*/
private static function delete_directory(string $dir): void {
if (!is_dir($dir)) return;
foreach (array_diff((array) scandir($dir), ['.', '..']) as $entry) {
$path = $dir . '/' . $entry;
is_dir($path) ? self::delete_directory($path) : unlink($path);
}
rmdir($dir);
}
/**
* Cron-Callback: Aufträge im Status "versendet" nach 3 Monaten löschen.
*/
public function cleanup_old_jobs(): void {
$jobs = self::get_jobs();
$cutoff = strtotime('-3 months');
$changed = false;
foreach ($jobs as $job_id => $job) {
if (($job['status'] ?? '') !== 'shipped') continue;
// Zeitstempel des Versand-Status als Referenz
$shipped_ts = !empty($job['status_updated_at'])
? strtotime($job['status_updated_at'])
: (!empty($job['created_at']) ? strtotime($job['created_at']) : 0);
if ($shipped_ts > 0 && $shipped_ts < $cutoff) {
// Dateien löschen
$dir = self::get_job_dir($job_id);
if (is_dir($dir)) {
self::delete_directory($dir);
}
// Auftrag aus der Liste entfernen
unset($jobs[$job_id]);
$changed = true;
}
}
if ($changed) {
update_option(self::JOBS_KEY, $jobs);
}
}
// ─── xlsx-Generator ───────────────────────────────────────────────────────────
private static function create_xlsx(array $rows): string {
if (!class_exists('ZipArchive')) return '';
$tmp = tempnam(sys_get_temp_dir(), 'skrift_xlsx_');
$zip = new ZipArchive();
$zip->open($tmp, ZipArchive::CREATE | ZipArchive::OVERWRITE);
$zip->addFromString('[Content_Types].xml', self::xlsx_content_types());
$zip->addFromString('_rels/.rels', self::xlsx_rels());
$zip->addFromString('xl/workbook.xml', self::xlsx_workbook());
$zip->addFromString('xl/_rels/workbook.xml.rels', self::xlsx_workbook_rels());
$zip->addFromString('xl/worksheets/sheet1.xml', self::xlsx_sheet($rows));
$zip->addFromString('xl/styles.xml', self::xlsx_styles());
$zip->close();
$content = file_get_contents($tmp);
unlink($tmp);
return $content;
}
private static function xlsx_content_types(): string {
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>
</Types>';
}
private static function xlsx_rels(): string {
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
</Relationships>';
}
private static function xlsx_workbook(): string {
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<sheets><sheet name="Tabelle1" sheetId="1" r:id="rId1"/></sheets>
</workbook>';
}
private static function xlsx_workbook_rels(): string {
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
</Relationships>';
}
private static function xlsx_styles(): string {
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<fonts count="2">
<font><sz val="11"/><name val="Calibri"/></font>
<font><b/><sz val="11"/><name val="Calibri"/></font>
</fonts>
<fills count="2">
<fill><patternFill patternType="none"/></fill>
<fill><patternFill patternType="gray125"/></fill>
</fills>
<borders count="1">
<border><left/><right/><top/><bottom/><diagonal/></border>
</borders>
<cellStyleXfs count="1">
<xf numFmtId="0" fontId="0" fillId="0" borderId="0"/>
</cellStyleXfs>
<cellXfs count="2">
<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>
<xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0"/>
</cellXfs>
</styleSheet>';
}
private static function xlsx_sheet(array $rows): string {
$rows_xml = '';
$row_num = 1;
foreach ($rows as $row) {
$cells_xml = '';
$col_idx = 0;
foreach ($row as $cell) {
$col = self::col_letter($col_idx);
$ref = $col . $row_num;
$escaped = htmlspecialchars((string) $cell, ENT_XML1, 'UTF-8');
$style = ($row_num === 1) ? ' s="1"' : '';
$cells_xml .= "<c r=\"{$ref}\"{$style} t=\"inlineStr\"><is><t>{$escaped}</t></is></c>";
$col_idx++;
}
$rows_xml .= "<row r=\"{$row_num}\">{$cells_xml}</row>";
$row_num++;
}
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<sheetData>' . $rows_xml . '</sheetData>
</worksheet>';
}
private static function col_letter(int $n): string {
$letter = '';
do {
$letter = chr(65 + ($n % 26)) . $letter;
$n = intval($n / 26) - 1;
} while ($n >= 0);
return $letter;
}
}
new Skrift_Job_Manager();