- 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>
430 lines
20 KiB
Twig
430 lines
20 KiB
Twig
{% 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 & 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,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||
})();
|
||
</script>
|
||
|
||
{% endblock %}
|