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:
orasche committed 2026-08-21 11:00:45 +02:00
1 parent 12eb40614b
commit 7bd1161abf
28 files changed
+2594 -39

No files matched your search

@@ -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>