added file uploads

This commit is contained in:
2025-10-24 09:30:48 +02:00
parent 0131f472f0
commit 6af9d8dc32
5 changed files with 654 additions and 30 deletions

View File

@@ -100,51 +100,482 @@ $pagination_entity_name = "Vorbestellungen";
} }
} }
/* styles for documents */
.document-upload-wrapper {
background: #fdfdfd;
border: 1px solid #e9ecef;
border-radius: .25rem;
}
.document-dropzone {
border: 2px dashed #ced4da;
border-radius: 0.25rem;
padding: 1.5rem;
text-align: center;
background-color: #f8f9fa;
transition: all 0.3s ease;
cursor: pointer;
}
.document-dropzone:hover {
border-color: #007bff;
background-color: #e9ecef;
}
.document-dropzone.active {
border-color: #007bff;
border-style: solid;
}
.document-staging-area {
max-height: 250px;
overflow-y: auto;
}
.document-staging-item {
display: flex;
align-items: flex-start;
padding: 0.75rem;
border: 1px solid #dee2e6;
border-radius: 0.25rem;
margin-bottom: 0.5rem;
background-color: #fff;
}
.doc-staging-icon {
flex-shrink: 0;
width: 30px;
text-align: center;
padding-top: 0.25rem;
}
.doc-staging-details {
flex-grow: 1;
padding: 0 0.5rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.doc-staging-filename {
font-size: 0.9em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.doc-staging-filesize {
font-size: 0.8em;
}
.doc-staging-actions {
flex-shrink: 0;
}
.document-list-wrapper {
min-height: 300px;
}
.doc-spinner {
display: inline-block;
width: 3rem;
height: 3rem;
vertical-align: text-bottom;
border: 0.25em solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: doc-spin 0.75s linear infinite;
color: #007bff;
}
@keyframes doc-spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.doc-row-icon i { font-size: 1.5rem; }
.doc-row-icon .fa-file-pdf { color: #dc3545; }
.doc-row-icon .fa-file-image { color: #28a745; }
.doc-row-icon .fa-file-word { color: #007bff; }
.doc-row-icon .fa-file-excel { color: #207245; }
.doc-row-icon .fa-file-archive { color: #ffc107; }
.doc-row-icon .fa-file { color: #6c757d; }
.doc-preview-modal-body img,
.doc-preview-modal-body embed,
.doc-preview-modal-body iframe {
max-width: 100%;
max-height: 75vh;
border: none;
}
</style> </style>
<script> <script>
$(document).ready(function () { $(function() {
if (window.matchMedia('(min-width: 576px)').matches) { if (window.matchMedia('(min-width: 576px)').matches) return;
const $pagination = $("ul.pagination");
const $disabled = $pagination.find(".page-item.disabled.points");
if ($disabled.length) {
const $first = $pagination.find(".page-item").first();
const $firstNext = $first.next();
const $last = $pagination.find(".page-item").last();
const $lastPrev = $last.prev();
const $prev = $disabled.prev();
const $next = $disabled.next();
const $keep = $().add($first).add($firstNext).add($last).add($lastPrev).add($prev).add($next).add($disabled);
$pagination.find(".page-item").not($keep).remove();
}
});
$(function() {
function initPreorderDocumentTabs() {
$('.preorder-documents-tab').each(function() {
const tabPane = $(this);
const preorderId = tabPane.data('preorder-id');
if (!preorderId) return;
const dropzone = tabPane.find('.doc-dropzone');
const browseBtn = tabPane.find('.doc-browse-btn');
const fileInput = tabPane.find('.doc-file-input');
const stagingArea = tabPane.find('.doc-staging-area');
const stagingTemplate = tabPane.find('.doc-staging-item-template')[0];
const uploadBtn = tabPane.find('.doc-upload-btn');
const uploadBtnText = tabPane.find('.doc-upload-btn-text');
const uploadSpinner = tabPane.find('.doc-upload-spinner');
const listLoader = tabPane.find('.document-list-loader');
const emptyState = tabPane.find('.document-empty-state');
const listContainer = tabPane.find('.document-list-container');
const listTbody = tabPane.find('.doc-list-tbody');
const listRowTemplate = tabPane.find('.doc-list-row-template')[0];
const modal = tabPane.find('.doc-preview-modal');
const modalTitle = modal.find('.doc-preview-modal-title');
const modalBody = modal.find('.doc-preview-modal-body');
let stagedFiles = new Map();
// --- START FIX 1: Helper function to decode HTML entities ---
function htmlDecode(input) {
if (!input || typeof input !== 'string') return input;
var doc = new DOMParser().parseFromString(input, "text/html");
return doc.documentElement.textContent;
}
// --- END FIX 1 ---
function formatFileSize(bytes, decimals = 2) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
}
function guessMimeType(filename) {
const ext = (filename || '').split('.').pop().toLowerCase();
const mimeTypes = {
'pdf': 'application/pdf',
'jpg': 'image/jpeg',
'jpeg': 'image/jpeg',
'png': 'image/png',
'gif': 'image/gif',
'doc': 'application/msword',
'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'xls': 'application/vnd.ms-excel',
'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'zip': 'application/zip',
};
return mimeTypes[ext] || 'application/octet-stream';
}
function getFileIcon(mimeType) {
if (!mimeType) return '<i class="fas fa-file fa-fw"></i>';
const iconMap = {
'image/': 'fa-file-image',
'application/pdf': 'fa-file-pdf',
'word': 'fa-file-word',
'excel': 'fa-file-excel',
'spreadsheet': 'fa-file-excel',
'zip': 'fa-file-archive',
'archive': 'fa-file-archive'
};
for (const key in iconMap) {
if (mimeType.includes(key)) return `<i class="fas ${iconMap[key]} fa-fw"></i>`;
}
return '<i class="fas fa-file fa-fw"></i>';
}
function isPreviewable(mimeType) {
return mimeType.startsWith('image/') || mimeType === 'application/pdf';
}
async function fetchFileMetaData(fileId, description) {
try {
const response = await fetch(`/File/getById?id=${fileId}`);
if (!response.ok) return null;
const data = await response.json();
const mimetype = guessMimeType(data.filename);
return {
id: fileId,
fileId: fileId,
fileName: data.filename,
description: description,
mimetype: mimetype
};
} catch (error) {
return null;
}
}
async function loadDocuments() {
listLoader.show();
listContainer.hide();
emptyState.hide();
listTbody.empty();
let fileObjects = [];
try {
const rawData = tabPane.data('file-objects');
if (typeof rawData === 'string') {
const decodedData = htmlDecode(rawData);
fileObjects = JSON.parse(decodedData);
} else if (Array.isArray(rawData)) {
fileObjects = rawData;
}
} catch (e) {
console.error("Error parsing file objects:", e, tabPane.data('file-objects'));
fileObjects = [];
}
if (fileObjects.length === 0) {
emptyState.show();
listLoader.hide();
return; return;
} }
// in ul.pagination try {
// if .page-item.disabled.text-secondary exists const fetchPromises = fileObjects.map(obj => fetchFileMetaData(obj.id, obj.description));
// only keep the first 2 .page-item, the last 2 .page-item and the .page-item.disabled.text-secondary and the one before and the one after that const documents = (await Promise.all(fetchPromises)).filter(doc => doc !== null);
const pagination = $("ul.pagination"); if (documents.length === 0) {
const disabled = pagination.find(".page-item.disabled.points"); emptyState.show();
} else {
documents.forEach(addDocumentRow);
listContainer.show();
}
} catch (error) {
emptyState.find('h5').text('Fehler beim Laden');
emptyState.find('p').text('Die Dokumente konnten nicht geladen werden.');
emptyState.show();
} finally {
listLoader.hide();
}
}
if (disabled.length) { function addDocumentRow(docData, prepend = false) {
const first = pagination.find(".page-item").first(); const rowClone = listRowTemplate.content.cloneNode(true);
const firstNext = first.next(); const $row = $(rowClone).find('tr');
const last = pagination.find(".page-item").last();
const lastNext = last.prev();
const prev = disabled.prev();
const next = disabled.next();
const notToDelete = [first, firstNext, last, lastNext, prev, next, disabled];
// loop through pagination.find(".page-item") and remove all but the first, last, prev, next, firstNext, lastNext $row.attr('data-doc-id', docData.id);
$row.find('.doc-row-icon').html(getFileIcon(docData.mimetype));
$row.find('.doc-row-filename').text(docData.fileName);
$row.find('.doc-row-description').text(docData.description || '');
pagination.find(".page-item").each(function (index, item) { const previewUrl = `/File/show?id=${docData.fileId}`;
// if (!notToDelete.includes($(item))) { const downloadUrl = `/File/download?id=${docData.fileId}`;
// $(item).remove();
// } fix this becaues of we need [0] of notToDelete to compare $row.find('.doc-preview-btn').data({
let check = false; url: previewUrl,
notToDelete.forEach(function (n) { type: docData.mimetype,
if (n[0] === item) { filename: docData.fileName
check = true; });
$row.find('.doc-download-btn').attr({
href: downloadUrl,
download: docData.fileName
});
if (!isPreviewable(docData.mimetype)) {
$row.find('.doc-preview-btn').addClass('disabled').attr('title', 'Vorschau nicht verfügbar');
}
prepend ? listTbody.prepend($row) : listTbody.append($row);
emptyState.hide();
listContainer.show();
}
tabPane.data('loadDocumentsFunction', loadDocuments);
function handleFiles(files) {
for (const file of files) {
const fileId = `file-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
stagedFiles.set(fileId, file);
const itemClone = stagingTemplate.content.cloneNode(true);
const item = $(itemClone).find('.document-staging-item');
item.attr('data-file-id', fileId);
item.find('.doc-staging-filename').text(file.name);
item.find('.doc-staging-filesize').text(formatFileSize(file.size));
item.find('.doc-staging-icon').html(getFileIcon(file.type));
stagingArea.append(item).show();
}
updateUploadButton();
}
function updateUploadButton() {
if (stagedFiles.size > 0) {
uploadBtnText.text(`${stagedFiles.size} Datei(en) hochladen`);
uploadBtn.show();
} else {
uploadBtn.hide();
stagingArea.hide();
}
}
dropzone.on('dragover', (e) => {
e.preventDefault();
e.stopPropagation();
dropzone.addClass('active');
});
dropzone.on('dragleave', (e) => {
e.preventDefault();
e.stopPropagation();
dropzone.removeClass('active');
});
dropzone.on('drop', (e) => {
e.preventDefault();
e.stopPropagation();
dropzone.removeClass('active');
handleFiles(e.originalEvent.dataTransfer.files);
});
// --- START FIX 2: Modify click handlers ---
browseBtn.on('click', (e) => {
e.preventDefault();
e.stopPropagation(); // Stop this click from bubbling up to the dropzone
fileInput.click();
});
dropzone.on('click', function(e) {
// If the click target was the button or the file input, do nothing.
if ($(e.target).closest('.doc-browse-btn').length > 0 || $(e.target).hasClass('doc-file-input')) {
return;
}
// Otherwise, trigger the file input click.
fileInput.click();
});
// --- END FIX 2 ---
fileInput.on('change', (e) => {
handleFiles(e.target.files);
fileInput.val(null);
});
stagingArea.on('click', '.doc-staging-remove', function() {
const item = $(this).closest('.document-staging-item');
const fileId = item.data('file-id');
stagedFiles.delete(fileId);
item.fadeOut(300, function() {
$(this).remove();
updateUploadButton();
});
});
uploadBtn.on('click', async function() {
let allValid = true;
const formData = new FormData();
formData.append('preorderId', preorderId);
stagingArea.find('.document-staging-item').each(function() {
const item = $(this);
const fileId = item.data('file-id');
const file = stagedFiles.get(fileId);
const descriptionInput = item.find('.doc-staging-description');
const description = descriptionInput.val().trim();
if (!description) {
descriptionInput.addClass('is-invalid');
allValid = false;
} else {
descriptionInput.removeClass('is-invalid');
formData.append('files[]', file, file.name);
formData.append('descriptions[]', description);
} }
}); });
if (!check) { if (!allValid) {
$(item).remove(); if (window.notify) window.notify('error', 'Bitte füllen Sie alle Beschreibungsfelder aus.');
return;
} }
uploadBtn.prop('disabled', true);
uploadSpinner.show();
try {
const response = await fetch('/Preorder/uploadDocuments', {
method: 'POST',
body: formData
});
const result = await response.json();
if (response.ok && result.success) {
if (window.notify) window.notify('success', result.message || 'Dateien erfolgreich hochgeladen.');
stagedFiles.clear();
stagingArea.empty().hide();
updateUploadButton();
if (result.fileObjects) tabPane.data('file-objects', result.fileObjects);
loadDocuments();
} else {
throw new Error(result.message || 'Unbekannter Fehler beim Upload.');
}
} catch (err) {
if (window.notify) window.notify('error', `Upload fehlgeschlagen: ${err.message}`);
} finally {
uploadBtn.prop('disabled', false);
uploadSpinner.hide();
}
}); });
listTbody.on('click', '.doc-preview-btn', function() {
if ($(this).hasClass('disabled')) return;
const url = $(this).data('url');
const type = $(this).data('type');
const filename = $(this).data('filename');
modalTitle.text(filename);
modalBody.empty();
if (type.startsWith('image/')) {
modalBody.html(`<img src="${url}" class="img-fluid" alt="Vorschau von ${filename}">`);
} else if (type === 'application/pdf') {
window.open(url, '_blank');
return;
} else {
modalBody.html(`<div class="text-center p-5"><i class="fas fa-file-excel fa-3x text-muted mb-3"></i><p>Keine Vorschau für den Dateityp "${type}" verfügbar.</p></div>`);
} }
modal.modal('show');
});
});
}
$('a[data-toggle="tab"]').on('shown.bs.tab', function(e) {
const targetId = $(e.target).attr('href');
if (!targetId || !targetId.endsWith('-documents')) return;
const tabPane = $(targetId);
if (tabPane.hasClass('preorder-documents-tab') && tabPane.data('loaded') === false) {
const loaderFunc = tabPane.data('loadDocumentsFunction');
if (typeof loaderFunc === 'function') {
loaderFunc();
tabPane.data('loaded', true);
}
}
});
initPreorderDocumentTabs();
}); });
</script> </script>

View File

@@ -22,6 +22,7 @@
<li class="nav-item"><a class="nav-link" href="#preorder-detail-<?=$preorder->id?>-history" data-toggle="tab" aria-expanded="false">History</a></li> <li class="nav-item"><a class="nav-link" href="#preorder-detail-<?=$preorder->id?>-history" data-toggle="tab" aria-expanded="false">History</a></li>
<li class="nav-item"><a class="nav-link" href="#preorder-detail-<?=$preorder->id?>-emails" data-toggle="tab" aria-expanded="false">E-Mails</a></li> <li class="nav-item"><a class="nav-link" href="#preorder-detail-<?=$preorder->id?>-emails" data-toggle="tab" aria-expanded="false">E-Mails</a></li>
<li class="nav-item"><a class="nav-link" href="#preorder-detail-<?=$preorder->id?>-documents" data-toggle="tab" aria-expanded="false">Dokumente</a></li>
</ul> </ul>
</div> </div>
@@ -1162,6 +1163,125 @@
</div> </div>
</div> </div>
<div id="preorder-detail-<?=$preorder->id?>-documents"
class="tab-pane preorder-documents-tab"
data-preorder-id="<?=$preorder->id?>"
data-file-objects="<?=htmlentities($preorder->files ?? '[]')?>"
data-loaded="false">
<div class="card">
<div class="card-body">
<div class="row">
<div class="col-lg-8">
<div class="card-header bg-info text-white pl-2 pr-2 pt-1 pb-1">Hochgeladene Dokumente</div>
<div class="document-list-wrapper">
<div class="document-list-loader text-center p-5" style="display: none;">
<div class="doc-spinner"></div>
<p class="mt-3 text-muted">Lade Dokumente...</p>
</div>
<div class="document-empty-state text-center p-5" style="display: none;">
<i class="fas fa-file-excel fa-3x text-muted mb-3"></i>
<h5>Keine Dokumente gefunden</h5>
<p class="text-muted">Für diese Bestellung wurden noch keine Dokumente hochgeladen.</p>
</div>
<div class="document-list-container table-responsive" style="display: none;">
<table class="table table-sm table-striped table-hover">
<thead class="thead-light">
<tr>
<th style="width: 5%;">Typ</th>
<th>Dateiname</th>
<th>Beschreibung</th>
<th style="width: 15%;" class="text-right">Aktionen</th>
</tr>
</thead>
<tbody class="doc-list-tbody">
</tbody>
</table>
</div>
</div>
</div>
<div class="col-lg-4 mt-3 mt-lg-0">
<div class="card-header bg-secondary text-white pl-2 pr-2 pt-1 pb-1">Neuer Upload</div>
<div class="document-upload-wrapper p-3">
<div class="doc-dropzone document-dropzone">
<div class="document-dropzone-internal">
<i class="fas fa-cloud-upload-alt fa-2x text-muted mb-2"></i>
<p class="mb-2">Dateien hier ablegen oder</p>
<button type="button" class="btn btn-primary btn-sm doc-browse-btn">Datei auswählen</button>
<input type="file" class="doc-file-input" multiple style="display: none;" />
</div>
</div>
<div class="doc-staging-area document-staging-area mt-3" style="display: none;">
</div>
<button class="btn btn-success btn-block mt-2 doc-upload-btn" style="display: none;">
<i class="fas fa-fw fa-upload"></i> <span class="doc-upload-btn-text">Hochladen</span>
<i class="fas fa-spinner fa-spin ml-1 doc-upload-spinner" style="display: none;"></i>
</button>
</div>
</div>
</div>
</div>
</div>
<template class="doc-staging-item-template">
<div class="document-staging-item">
<div class="doc-staging-icon">
<i class="fas fa-file-alt text-muted"></i>
</div>
<div class="doc-staging-details">
<div class="doc-staging-filename">filename.jpg</div>
<div class="doc-staging-filesize text-muted small">1.2 MB</div>
<input type="text" class="form-control form-control-sm mt-1 doc-staging-description" placeholder="Beschreibung (erforderlich)" required />
<div class="invalid-feedback">Beschreibung fehlt.</div>
</div>
<div class="doc-staging-actions">
<button type="button" class="btn btn-sm btn-outline-danger doc-staging-remove" title="Entfernen">
<i class="fas fa-times"></i>
</button>
</div>
</div>
</template>
<template class="doc-list-row-template">
<tr class="document-list-row">
<td class="doc-row-icon text-center align-middle">
</td>
<td class="doc-row-filename align-middle">filename.pdf</td>
<td class="doc-row-description align-middle small">Beschreibung...</td>
<td class="doc-row-actions text-right align-middle">
<button type="button" class="btn btn-sm btn-outline-info doc-preview-btn" title="Vorschau">
<i class="fas fa-eye"></i>
</button>
<a href="#" class="btn btn-sm btn-outline-secondary doc-download-btn" title="Download" download>
<i class="fas fa-download"></i>
</a>
</td>
</tr>
</template>
<div class="modal fade doc-preview-modal" id="doc-preview-modal-<?=$preorder->id?>" tabindex="-1" role="dialog" aria-labelledby="doc-preview-modal-title-<?=$preorder->id?>" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title doc-preview-modal-title" id="doc-preview-modal-title-<?=$preorder->id?>">Vorschau</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body doc-preview-modal-body" id="doc-preview-modal-body-<?=$preorder->id?>" style="min-height: 70vh; display: flex; justify-content: center; align-items: center;">
</div>
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>

View File

@@ -35,6 +35,7 @@ class FileController extends mfBaseController {
} }
if(preg_match('/\.([^.]+)/',$filename,$m)) { if(preg_match('/\.([^.]+)/',$filename,$m)) {
if (!isset($ext)) $ext = '';
$ext .= $m[1]; $ext .= $m[1];
} else { } else {
throw new Exception("File not found", 4042); throw new Exception("File not found", 4042);

View File

@@ -1985,4 +1985,56 @@ class PreorderController extends mfBaseController {
unlink($filename); unlink($filename);
exit; exit;
} }
protected function uploadDocumentsAction() {
if (empty($_FILES['files']) || empty($_POST['preorderId']) || empty($_POST['descriptions'])) {
self::sendError('Erforderliche Daten fehlen (Dateien, preorderId oder Beschreibungen).');
}
$preorderId = $_POST['preorderId'];
$descriptions = $_POST['descriptions'];
if (count($_FILES['files']['name']) !== count($descriptions)) {
self::sendError('Anzahl der Dateien und Beschreibungen stimmt nicht überein.');
}
$preorder = new Preorder($preorderId);
if (!$preorder->id) {
self::sendError('Bestellung nicht gefunden.');
}
$fileObjects = json_decode($preorder->files, true);
if (!is_array($fileObjects)) $fileObjects = [];
foreach ($_FILES['files']['name'] as $index => $name) {
if ($_FILES['files']['error'][$index] !== UPLOAD_ERR_OK) continue;
$_FILES['file'] = [
'name' => $name,
'type' => $_FILES['files']['type'][$index],
'tmp_name' => $_FILES['files']['tmp_name'][$index],
'error' => $_FILES['files']['error'][$index],
'size' => $_FILES['files']['size'][$index]
];
$description = trim($descriptions[$index]);
if (empty($description)) continue;
try {
$uploaded = mfUpload::handleFormUpload("file", false, "/PreorderDocuments");
$fileObjects[] = ["id" => $uploaded->id, "description" => $description];
} catch (Exception $e) {}
}
$db = FronkDB::singleton();
$escapedFilesJson = $db->escape(json_encode($fileObjects));
$sql = "UPDATE `" . FRONKDB_DBNAME . "`.Preorder SET files = '$escapedFilesJson' WHERE id = " . (int)$preorder->id;
$db->query($sql);
self::returnJson([
'success' => true,
'message' => "Datei(en) erfolgreich hochgeladen.",
'fileObjects' => $fileObjects
]);
}
} }

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class PreorderAddFiles extends AbstractMigration {
public function up(): void {
if($this->getEnvironment() == "thetool") {
$Preorder = $this->table("Preorder");
$Preorder->addColumn("files", "text", ['null' => true, 'after' => 'notes']);
$Preorder->update();
}
}
public function down(): void {
if($this->getEnvironment() == "thetool") {
$this->table("Preorder")->removeColumn("files")->save();
}
}
}