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:
@@ -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">— not linked —</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>
|
||||
|
||||
@@ -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 — {product.title}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
<a href="{f:be.uri(route: 'web_vitecimport.products')}">← back to product list</a>
|
||||
<small class="text-muted">uid {product.uid} · {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> · loaded {workbook.date} ·
|
||||
</f:if>
|
||||
<code>{metaUrl}</code> · 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} — 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="">— do not import —</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} — {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">— none —</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 — nothing is written.
|
||||
Only ticked rows are written on "Apply selected".
|
||||
</p>
|
||||
|
||||
</f:if>
|
||||
|
||||
</form>
|
||||
|
||||
</f:section>
|
||||
</html>
|
||||
@@ -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 — 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 …" />
|
||||
</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>
|
||||
Reference in New Issue
Block a user