diff --git a/admin/setup.php b/admin/setup.php index e02a070..da17ad5 100644 --- a/admin/setup.php +++ b/admin/setup.php @@ -67,6 +67,10 @@ if ($action === 'save_const') { 'BERICHT_TAB_ON_PROPAL' => GETPOST('tab_propal', 'int') ? '1' : '0', 'BERICHT_BURN_ANNOTATIONS' => GETPOST('burn', 'int') ? '1' : '0', 'BERICHT_LIBREOFFICE_BIN' => GETPOST('lobin', 'alphanohtml'), + 'BERICHT_WHISPER_URL' => GETPOST('whisper_url', 'alphanohtml'), + 'BERICHT_WHISPER_MODE' => GETPOST('whisper_mode', 'alphanohtml'), + 'BERICHT_WHISPER_API_KEY' => GETPOST('whisper_key', 'alphanohtml'), + 'BERICHT_WHISPER_LANG' => GETPOST('whisper_lang', 'alphanohtml'), ); foreach ($consts as $k => $v) { dolibarr_set_const($db, $k, $v, 'chaine', 0, '', $conf->entity); @@ -205,6 +209,25 @@ print ''.$langs->trans("BerichtSetupLibreOfficeBin").''; print ''; +print '

🎙 Whisper-Transkription (Sprachnotizen → Text)

'; +print 'Whisper-URL'; +print ''; +print '
Leer lassen um Transkription zu deaktivieren. Bei OpenAI: https://api.openai.com'; +print ''; +print 'Modus'; +$mode_val = getDolGlobalString('BERICHT_WHISPER_MODE', 'whispercpp'); +print ''; +print ''; +print 'API-Key (nur OpenAI)'; +print ''; +print ''; +print 'Sprache'; +print ''; +print ''; + print ''; print '
'; print ''; diff --git a/ajax/save_as_template.php b/ajax/save_as_template.php new file mode 100644 index 0000000..112e155 --- /dev/null +++ b/ajax/save_as_template.php @@ -0,0 +1,50 @@ +hasRight('bericht', 'write')) bericht_ajax_fail('Permission denied', 403); + +$berichtid = (int) ($_POST['berichtid'] ?? 0); +$label = trim((string) ($_POST['label'] ?? '')); +if (!$berichtid) bericht_ajax_fail('berichtid fehlt'); +if (empty($label)) bericht_ajax_fail('Label erforderlich'); + +$src = new Bericht($db); +if ($src->fetch($berichtid) <= 0) bericht_ajax_fail('Bericht nicht gefunden', 404); + +// Vorlage als neuer Bericht +$tpl = new Bericht($db); +$tpl->element_type = $src->element_type; +$tpl->fk_element = 0; +$tpl->titel = $src->titel; +$tpl->auftragsnummer = ''; +$tpl->template_odt = $src->template_odt; +$tpl->page_format = $src->page_format; +$tpl->page_orientation = $src->page_orientation; +$tpl->is_template = 1; +$tpl->template_label = $label; +if ($tpl->create($user) <= 0) bericht_ajax_fail('Erstellen fehlgeschlagen', 500); + +// Seiten kopieren (mit source_path-Referenz — die Vorlagen zeigen auf die Quell-Assets) +$src_pages = BerichtPage::fetchAllForBericht($db, $src->id); +foreach ($src_pages as $order => $p) { + $np = new BerichtPage($db); + $np->fk_bericht = $tpl->id; + $np->page_order = $order + 1; + $np->source_type = $p->source_type; + $np->source_path = $p->source_path; + $np->source_page = $p->source_page; + $np->note = $p->note; + $np->layout = $p->layout; + $np->image_scale = $p->image_scale; + $np->image_align = $p->image_align; + $np->create(); +} + +bericht_ajax_ok(array('template_id' => $tpl->id, 'label' => $label)); diff --git a/api/odt_templates.php b/api/odt_templates.php new file mode 100644 index 0000000..45c7d43 --- /dev/null +++ b/api/odt_templates.php @@ -0,0 +1,16 @@ + $fn, + 'label' => preg_replace('/\.odt$/i', '', $fn), + ); +} +$default = getDolGlobalString('BERICHT_DEFAULT_TEMPLATE', ''); +api_ok(array('templates' => $out, 'default' => $default)); diff --git a/api/reports.php b/api/reports.php index 3b22f42..9592884 100644 --- a/api/reports.php +++ b/api/reports.php @@ -57,6 +57,57 @@ if (!$id && $action === '') { api_ok(array('reports' => $items, 'count' => count($items))); } +/* ----- POST: neuen Bericht anlegen ----- */ +if (!$id && $_SERVER['REQUEST_METHOD'] === 'POST' && $action === 'create') { + if (!$user->hasRight('bericht', 'write')) api_fail('Permission denied', 403); + $in = api_input(); + $el_type = (string) ($in['element_type'] ?? 'order'); + $el_id = (int) ($in['element_id'] ?? 0); + $titel = trim((string) ($in['titel'] ?? '')); + $format = (string) ($in['page_format'] ?? 'A4'); + $orient = (string) ($in['page_orientation'] ?? 'P'); + $template_id = (int) ($in['template_id'] ?? 0); + $template_odt = (string) ($in['template_odt'] ?? ''); + + if (!$el_id) api_fail('element_id erforderlich'); + if (!in_array($el_type, array('order', 'invoice', 'propal'), true)) api_fail('element_type ungĂŒltig'); + + // Auftragsnummer ermitteln + $parent = bericht_fetch_parent($db, $el_type, $el_id); + if (!$parent) api_fail('Parent nicht gefunden', 404); + $auftragsnummer = ''; + if ($el_type === 'invoice') { + $auftragsnummer = $parent->array_options['options_auftragsnummer'] ?? $parent->ref_client ?? $parent->ref; + } else { + $auftragsnummer = $parent->ref; + } + + if ($template_id > 0) { + $new_id = Bericht::createFromTemplate($db, $user, $template_id, $el_type, $el_id, $auftragsnummer); + if (!$new_id) api_fail('Vorlage konnte nicht angewendet werden', 500); + // Nachbearbeiten falls abweichende Meta + $b = new Bericht($db); + $b->fetch($new_id); + if ($titel) $b->titel = $titel; + if (in_array($format, array('A4','A3','A5','Letter'), true)) $b->page_format = $format; + if (in_array($orient, array('P','L'), true)) $b->page_orientation = $orient; + if ($template_odt) $b->template_odt = $template_odt; + $b->update($user); + api_ok(array('bericht_id' => $new_id)); + } + + $b = new Bericht($db); + $b->element_type = $el_type; + $b->fk_element = $el_id; + $b->titel = $titel ?: ('Bericht '.$auftragsnummer); + $b->auftragsnummer = $auftragsnummer; + $b->template_odt = $template_odt ?: getDolGlobalString('BERICHT_DEFAULT_TEMPLATE', ''); + $b->page_format = in_array($format, array('A4','A3','A5','Letter'), true) ? $format : 'A4'; + $b->page_orientation = in_array($orient, array('P','L'), true) ? $orient : 'P'; + if ($b->create($user) <= 0) api_fail('Anlegen fehlgeschlagen', 500); + api_ok(array('bericht_id' => $b->id)); +} + if (!$id) api_fail('id erforderlich'); $bericht = new Bericht($db); diff --git a/api/templates.php b/api/templates.php new file mode 100644 index 0000000..0a2157e --- /dev/null +++ b/api/templates.php @@ -0,0 +1,61 @@ +id); + $items[] = array( + 'id' => (int) $t->id, + 'label' => $t->template_label ?: $t->titel ?: $t->ref, + 'titel' => $t->titel, + 'page_format' => $t->page_format, + 'page_orientation' => $t->page_orientation, + 'template_odt' => $t->template_odt, + 'page_count' => count($pages), + ); + } + api_ok(array('templates' => $items, 'count' => count($items))); +} + +if ($method === 'POST' && $action === 'create_from_template') { + if (!$user->hasRight('bericht', 'write')) api_fail('Permission denied', 403); + $in = api_input(); + $tpl_id = (int) ($in['template_id'] ?? 0); + $el_type = (string) ($in['element_type'] ?? 'order'); + $el_id = (int) ($in['element_id'] ?? 0); + $auftrag = (string) ($in['auftragsnummer'] ?? ''); + + if (!$tpl_id || !$el_id) api_fail('template_id + element_id erforderlich'); + if (!in_array($el_type, array('order', 'invoice', 'propal'), true)) api_fail('element_type ungĂŒltig'); + + // Auftragsnummer auto-fĂŒllen wenn leer + if (empty($auftrag)) { + $parent = bericht_fetch_parent($db, $el_type, $el_id); + if ($parent) { + if ($el_type === 'invoice') { + $auftrag = $parent->array_options['options_auftragsnummer'] ?? $parent->ref_client ?? $parent->ref; + } else { + $auftrag = $parent->ref; + } + } + } + + $new_id = Bericht::createFromTemplate($db, $user, $tpl_id, $el_type, $el_id, $auftrag); + if (!$new_id) api_fail('Erstellung fehlgeschlagen', 500); + + api_ok(array('bericht_id' => $new_id)); +} + +api_fail('Methode/Action nicht unterstĂŒtzt', 405); diff --git a/api/transcribe.php b/api/transcribe.php new file mode 100644 index 0000000..3f5bbb1 --- /dev/null +++ b/api/transcribe.php @@ -0,0 +1,92 @@ + rtrim($whisper_url, '/').'/v1/audio/transcriptions', + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => array( + 'file' => $cfile, + 'model' => 'whisper-1', + 'language' => $language, + ), + CURLOPT_HTTPHEADER => $api_key ? array('Authorization: Bearer '.$api_key) : array(), + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 120, + )); +} else { + // whisper.cpp server /inference + curl_setopt_array($ch, array( + CURLOPT_URL => rtrim($whisper_url, '/').'/inference', + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => array( + 'file' => $cfile, + 'language' => $language, + 'response_format' => 'json', + ), + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 120, + )); +} + +$resp = curl_exec($ch); +$status = curl_getinfo($ch, CURLINFO_HTTP_CODE); +$err = curl_error($ch); +curl_close($ch); + +if ($err) api_fail('Whisper-Fehler: '.$err, 502); +if ($status !== 200) api_fail('Whisper HTTP '.$status.': '.substr($resp, 0, 200), 502); + +$data = json_decode($resp, true); +$text = ''; +if (is_array($data)) { + $text = $data['text'] ?? ($data['transcription'] ?? ''); +} else { + $text = (string) $resp; +} +$text = trim($text); + +api_ok(array('text' => $text, 'language' => $language)); diff --git a/bericht_card.php b/bericht_card.php index 1c51a00..d0d3da7 100644 --- a/bericht_card.php +++ b/bericht_card.php @@ -79,8 +79,17 @@ if ($action === 'unlink' && $berichtid > 0 && $user->hasRight('bericht', 'write' exit; } -// Aktion: neuen Bericht anlegen +// Aktion: neuen Bericht anlegen (ggf. aus Vorlage) if ($action === 'create' && $user->hasRight('bericht', 'write')) { + $tpl_id = GETPOSTINT('template_id'); + if ($tpl_id > 0) { + $new_id = Bericht::createFromTemplate($db, $user, $tpl_id, $element, $parent->id, $auftragsnummer); + if ($new_id) { + header("Location: ".$_SERVER['PHP_SELF'].'?berichtid='.$new_id); + exit; + } + setEventMessages('Vorlage konnte nicht angewendet werden', null, 'errors'); + } $b = new Bericht($db); $b->element_type = $element; $b->fk_element = $parent->id; @@ -168,11 +177,20 @@ if (!$bericht) { print '
'; print '

'.$langs->trans("Berichte").'

'; if ($user->hasRight('bericht', 'write')) { - print '
'; + $templates = Bericht::fetchAllTemplates($db); + print ''; print ''; print ''; print ''; print ''; + if (!empty($templates)) { + print ''; + } print ''; print '
'; } @@ -268,6 +286,7 @@ if (!$bericht) { 'create_upload_token' => dol_buildpath('/bericht/ajax/create_upload_token.php', 1), 'list_pages' => dol_buildpath('/bericht/ajax/list_pages.php', 1), 'verify_signature' => dol_buildpath('/bericht/ajax/verify_signature.php', 1), + 'save_as_template' => dol_buildpath('/bericht/ajax/save_as_template.php', 1), ), 'lang' => array( 'undo' => $langs->trans("BerichtUndo"), @@ -489,6 +508,7 @@ if (!$bericht) { print '
'; print ''; print ''; + print ''; print ''; if ($user->hasRight('bericht', 'delete')) { print 'db->prefix()."bericht (" - ."entity, ref, titel, element_type, fk_element, auftragsnummer, template_odt, page_format, page_orientation, status, fk_user_creat, datec, note" + ."entity, ref, titel, element_type, fk_element, auftragsnummer, template_odt, page_format, page_orientation, is_template, template_label, status, fk_user_creat, datec, note" .") VALUES (" .((int) $this->entity)."," ."'".$this->db->escape($this->ref)."'," @@ -67,6 +69,8 @@ class Bericht extends CommonObject .($this->template_odt ? "'".$this->db->escape($this->template_odt)."'" : "NULL")."," ."'".$this->db->escape($this->page_format)."'," ."'".$this->db->escape($this->page_orientation)."'," + .((int) ($this->is_template ? 1 : 0))."," + .($this->template_label ? "'".$this->db->escape($this->template_label)."'" : "NULL")."," .((int) $this->status)."," .((int) $this->fk_user_creat)."," ."'".$this->db->idate($this->datec)."'," @@ -88,7 +92,7 @@ class Bericht extends CommonObject public function fetch($id) { $sql = "SELECT rowid, entity, ref, titel, element_type, fk_element, auftragsnummer, template_odt," - ." page_format, page_orientation," + ." page_format, page_orientation, COALESCE(is_template,0) AS is_template, template_label," ." status, final_pdf_path, fk_user_creat, fk_user_modif, datec, tms, note" ." FROM ".$this->db->prefix()."bericht WHERE rowid = ".((int) $id); $res = $this->db->query($sql); @@ -110,6 +114,8 @@ class Bericht extends CommonObject $this->template_odt = $obj->template_odt; $this->page_format = $obj->page_format ?: 'A4'; $this->page_orientation= $obj->page_orientation ?: 'P'; + $this->is_template = (int) $obj->is_template; + $this->template_label = $obj->template_label; $this->status = (int) $obj->status; $this->final_pdf_path = $obj->final_pdf_path; $this->fk_user_creat = $obj->fk_user_creat; @@ -169,6 +175,7 @@ class Bericht extends CommonObject $sql = "SELECT rowid FROM ".$db->prefix()."bericht" ." WHERE element_type = '".$db->escape($element_type)."'" ." AND fk_element = ".((int) $fk_element) + ." AND COALESCE(is_template,0) = 0" ." ORDER BY datec DESC"; $res = $db->query($sql); if (!$res) return $list; @@ -235,6 +242,67 @@ class Bericht extends CommonObject return $this->db->query($sql) ? 1 : -1; } + /** + * Liefert alle Vorlagen (is_template = 1). + * @return Bericht[] + */ + public static function fetchAllTemplates(DoliDB $db) + { + $list = array(); + $sql = "SELECT rowid FROM ".$db->prefix()."bericht" + ." WHERE COALESCE(is_template,0) = 1" + ." ORDER BY template_label ASC, ref ASC"; + $res = $db->query($sql); + if (!$res) return $list; + while ($obj = $db->fetch_object($res)) { + $b = new self($db); + if ($b->fetch($obj->rowid) > 0) $list[] = $b; + } + return $list; + } + + /** + * Erstellt einen neuen Bericht aus einer Vorlage: + * Titel, Format, Orientation, Template-ODT werden ĂŒbernommen; + * alle Vorlagen-Seiten werden in den neuen Bericht kopiert (mit Notizen, + * aber source_path wird nur ĂŒbernommen wenn es ein dauerhaftes Asset ist). + * + * @return int|false Neue Bericht-ID oder false + */ + public static function createFromTemplate(DoliDB $db, User $user, $template_id, $element_type, $fk_element, $auftragsnummer) + { + $tpl = new self($db); + if ($tpl->fetch($template_id) <= 0 || !$tpl->is_template) return false; + + $b = new self($db); + $b->element_type = $element_type; + $b->fk_element = $fk_element; + $b->titel = $tpl->titel ?: ('Bericht '.$auftragsnummer); + $b->auftragsnummer = $auftragsnummer; + $b->template_odt = $tpl->template_odt; + $b->page_format = $tpl->page_format; + $b->page_orientation = $tpl->page_orientation; + $b->is_template = 0; + if ($b->create($user) <= 0) return false; + + // Seiten kopieren + $tpl_pages = BerichtPage::fetchAllForBericht($db, $tpl->id); + foreach ($tpl_pages as $order => $tp) { + $np = new BerichtPage($db); + $np->fk_bericht = $b->id; + $np->page_order = $order + 1; + $np->source_type = $tp->source_type; + $np->source_path = $tp->source_path; // zeigt auf die Vorlagen-Assets (werden mitgenutzt, nicht kopiert) + $np->source_page = $tp->source_page; + $np->note = $tp->note; + $np->layout = $tp->layout; + $np->image_scale = $tp->image_scale; + $np->image_align = $tp->image_align; + $np->create(); + } + return (int) $b->id; + } + public function getLibStatut($mode = 0) { global $langs; diff --git a/class/upload_token.class.php b/class/upload_token.class.php index 61977a5..57bffba 100644 --- a/class/upload_token.class.php +++ b/class/upload_token.class.php @@ -88,9 +88,27 @@ class BerichtUploadToken } /** + * Instanz-Methode fĂŒr den Dolibarr-Cronjob. * RĂ€umt expired Tokens auf. + * + * @return int Anzahl gelöschter Tokens (>=0), -1 bei Fehler */ - public static function cleanupExpired(DoliDB $db) + public function cleanupExpired() + { + $sql = "DELETE FROM ".$this->db->prefix()."bericht_upload_token" + ." WHERE expires_at < '".$this->db->idate(dol_now())."'"; + $res = $this->db->query($sql); + if (!$res) { + $this->error = $this->db->lasterror(); + return -1; + } + return $this->db->affected_rows($res); + } + + /** + * Statische Variante fĂŒr Direktaufrufe außerhalb des Cronjobs. + */ + public static function cleanupExpiredStatic(DoliDB $db) { $db->query("DELETE FROM ".$db->prefix()."bericht_upload_token" ." WHERE expires_at < '".$db->idate(dol_now())."'"); diff --git a/core/modules/modBericht.class.php b/core/modules/modBericht.class.php index 6efd216..8f88cfc 100644 --- a/core/modules/modBericht.class.php +++ b/core/modules/modBericht.class.php @@ -166,6 +166,9 @@ class modBericht extends DolibarrModules // Phase 1.5: BildgrĂ¶ĂŸe/-position "ALTER TABLE ".$this->db->prefix()."bericht_page ADD COLUMN image_scale FLOAT DEFAULT 1.0", "ALTER TABLE ".$this->db->prefix()."bericht_page ADD COLUMN image_align VARCHAR(16) DEFAULT 'fit'", + // Phase 5.5: Bericht-Vorlagen + "ALTER TABLE ".$this->db->prefix()."bericht ADD COLUMN is_template TINYINT(1) DEFAULT 0", + "ALTER TABLE ".$this->db->prefix()."bericht ADD COLUMN template_label VARCHAR(255) DEFAULT NULL", ); foreach ($migrations as $sql) { // Errors ignorieren — Spalten existieren ggf. schon diff --git a/js/editor.js b/js/editor.js index 6661127..2148117 100644 --- a/js/editor.js +++ b/js/editor.js @@ -660,6 +660,24 @@ if (e.key === 'Escape') closePreviewModal(); }); + // Als Vorlage speichern + const tplBtn = document.getElementById('btn-save-as-template'); + if (tplBtn) { + tplBtn.addEventListener('click', async () => { + const label = prompt('Label fĂŒr die Vorlage:\n(z. B. "PV-Anlage Standard" oder "Wallbox 11kW")'); + if (!label) return; + await savePageAnnotations(false); + const fd = new FormData(); + fd.append('token', cfg.token); + fd.append('berichtid', cfg.berichtid); + fd.append('label', label); + const r = await fetch(cfg.urls.save_as_template, { method: 'POST', body: fd }); + const data = await r.json(); + if (data.success) toast('✓ Vorlage "' + label + '" gespeichert'); + else alert('Fehler: ' + (data.error || '')); + }); + } + document.getElementById('btn-finalize').addEventListener('click', async () => { await savePageAnnotations(false); const fd = new FormData();