Product text import from agency XLSX workbooks

VITEC Import module: Products tab is now a searchable/sortable product
overview. "Edit Product" opens an XLSX upload with per-field source
mapping (component + cell), old/new preview and checkbox apply via
DataHandler; the mapping and manual matches are persisted and preselect
the next workbook. Category workbooks are detected and rejected.

- new: ProductXlsxReader (PhpSpreadsheet), ProductTextImportController,
  Products/ProductTexts templates, 4 module routes
- related products: per-pair card text in new side table
  tx_vitec_product_related_text (survives MM rewrites), emitted as
  `cardtext` in the product JSON; missing MM relations added add-only
- card copy read from Body Copy (D) with fallback to CTA/Card Copy (E) -
  the workbooks fill either depending on row type
- product detail page <title> now uses seotitle with title fallback
  (provider made singleton and fed from the JSON renderer)
- per-user recent-search badges; last loaded workbook stored per product
  (tx_vitec_product_workbook), Edit Product reopens on it
- composer: add phpoffice/phpspreadsheet ^5.9
- diagnostics: migrations/check_workbook.php
This commit is contained in:
2026-08-21 11:00:45 +02:00
parent 12eb40614b
commit 7bd1161abf
28 changed files with 2594 additions and 39 deletions

View File

@@ -11,14 +11,21 @@
</div>
</div>
<!-- Model tabs -->
<!-- Model tabs. Products left the CSV flow on 2026-08-21: its tab now leads to
the XLSX product-text overview (ProductTextImportController); the CSV code
path for products stays functional but is no longer linked. -->
<ul class="nav nav-tabs" style="margin-bottom:1rem;">
<f:for each="{models}" as="m">
<li class="nav-item">
<a class="nav-link {f:if(condition: '{m.key} == {model.key}', then: 'active')}"
href="{f:be.uri(route: 'web_vitecimport', parameters: {model: m.key})}">{m.label}</a>
</li>
<f:if condition="{m.key} != 'product'">
<li class="nav-item">
<a class="nav-link {f:if(condition: '{m.key} == {model.key}', then: 'active')}"
href="{f:be.uri(route: 'web_vitecimport', parameters: {model: m.key})}">{m.label}</a>
</li>
</f:if>
</f:for>
<li class="nav-item">
<a class="nav-link" href="{f:be.uri(route: 'web_vitecimport.products')}">Products</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{f:be.uri(route: 'web_vitecimport.seo')}">SEO Research</a>
</li>
@@ -134,6 +141,7 @@
<f:case value="unchanged"><span class="vitec-badge vitec-badge-unchanged">unchanged</span></f:case>
<f:defaultCase><span class="vitec-badge vitec-badge-dbonly">only in DB</span></f:defaultCase>
</f:switch>
<f:if condition="{row.viaAlias}"><br /><span class="vitec-badge vitec-badge-info">linked</span></f:if>
</td>
<td>
<strong>{row.label}</strong>
@@ -141,6 +149,14 @@
<f:if condition="{row.status} == 'update'">
<br /><small class="text-muted">changes: {row.diff}</small>
</f:if>
<f:if condition="{row.aliasable}">
<select name="alias[{row.index}]" class="form-select form-select-sm" style="margin-top:4px;">
<option value="0">&mdash; not linked &mdash;</option>
<f:for each="{aliasCandidates}" as="cand">
<option value="{cand.uid}" {f:if(condition: '{cand.uid} == {row.aliasUid}', then: 'selected')}>{cand.title} (uid {cand.uid})</option>
</f:for>
</select>
</f:if>
</td>
<td>
<f:if condition="{row.status} != 'dbonly'">
@@ -156,10 +172,18 @@
<div class="row row-cols-auto align-items-center g-2">
<div class="col"><label for="pid"><strong>Storage pid for new records</strong></label></div>
<div class="col"><input type="number" name="pid" id="pid" value="{pid}" class="form-control form-control-sm" style="width:110px;" /></div>
<div class="col">
<button type="submit" name="op" value="saveAliases" class="btn btn-default">Save record links</button>
</div>
<div class="col">
<button type="submit" name="op" value="apply" class="btn btn-primary"
onclick="return confirm('Apply the checked rows to the database?');">Apply checked rows</button>
</div>
<div class="col form-text">
A "new" row that exists in TYPO3 under a different name: pick the record in
its select, then "Save record links" - the link is stored per model and
re-applied on every upload.
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,190 @@
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers"
data-namespace-typo3-fluid="true">
<f:layout name="Backend/Default" />
<f:section name="main">
<div class="module-docheader-bar module-docheader-bar-navigation">
<div class="module-docheader-bar-column-left">
<h2 class="t3js-title-inlineedit">Edit Product &mdash; {product.title}</h2>
</div>
</div>
<p>
<a href="{f:be.uri(route: 'web_vitecimport.products')}">&larr; back to product list</a>
&nbsp;&nbsp;<small class="text-muted">uid {product.uid} &middot; {product.slug}</small>
</p>
<form action="{f:be.uri(route: 'web_vitecimport.product_preview')}" method="post" enctype="multipart/form-data">
<input type="hidden" name="uid" value="{product.uid}" />
<input type="hidden" name="parsed" value="{parsedJson}" />
<!-- Upload -->
<div class="card" style="margin-bottom:1rem;">
<div class="card-body">
<div class="row row-cols-auto align-items-center g-2">
<div class="col">
<input type="file" class="form-control" name="xlsxfile" accept=".xlsx,.xls" />
</div>
<div class="col">
<button type="submit" class="btn btn-default">Load workbook</button>
</div>
<f:if condition="{parsed}">
<div class="col text-muted">
<f:if condition="{workbook}">
<strong>{workbook.filename}</strong> &middot; loaded {workbook.date} &middot;
</f:if>
<code>{metaUrl}</code> &middot; page type <strong>{pageType}</strong>
</div>
</f:if>
</div>
</div>
</div>
<f:if condition="{parsed}">
<f:if condition="{slugMismatch}">
<div class="callout callout-warning">
<div class="callout-body">
<strong>URL mismatch:</strong> {slugMismatch} &mdash; is this the right product for this workbook?
</div>
</div>
</f:if>
<!-- Mapping + preview -->
<table class="table table-striped">
<thead>
<tr>
<th style="width:11rem;">Product field</th>
<th style="width:26rem;">Source in workbook</th>
<th>Current value</th>
<th>New value</th>
<th style="width:5rem;">Apply</th>
</tr>
</thead>
<tbody>
<f:for each="{rows}" as="row">
<tr>
<td><strong>{row.label}</strong><br /><small class="text-muted">{row.field}</small></td>
<td>
<select class="form-select form-select-sm" name="map[{row.field}]">
<option value="">&mdash; do not import &mdash;</option>
<f:for each="{sources}" as="group" key="groupName">
<optgroup label="{groupName}">
<f:for each="{group}" as="src">
<option value="{src.selector}" {f:if(condition: '{src.selector} == {row.selector}', then: 'selected="selected"')}>{src.label} &mdash; {src.preview}</option>
</f:for>
</optgroup>
</f:for>
</select>
</td>
<td><small>{row.oldPreview}</small></td>
<td>
<f:if condition="{row.new}">
<f:then>
<small>{row.newPreview}</small>
<f:if condition="{row.changed}"><br /><span class="badge bg-warning">differs</span></f:if>
</f:then>
<f:else>
<f:if condition="{row.selector}">
<small class="text-muted">not in this file</small>
</f:if>
</f:else>
</f:if>
</td>
<td>
<input class="form-check-input" type="checkbox" name="apply[]" value="{row.field}"
{f:if(condition: row.changed, then: 'checked="checked"')} />
</td>
</tr>
</f:for>
</tbody>
</table>
<f:if condition="{relatedRows}">
<div class="card" style="margin-bottom:1rem;">
<div class="card-header">Related products from the workbook</div>
<div class="card-body">
<p class="text-muted">
Each ticked card writes its text and adds the missing relation
(existing relations and their order are never touched, nothing is removed).
Your manual choices are remembered for the next workbook.
</p>
<table class="table table-striped">
<thead>
<tr>
<th style="width:11rem;">Card in workbook</th>
<th style="width:20rem;">Target product</th>
<th>Card text (new)</th>
<th>Card text (current)</th>
<th style="width:5rem;">Apply</th>
</tr>
</thead>
<tbody>
<f:for each="{relatedRows}" as="rel">
<tr>
<td>
<strong>{rel.title}</strong>
<f:if condition="{rel.cta}"><br /><small class="text-muted">CTA: {rel.cta}</small></f:if>
</td>
<td>
<select class="form-select form-select-sm" name="related[{rel.index}]">
<option value="0">&mdash; none &mdash;</option>
<f:for each="{productOptions}" as="opt">
<option value="{opt.uid}" {f:if(condition: '{opt.uid} == {rel.selected}', then: 'selected="selected"')}>{opt.title}</option>
</f:for>
</select>
<f:if condition="{rel.status} == 'alias'"><span class="badge bg-info">remembered</span></f:if>
<f:if condition="{rel.status} == 'title'"><span class="badge bg-success">title match</span></f:if>
<f:if condition="{rel.status} == 'none'"><span class="badge bg-warning">no match</span></f:if>
<f:if condition="{rel.selected}">
<f:if condition="{rel.isRelated}">
<f:else><span class="badge bg-secondary">relation will be added</span></f:else>
</f:if>
</f:if>
</td>
<td><small>{rel.preview}</small></td>
<td>
<small>{rel.oldPreview}</small>
<f:if condition="{rel.changed}"><br /><span class="badge bg-warning">differs</span></f:if>
</td>
<td>
<input class="form-check-input" type="checkbox" name="applyRelated[]" value="{rel.index}"
{f:if(condition: rel.changed, then: 'checked="checked"')} />
</td>
</tr>
</f:for>
</tbody>
</table>
</div>
</div>
</f:if>
<div class="row row-cols-auto align-items-center g-2" style="margin-bottom:2rem;">
<div class="col">
<button type="submit" class="btn btn-default">Refresh preview</button>
</div>
<div class="col">
<button type="submit" class="btn btn-primary"
formaction="{f:be.uri(route: 'web_vitecimport.product_apply')}">Apply selected</button>
</div>
<div class="col">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="saveMapping" id="saveMapping" value="1" checked="checked" />
<label class="form-check-label" for="saveMapping">Save mapping for the next product</label>
</div>
</div>
</div>
<p class="text-muted">
"Refresh preview" re-reads the values after changing a dropdown &mdash; nothing is written.
Only ticked rows are written on "Apply selected".
</p>
</f:if>
</form>
</f:section>
</html>

View File

@@ -0,0 +1,99 @@
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers"
data-namespace-typo3-fluid="true">
<f:layout name="Backend/Default" />
<f:section name="main">
<div class="module-docheader-bar module-docheader-bar-navigation">
<div class="module-docheader-bar-column-left">
<h2 class="t3js-title-inlineedit">VITEC Import &mdash; Products</h2>
</div>
</div>
<!-- Tabs: Markets/Solutions stay on the CSV flow, Products is this overview -->
<ul class="nav nav-tabs" style="margin-bottom:1rem;">
<f:for each="{models}" as="m">
<f:if condition="{m.key} != 'product'">
<li class="nav-item">
<a class="nav-link" href="{f:be.uri(route: 'web_vitecimport', parameters: {model: m.key})}">{m.label}</a>
</li>
</f:if>
</f:for>
<li class="nav-item">
<a class="nav-link active" href="{f:be.uri(route: 'web_vitecimport.products')}">Products</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{f:be.uri(route: 'web_vitecimport.seo')}">SEO Research</a>
</li>
</ul>
<!-- Search -->
<div class="card" style="margin-bottom:1rem;">
<div class="card-body">
<form action="{f:be.uri(route: 'web_vitecimport.products')}" method="post" class="row row-cols-auto align-items-center g-2">
<div class="col">
<input type="text" class="form-control" name="q" value="{q}" placeholder="Search title or slug &hellip;" />
</div>
<div class="col">
<button type="submit" class="btn btn-default">Search</button>
</div>
<f:if condition="{q}">
<div class="col">
<a class="btn btn-link" href="{f:be.uri(route: 'web_vitecimport.products')}">Reset</a>
</div>
</f:if>
<div class="col text-muted">{total} products</div>
</form>
<f:if condition="{searchHistory}">
<div style="margin-top:0.6rem;">
<small class="text-muted" style="margin-right:0.4rem;">Recent:</small>
<f:for each="{searchHistory}" as="term">
<a class="badge {f:if(condition: '{term} == {q}', then: 'bg-primary', else: 'bg-secondary')}"
style="text-decoration:none;margin-right:0.3rem;"
href="{f:be.uri(route: 'web_vitecimport.products', parameters: {q: term})}">{term}</a>
</f:for>
</div>
</f:if>
</div>
</div>
<table class="table table-striped table-hover">
<thead>
<tr>
<th>
<a href="{f:be.uri(route: 'web_vitecimport.products', parameters: {q: q, sort: 'title', dir: sortLinks.title})}">Title {sortArrows.title}</a>
</th>
<th>
<a href="{f:be.uri(route: 'web_vitecimport.products', parameters: {q: q, sort: 'slug', dir: sortLinks.slug})}">Slug {sortArrows.slug}</a>
</th>
<th style="width:10rem;">
<a href="{f:be.uri(route: 'web_vitecimport.products', parameters: {q: q, sort: 'tstamp', dir: sortLinks.tstamp})}">Changed {sortArrows.tstamp}</a>
</th>
<th style="width:10rem;">Content</th>
<th style="width:9rem;"></th>
</tr>
</thead>
<tbody>
<f:for each="{products}" as="p">
<tr>
<td>
{p.title}
<f:if condition="{p.hidden}"> <span class="badge bg-secondary">hidden</span></f:if>
</td>
<td><small class="text-muted">{p.slug}</small></td>
<td>{p.changed}</td>
<td>
<span class="vitec-badge">{f:if(condition: p.hasTeaser, then: '✓', else: '')} teaser</span>
<span class="vitec-badge">{f:if(condition: p.hasDescription, then: '✓', else: '')} descr.</span>
</td>
<td>
<a class="btn btn-primary btn-sm" href="{f:be.uri(route: 'web_vitecimport.product_edit', parameters: {uid: p.uid})}">Edit Product</a>
</td>
</tr>
</f:for>
</tbody>
</table>
</f:section>
</html>