SuperSeller3000/templates/admin/manual_ingest.html.twig
Simon Kuehn 6241398390 feat: camera capture and multi-photo upload at article ingest
- Ingest page redesigned: camera modal (getUserMedia + capture fallback
  for mobile), mandatory search photo with client-side validation,
  optional extra photos grid with per-photo remove button
- Camera modal: live video preview, capture → preview → confirm/retake
  flow; stops stream on modal close; falls back to native file picker
  if getUserMedia is unavailable
- ManualIngestController: uploads extra photos via PhotoService::uploadRaw(),
  stores [{storagePathId, filename}] in job inputData as extraPhotos
- PhotoService::attachExtra(): attach already-stored file to an article
  by StoragePath ID + filename
- DraftArticleHandler: after creating the article, attaches extra photos
  in sort order; errors are best-effort (pipeline not aborted)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 09:16:04 +00:00

430 lines
20 KiB
Twig
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.

{% extends '@EasyAdmin/page/content.html.twig' %}
{% block page_title %}Artikel einlesen{% endblock %}
{% block main %}
{% if catalogNumber %}
<div class="alert alert-success d-flex align-items-center gap-3 mb-4" role="alert">
<i class="fa fa-check-circle fa-2x"></i>
<div>
<strong>In der Pipeline —</strong>
<span class="fs-4 ms-2 font-monospace fw-bold">{{ catalogNumber }}</span>
<div class="small mt-1">
<a href="{{ ea_url().setController('App\\Infrastructure\\Http\\Controller\\Admin\\AIPipelineJobCrudController').setAction('index').generateUrl() }}">Job-Status ansehen →</a>
</div>
</div>
</div>
{% endif %}
{# ── Camera Modal ─────────────────────────────────────────────────── #}
<div class="modal fade" id="cameraModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="cameraModalLabel"><i class="fa fa-camera me-2"></i>Foto aufnehmen</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body p-0 bg-black text-center" style="min-height:300px;display:flex;align-items:center;justify-content:center;">
<video id="camera-video" autoplay playsinline muted
style="max-width:100%;max-height:420px;display:block;"></video>
<canvas id="camera-canvas" style="display:none;max-width:100%;max-height:420px;"></canvas>
<div id="camera-error" class="text-white p-4" style="display:none;">
<i class="fa fa-exclamation-circle fa-2x mb-2 d-block text-warning"></i>
Kein Kamerazugriff. Bitte Datei hochladen.
</div>
</div>
<div class="modal-footer justify-content-center gap-2">
<button id="camera-capture-btn" class="btn btn-primary btn-lg" style="display:none;">
<i class="fa fa-circle me-1" style="color:#e74c3c;"></i> Aufnehmen
</button>
<button id="camera-retake-btn" class="btn btn-secondary" style="display:none;">
<i class="fa fa-redo me-1"></i> Wiederholen
</button>
<button id="camera-confirm-btn" class="btn btn-success btn-lg" style="display:none;">
<i class="fa fa-check me-1"></i> Verwenden
</button>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Abbrechen</button>
</div>
</div>
</div>
</div>
<div class="row g-4">
{# ── Left: form ───────────────────────────────────────────────── #}
<div class="col-lg-7">
{{ form_start(form, {'attr': {'enctype': 'multipart/form-data', 'id': 'ingest-form', 'novalidate': 'novalidate'}}) }}
<div class="card mb-3">
<div class="card-header"><h5 class="mb-0"><i class="fa fa-tag me-2"></i>Artikelinfo</h5></div>
<div class="card-body">
<div class="mb-3">
{{ form_label(form.articleType) }}
{{ form_widget(form.articleType) }}
{{ form_errors(form.articleType) }}
</div>
<div class="row g-3">
<div class="col-sm-6">
{{ form_label(form.condition) }}
{{ form_widget(form.condition) }}
</div>
<div class="col-sm-6">
{{ form_label(form.conditionNotes) }}
{{ form_widget(form.conditionNotes, {'attr': {'class': 'form-control'}}) }}
</div>
</div>
</div>
</div>
{# ── Search photo (mandatory) ─────────────────────────────── #}
<div class="card mb-3 border-primary">
<div class="card-header bg-primary bg-opacity-10">
<h5 class="mb-0 text-primary">
<i class="fa fa-search me-2"></i>Erkennungs-Foto
<span class="badge bg-danger ms-2 fs-6">Pflicht</span>
</h5>
<div class="text-muted small mt-1">Typenschild / Aufkleber mit Modell &amp; Seriennummer</div>
</div>
<div class="card-body">
{# Hidden actual file input #}
{{ form_widget(form.image, {'attr': {'class': 'd-none', 'id': 'search-photo-input'}}) }}
{{ form_errors(form.image) }}
{# Preview #}
<div id="search-photo-preview" class="mb-3 text-center" style="display:none;">
<img id="search-photo-img" src="" alt="Vorschau"
class="img-fluid rounded border" style="max-height:220px;">
<div class="mt-2">
<button type="button" class="btn btn-sm btn-outline-secondary" id="search-photo-clear">
<i class="fa fa-times me-1"></i>Entfernen
</button>
</div>
</div>
{# Placeholder when empty #}
<div id="search-photo-placeholder" class="border rounded text-center p-4 text-muted bg-light">
<i class="fa fa-image fa-3x mb-3 d-block"></i>
Noch kein Foto ausgewählt
</div>
<div class="d-flex gap-2 mt-3">
<button type="button" class="btn btn-primary flex-fill" id="btn-search-camera">
<i class="fa fa-camera me-2"></i>Kamera
</button>
<button type="button" class="btn btn-outline-secondary flex-fill" id="btn-search-file">
<i class="fa fa-folder-open me-2"></i>Datei wählen
</button>
</div>
</div>
</div>
{# ── Additional photos (optional) ─────────────────────────── #}
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0"><i class="fa fa-images me-2"></i>Weitere Fotos <span class="text-muted fw-normal fs-6">(optional)</span></h5>
</div>
<div class="card-body">
{# Hidden input that holds extra files #}
<input type="file" name="additional_photos[]" id="extra-photos-input"
accept="image/jpeg,image/png,image/webp" multiple class="d-none">
<div id="extra-photos-grid" class="d-flex flex-wrap gap-2 mb-3" style="min-height:60px;">
<div id="extra-photos-empty" class="text-muted small fst-italic d-flex align-items-center">
Keine weiteren Fotos hinzugefügt
</div>
</div>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-primary flex-fill" id="btn-extra-camera">
<i class="fa fa-camera me-2"></i>Kamera
</button>
<button type="button" class="btn btn-outline-secondary flex-fill" id="btn-extra-file">
<i class="fa fa-folder-open me-2"></i>Datei wählen
</button>
</div>
</div>
</div>
<button type="submit" class="btn btn-success btn-lg w-100" id="submit-btn">
<i class="fa fa-rocket me-2"></i>Zur KI-Pipeline senden
</button>
{{ form_end(form) }}
</div>
{# ── Right: AI config ─────────────────────────────────────────── #}
<div class="col-lg-5">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0"><i class="fa fa-robot me-2"></i>KI-Konfiguration</h5>
<span class="badge bg-{{ aiConfig.backend == 'Mistral' ? 'primary' : 'secondary' }}">
{{ aiConfig.backend }} aktiv
</span>
</div>
<div class="card-body p-0">
<table class="table table-sm table-borderless mb-0">
<tbody>
<tr>
<th class="ps-3 text-muted fw-normal" style="width:45%">Vision-Modell</th>
<td class="font-monospace">{{ aiConfig.vision_model }}</td>
</tr>
<tr>
<th class="ps-3 text-muted fw-normal">Text-Modell</th>
<td class="font-monospace">{{ aiConfig.text_model }}</td>
</tr>
<tr>
<th class="ps-3 text-muted fw-normal">Mistral Endpoint</th>
<td class="font-monospace small">{{ aiConfig.mistral_base_url }}</td>
</tr>
<tr>
<th class="ps-3 text-muted fw-normal">Mistral API-Key</th>
<td>
{% if aiConfig.mistral_key_set %}
<span class="badge bg-success me-1">gesetzt</span>
<span class="font-monospace small text-muted">{{ aiConfig.mistral_key_hint }}</span>
{% else %}
<span class="badge bg-danger">nicht gesetzt</span>
{% endif %}
</td>
</tr>
</tbody>
</table>
</div>
<div class="card-footer">
<button id="btn-test-mistral" class="btn btn-outline-primary btn-sm w-100"
{% if not aiConfig.mistral_key_set %}disabled title="MISTRAL_API_KEY setzen"{% endif %}>
<i class="fa fa-plug me-1"></i>Mistral-Verbindung testen
</button>
<div id="test-result" class="mt-3" style="display:none"></div>
</div>
</div>
</div>
</div>
<script>
(function () {
/* ── Camera state ──────────────────────────────────────────── */
const video = document.getElementById('camera-video');
const canvas = document.getElementById('camera-canvas');
const errDiv = document.getElementById('camera-error');
const btnCapture = document.getElementById('camera-capture-btn');
const btnRetake = document.getElementById('camera-retake-btn');
const btnConfirm = document.getElementById('camera-confirm-btn');
let cameraStream = null;
let cameraTarget = null; // 'search' | 'extra'
let capturedBlob = null;
const modal = new bootstrap.Modal(document.getElementById('cameraModal'));
async function openCamera(target) {
cameraTarget = target;
capturedBlob = null;
canvas.style.display = 'none';
errDiv.style.display = 'none';
video.style.display = 'block';
btnCapture.style.display = 'none';
btnRetake.style.display = 'none';
btnConfirm.style.display = 'none';
modal.show();
try {
cameraStream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: { ideal: 'environment' }, width: { ideal: 1920 } },
});
video.srcObject = cameraStream;
await video.play();
btnCapture.style.display = 'inline-block';
} catch {
stopCamera();
video.style.display = 'none';
errDiv.style.display = 'block';
// Mobile fallback: trigger native camera input
if (cameraTarget === 'search') {
document.getElementById('search-photo-input').click();
} else {
document.getElementById('extra-photos-input').click();
}
modal.hide();
}
}
function stopCamera() {
cameraStream?.getTracks().forEach(t => t.stop());
cameraStream = null;
}
document.getElementById('cameraModal').addEventListener('hidden.bs.modal', stopCamera);
btnCapture.addEventListener('click', () => {
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
canvas.getContext('2d').drawImage(video, 0, 0);
stopCamera();
video.style.display = 'none';
canvas.style.display = 'block';
btnCapture.style.display = 'none';
btnRetake.style.display = 'inline-block';
btnConfirm.style.display = 'inline-block';
canvas.toBlob(b => { capturedBlob = b; }, 'image/jpeg', 0.92);
});
btnRetake.addEventListener('click', () => openCamera(cameraTarget));
btnConfirm.addEventListener('click', () => {
if (!capturedBlob) return;
const file = new File([capturedBlob], 'kamera-' + Date.now() + '.jpg', { type: 'image/jpeg' });
if (cameraTarget === 'search') {
setSearchPhoto(file);
} else {
addExtraPhoto(file);
}
modal.hide();
});
/* ── Search photo ──────────────────────────────────────────── */
const searchInput = document.getElementById('search-photo-input');
const searchPreview = document.getElementById('search-photo-preview');
const searchImg = document.getElementById('search-photo-img');
const searchHolder = document.getElementById('search-photo-placeholder');
document.getElementById('btn-search-camera').addEventListener('click', () => openCamera('search'));
document.getElementById('btn-search-file').addEventListener('click', () => searchInput.click());
document.getElementById('search-photo-clear').addEventListener('click', () => {
searchInput.value = '';
searchImg.src = '';
searchPreview.style.display = 'none';
searchHolder.style.display = 'block';
});
searchInput.addEventListener('change', () => {
if (searchInput.files[0]) setSearchPhoto(searchInput.files[0]);
});
function setSearchPhoto(file) {
const dt = new DataTransfer();
dt.items.add(file);
searchInput.files = dt.files;
searchImg.src = URL.createObjectURL(file);
searchPreview.style.display = 'block';
searchHolder.style.display = 'none';
}
/* ── Extra photos ──────────────────────────────────────────── */
const extraInput = document.getElementById('extra-photos-input');
const extraGrid = document.getElementById('extra-photos-grid');
const extraEmpty = document.getElementById('extra-photos-empty');
let extraFiles = [];
document.getElementById('btn-extra-camera').addEventListener('click', () => openCamera('extra'));
document.getElementById('btn-extra-file').addEventListener('click', () => extraInput.click());
extraInput.addEventListener('change', () => {
for (const f of extraInput.files) addExtraPhoto(f);
extraInput.value = '';
});
function addExtraPhoto(file) {
extraFiles.push(file);
syncExtraInput();
renderExtraGrid();
}
function removeExtraPhoto(idx) {
extraFiles.splice(idx, 1);
syncExtraInput();
renderExtraGrid();
}
function syncExtraInput() {
const dt = new DataTransfer();
extraFiles.forEach(f => dt.items.add(f));
extraInput.files = dt.files;
}
function renderExtraGrid() {
// remove all thumbnail cards (keep the empty placeholder)
extraGrid.querySelectorAll('.extra-thumb').forEach(el => el.remove());
extraEmpty.style.display = extraFiles.length === 0 ? 'flex' : 'none';
extraFiles.forEach((file, idx) => {
const card = document.createElement('div');
card.className = 'extra-thumb position-relative';
card.style.cssText = 'width:90px;height:90px;';
const img = document.createElement('img');
img.src = URL.createObjectURL(file);
img.className = 'w-100 h-100 rounded border object-fit-cover';
img.style.objectFit = 'cover';
const del = document.createElement('button');
del.type = 'button';
del.className = 'btn btn-danger btn-sm position-absolute top-0 end-0 p-0 d-flex align-items-center justify-content-center';
del.style.cssText = 'width:20px;height:20px;font-size:.65rem;line-height:1;border-radius:50%;transform:translate(40%,-40%);';
del.innerHTML = '×';
del.addEventListener('click', () => removeExtraPhoto(idx));
card.appendChild(img);
card.appendChild(del);
extraGrid.appendChild(card);
});
}
/* ── Submit validation ─────────────────────────────────────── */
document.getElementById('ingest-form').addEventListener('submit', e => {
if (!searchInput.files || searchInput.files.length === 0) {
e.preventDefault();
searchHolder.classList.add('border-danger');
searchHolder.innerHTML = '<i class="fa fa-exclamation-triangle fa-2x text-danger mb-2 d-block"></i>'
+ '<span class="text-danger fw-bold">Erkennungs-Foto ist Pflicht</span>';
searchHolder.style.display = 'block';
}
});
/* ── Mistral test ──────────────────────────────────────────── */
document.getElementById('btn-test-mistral')?.addEventListener('click', async function () {
const btn = this;
const resultDiv = document.getElementById('test-result');
btn.disabled = true;
btn.innerHTML = '<i class="fa fa-spinner fa-spin me-1"></i>Teste…';
resultDiv.style.display = 'none';
try {
const resp = await fetch('{{ path('admin_ai_test') }}', { method: 'POST', body: new FormData() });
const data = await resp.json();
resultDiv.style.display = 'block';
resultDiv.innerHTML = renderTestResult(data);
} catch (err) {
resultDiv.style.display = 'block';
resultDiv.innerHTML = '<div class="alert alert-danger py-2 mb-0">Fehler: ' + err.message + '</div>';
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="fa fa-plug me-1"></i>Mistral-Verbindung testen';
}
});
function renderTestResult(data) {
return '<table class="table table-sm mb-0">'
+ modelRow('Text', data.text)
+ modelRow('Vision', data.vision)
+ '</table>';
}
function modelRow(label, r) {
if (r?.ok) {
return `<tr>
<td><span class="badge bg-success">OK</span> <strong>${label}</strong></td>
<td class="font-monospace small">${e(r.model)}</td>
<td class="text-muted small">${r.ms} ms</td>
</tr><tr><td colspan="3" class="ps-3 text-muted small">${e(r.response ?? '')}</td></tr>`;
}
return `<tr>
<td><span class="badge bg-danger">FAIL</span> <strong>${label}</strong></td>
<td colspan="2" class="font-monospace small text-danger">${e(r?.error ?? 'Unbekannter Fehler')}</td>
</tr>`;
}
function e(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
})();
</script>
{% endblock %}