feat: eBay category typeahead search for aspect import
Adds live category search so users don't need to know eBay category IDs. Typing in the search box hits /admin/ebay/category-search, shows name + breadcrumb path, and auto-saves the selection to the ArticleType on pick. Aspect import table now only renders after a category is set. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
d26c534c34
commit
818c1ec8f7
4 changed files with 229 additions and 56 deletions
|
|
@ -66,6 +66,45 @@ final class EbayTaxonomyService
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search eBay categories by free-text query (e.g. "Notebook", "RAM", "Switch").
|
||||||
|
*
|
||||||
|
* @return list<array{id: string, name: string, path: string}>
|
||||||
|
*/
|
||||||
|
public function getCategorySuggestions(string $query): array
|
||||||
|
{
|
||||||
|
$token = $this->oauthClient->getAccessToken();
|
||||||
|
|
||||||
|
$response = $this->httpClient->request(
|
||||||
|
'GET',
|
||||||
|
$this->apiBaseUrl.'/commerce/taxonomy/v1/category_tree/'.$this->getTreeId().'/get_category_suggestions',
|
||||||
|
[
|
||||||
|
'headers' => [
|
||||||
|
'Authorization' => 'Bearer '.$token,
|
||||||
|
'X-EBAY-C-MARKETPLACE-ID' => $this->marketplaceId,
|
||||||
|
],
|
||||||
|
'query' => ['q' => $query],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** @var array{categorySuggestions?: list<array{category: array{categoryId: string, categoryName: string}, categoryTreeNodeAncestors?: list<array{categoryName: string}>}>} $data */
|
||||||
|
$data = $response->toArray();
|
||||||
|
|
||||||
|
$results = [];
|
||||||
|
foreach ($data['categorySuggestions'] ?? [] as $suggestion) {
|
||||||
|
$ancestors = array_reverse($suggestion['categoryTreeNodeAncestors'] ?? []);
|
||||||
|
$path = implode(' › ', array_column($ancestors, 'categoryName'));
|
||||||
|
|
||||||
|
$results[] = [
|
||||||
|
'id' => $suggestion['category']['categoryId'],
|
||||||
|
'name' => $suggestion['category']['categoryName'],
|
||||||
|
'path' => $path,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $results;
|
||||||
|
}
|
||||||
|
|
||||||
private function getTreeId(): string
|
private function getTreeId(): string
|
||||||
{
|
{
|
||||||
return match ($this->marketplaceId) {
|
return match ($this->marketplaceId) {
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,7 @@ final class ArticleCrudController extends AbstractCrudController
|
||||||
) && !$this->jobRepository->hasActiveJobForArticle($a->getId()));
|
) && !$this->jobRepository->hasActiveJobForArticle($a->getId()));
|
||||||
|
|
||||||
return $actions
|
return $actions
|
||||||
|
->add(Crud::PAGE_INDEX, Action::DETAIL)
|
||||||
->add(Crud::PAGE_INDEX, $activate)
|
->add(Crud::PAGE_INDEX, $activate)
|
||||||
->add(Crud::PAGE_INDEX, $markDraft)
|
->add(Crud::PAGE_INDEX, $markDraft)
|
||||||
->add(Crud::PAGE_INDEX, $rerunAi)
|
->add(Crud::PAGE_INDEX, $rerunAi)
|
||||||
|
|
|
||||||
|
|
@ -11,13 +11,13 @@ use App\Domain\Article\Repository\ArticleTypeRepositoryInterface;
|
||||||
use App\Infrastructure\Channel\Ebay\EbayTaxonomyService;
|
use App\Infrastructure\Channel\Ebay\EbayTaxonomyService;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
use Symfony\Component\Uid\Uuid;
|
use Symfony\Component\Uid\Uuid;
|
||||||
|
|
||||||
#[Route('/admin/ebay/aspect-import/{id}', name: 'admin_ebay_aspect_import')]
|
|
||||||
#[IsGranted('ROLE_USER')]
|
#[IsGranted('ROLE_USER')]
|
||||||
final class EbayAspectImportController extends AbstractController
|
final class EbayAspectImportController extends AbstractController
|
||||||
{
|
{
|
||||||
|
|
@ -28,6 +28,25 @@ final class EbayAspectImportController extends AbstractController
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Route('/admin/ebay/category-search', name: 'admin_ebay_category_search')]
|
||||||
|
public function searchCategories(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$q = trim((string) $request->query->get('q', ''));
|
||||||
|
if (mb_strlen($q) < 2) {
|
||||||
|
return $this->json([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$results = $this->taxonomy->getCategorySuggestions($q);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return $this->json([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->json(array_slice($results, 0, 15));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/admin/ebay/aspect-import/{id}', name: 'admin_ebay_aspect_import')]
|
||||||
|
#[IsGranted('ROLE_USER')]
|
||||||
public function __invoke(string $id, Request $request): Response
|
public function __invoke(string $id, Request $request): Response
|
||||||
{
|
{
|
||||||
$articleType = $this->articleTypeRepo->findById(Uuid::fromString($id));
|
$articleType = $this->articleTypeRepo->findById(Uuid::fromString($id));
|
||||||
|
|
@ -35,21 +54,22 @@ final class EbayAspectImportController extends AbstractController
|
||||||
throw $this->createNotFoundException();
|
throw $this->createNotFoundException();
|
||||||
}
|
}
|
||||||
|
|
||||||
$categoryId = $articleType->getEbayCategoryId();
|
// Save category selection (step 1 of the 2-step flow)
|
||||||
if (null === $categoryId) {
|
if ($request->isMethod('POST') && 'set-category' === $request->request->get('_action')) {
|
||||||
$this->addFlash('warning', 'Bitte zuerst die eBay Category ID am Artikel-Typ hinterlegen (Edit → eBay Category ID).');
|
return $this->handleSetCategory($request, $articleType);
|
||||||
|
|
||||||
return $this->redirectToRoute('easyadmin', [
|
|
||||||
'crudAction' => 'edit',
|
|
||||||
'crudControllerFqcn' => ArticleTypeCrudController::class,
|
|
||||||
'entityId' => $id,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($request->isMethod('POST')) {
|
if ($request->isMethod('POST')) {
|
||||||
return $this->handleImport($request, $articleType);
|
return $this->handleImport($request, $articleType);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$categoryId = $articleType->getEbayCategoryId();
|
||||||
|
|
||||||
|
$rows = [];
|
||||||
|
$allDefs = [];
|
||||||
|
$counts = ['required' => 0, 'recommended' => 0, 'optional' => 0];
|
||||||
|
|
||||||
|
if (null !== $categoryId) {
|
||||||
$aspects = $this->taxonomy->getCategoryAspects($categoryId);
|
$aspects = $this->taxonomy->getCategoryAspects($categoryId);
|
||||||
usort($aspects, static function (array $a, array $b): int {
|
usort($aspects, static function (array $a, array $b): int {
|
||||||
$tierA = $a['required'] ? 0 : ('RECOMMENDED' === $a['usage'] ? 1 : 2);
|
$tierA = $a['required'] ? 0 : ('RECOMMENDED' === $a['usage'] ? 1 : 2);
|
||||||
|
|
@ -60,14 +80,13 @@ final class EbayAspectImportController extends AbstractController
|
||||||
|
|
||||||
/** @var list<AttributeDefinition> $allDefs */
|
/** @var list<AttributeDefinition> $allDefs */
|
||||||
$allDefs = $this->em->getRepository(AttributeDefinition::class)->findBy([], ['name' => 'ASC']);
|
$allDefs = $this->em->getRepository(AttributeDefinition::class)->findBy([], ['name' => 'ASC']);
|
||||||
|
|
||||||
$rows = $this->buildRows($aspects, $allDefs, $articleType);
|
$rows = $this->buildRows($aspects, $allDefs, $articleType);
|
||||||
|
|
||||||
$counts = [
|
$counts = [
|
||||||
'required' => count(array_filter($aspects, static fn (array $a) => $a['required'])),
|
'required' => count(array_filter($aspects, static fn (array $a) => $a['required'])),
|
||||||
'recommended' => count(array_filter($aspects, static fn (array $a) => !$a['required'] && 'RECOMMENDED' === $a['usage'])),
|
'recommended' => count(array_filter($aspects, static fn (array $a) => !$a['required'] && 'RECOMMENDED' === $a['usage'])),
|
||||||
'optional' => count(array_filter($aspects, static fn (array $a) => !$a['required'] && 'OPTIONAL' === $a['usage'])),
|
'optional' => count(array_filter($aspects, static fn (array $a) => !$a['required'] && 'OPTIONAL' === $a['usage'])),
|
||||||
];
|
];
|
||||||
|
}
|
||||||
|
|
||||||
return $this->render('admin/ebay/aspect_import.html.twig', [
|
return $this->render('admin/ebay/aspect_import.html.twig', [
|
||||||
'articleType' => $articleType,
|
'articleType' => $articleType,
|
||||||
|
|
@ -75,9 +94,21 @@ final class EbayAspectImportController extends AbstractController
|
||||||
'allDefs' => $allDefs,
|
'allDefs' => $allDefs,
|
||||||
'counts' => $counts,
|
'counts' => $counts,
|
||||||
'categoryId' => $categoryId,
|
'categoryId' => $categoryId,
|
||||||
|
'searchUrl' => $this->generateUrl('admin_ebay_category_search'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function handleSetCategory(Request $request, ArticleType $articleType): Response
|
||||||
|
{
|
||||||
|
$categoryId = trim((string) $request->request->get('categoryId', ''));
|
||||||
|
if ('' !== $categoryId) {
|
||||||
|
$articleType->setEbayCategoryId($categoryId);
|
||||||
|
$this->em->flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->redirectToRoute('admin_ebay_aspect_import', ['id' => $articleType->getId()->toRfc4122()]);
|
||||||
|
}
|
||||||
|
|
||||||
private function handleImport(Request $request, ArticleType $articleType): Response
|
private function handleImport(Request $request, ArticleType $articleType): Response
|
||||||
{
|
{
|
||||||
if (!$this->isCsrfTokenValid('ebay_aspect_import', (string) $request->request->get('_token'))) {
|
if (!$this->isCsrfTokenValid('ebay_aspect_import', (string) $request->request->get('_token'))) {
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,46 @@
|
||||||
|
|
||||||
{% block page_title %}
|
{% block page_title %}
|
||||||
<i class="fa fa-cloud-download-alt me-2"></i>eBay Aspects importieren — {{ articleType.name }}
|
<i class="fa fa-cloud-download-alt me-2"></i>eBay Aspects importieren — {{ articleType.name }}
|
||||||
<small class="text-muted fw-normal ms-2 fs-6">Kategorie {{ categoryId }}</small>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block main %}
|
{% block main %}
|
||||||
|
|
||||||
<form method="post" id="aspect-import-form">
|
{# ── Category search / picker ─────────────────────────────────────── #}
|
||||||
<input type="hidden" name="_token" value="{{ csrf_token('ebay_aspect_import') }}">
|
<div class="card mb-4">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="mb-0"><i class="fa fa-search me-2"></i>eBay-Kategorie</h5>
|
||||||
|
{% if categoryId %}
|
||||||
|
<span class="badge bg-info fs-6 font-monospace">ID: {{ categoryId }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post" id="category-select-form">
|
||||||
|
<input type="hidden" name="_action" value="set-category">
|
||||||
|
<input type="hidden" name="categoryId" id="selected-category-id" value="{{ categoryId ?? '' }}">
|
||||||
|
|
||||||
|
<div class="position-relative">
|
||||||
|
<input type="text"
|
||||||
|
id="category-search-input"
|
||||||
|
class="form-control"
|
||||||
|
placeholder="Kategorie suchen, z.B. „Notebook", „RAM", „Switch" …"
|
||||||
|
value="{{ categoryId ? '(ID ' ~ categoryId ~ ' — tippen um zu ändern)' : '' }}"
|
||||||
|
autocomplete="off">
|
||||||
|
<div id="category-dropdown"
|
||||||
|
class="position-absolute w-100 border rounded bg-white shadow-sm"
|
||||||
|
style="z-index:1000;display:none;max-height:320px;overflow-y:auto;top:100%;left:0;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if not categoryId %}
|
||||||
|
<div class="text-muted small mt-2">
|
||||||
|
<i class="fa fa-info-circle me-1"></i>Kategorie wählen, um eBay-Aspekte zu laden.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if categoryId %}
|
||||||
|
|
||||||
{# ── Summary bar ──────────────────────────────────────────────────── #}
|
{# ── Summary bar ──────────────────────────────────────────────────── #}
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
|
@ -16,7 +49,11 @@
|
||||||
<span class="badge bg-danger fs-6">{{ counts.required }} Required</span>
|
<span class="badge bg-danger fs-6">{{ counts.required }} Required</span>
|
||||||
<span class="badge bg-warning text-dark fs-6">{{ counts.recommended }} Recommended</span>
|
<span class="badge bg-warning text-dark fs-6">{{ counts.recommended }} Recommended</span>
|
||||||
<span class="badge bg-secondary fs-6">{{ counts.optional }} Optional</span>
|
<span class="badge bg-secondary fs-6">{{ counts.optional }} Optional</span>
|
||||||
<span class="text-muted small ms-2">Auto-matched {{ rows|filter(r => r.action == 'match')|length }} · Create {{ rows|filter(r => r.action == 'create')|length }} · Skip {{ rows|filter(r => r.action == 'skip')|length }}</span>
|
<span class="text-muted small ms-2">
|
||||||
|
Auto-matched {{ rows|filter(r => r.action == 'match')|length }} ·
|
||||||
|
Create {{ rows|filter(r => r.action == 'create')|length }} ·
|
||||||
|
Skip {{ rows|filter(r => r.action == 'skip')|length }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="btn-select-all-create">All → Create</button>
|
<button type="button" class="btn btn-sm btn-outline-secondary" id="btn-select-all-create">All → Create</button>
|
||||||
|
|
@ -25,13 +62,16 @@
|
||||||
class="btn btn-sm btn-outline-secondary">
|
class="btn btn-sm btn-outline-secondary">
|
||||||
<i class="fa fa-arrow-left me-1"></i>Abbrechen
|
<i class="fa fa-arrow-left me-1"></i>Abbrechen
|
||||||
</a>
|
</a>
|
||||||
<button type="submit" class="btn btn-primary btn-sm">
|
<button type="submit" form="aspect-import-form" class="btn btn-primary btn-sm">
|
||||||
<i class="fa fa-check me-1"></i>Importieren
|
<i class="fa fa-check me-1"></i>Importieren
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# ── Main table ───────────────────────────────────────────────────── #}
|
{# ── Aspect import form ───────────────────────────────────────────── #}
|
||||||
|
<form method="post" id="aspect-import-form">
|
||||||
|
<input type="hidden" name="_token" value="{{ csrf_token('ebay_aspect_import') }}">
|
||||||
|
|
||||||
<table class="table table-hover align-middle" id="aspects-table">
|
<table class="table table-hover align-middle" id="aspects-table">
|
||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
<tr>
|
<tr>
|
||||||
|
|
@ -48,7 +88,6 @@
|
||||||
{% set aspect = row.aspect %}
|
{% set aspect = row.aspect %}
|
||||||
<tr class="aspect-row{% if row.alreadyAssigned %} table-success{% endif %}" data-index="{{ i }}">
|
<tr class="aspect-row{% if row.alreadyAssigned %} table-success{% endif %}" data-index="{{ i }}">
|
||||||
|
|
||||||
{# Aspect name #}
|
|
||||||
<td>
|
<td>
|
||||||
<span class="fw-medium">{{ aspect.name }}</span>
|
<span class="fw-medium">{{ aspect.name }}</span>
|
||||||
{% if row.alreadyAssigned %}
|
{% if row.alreadyAssigned %}
|
||||||
|
|
@ -56,7 +95,6 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{# Tier badge #}
|
|
||||||
<td>
|
<td>
|
||||||
{% if aspect.required %}
|
{% if aspect.required %}
|
||||||
<span class="badge bg-danger">Required</span>
|
<span class="badge bg-danger">Required</span>
|
||||||
|
|
@ -67,18 +105,16 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{# eBay values preview #}
|
|
||||||
<td>
|
<td>
|
||||||
{% if aspect.values %}
|
{% if aspect.values %}
|
||||||
<span class="text-muted small">
|
<span class="text-muted small">
|
||||||
{{ aspect.values|slice(0, 6)|join(', ') }}{% if aspect.values|length > 6 %} <em>(+{{ aspect.values|length - 6 }} weitere)</em>{% endif %}
|
{{ aspect.values|slice(0, 6)|join(', ') }}{% if aspect.values|length > 6 %} <em>(+{{ aspect.values|length - 6 }})</em>{% endif %}
|
||||||
</span>
|
</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-muted small fst-italic">Freitext</span>
|
<span class="text-muted small fst-italic">Freitext</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{# Action selector #}
|
|
||||||
<td>
|
<td>
|
||||||
<select name="aspects[{{ i }}][action]"
|
<select name="aspects[{{ i }}][action]"
|
||||||
class="form-select form-select-sm aspect-action"
|
class="form-select form-select-sm aspect-action"
|
||||||
|
|
@ -92,9 +128,7 @@
|
||||||
<input type="hidden" name="aspects[{{ i }}][ebayRequired]" value="{{ aspect.required ? '1' : '0' }}">
|
<input type="hidden" name="aspects[{{ i }}][ebayRequired]" value="{{ aspect.required ? '1' : '0' }}">
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{# Attribute input (match or create) #}
|
|
||||||
<td>
|
<td>
|
||||||
{# Match: pick existing definition #}
|
|
||||||
<div class="section-match-{{ i }}" style="display:{% if row.action == 'match' %}block{% else %}none{% endif %};">
|
<div class="section-match-{{ i }}" style="display:{% if row.action == 'match' %}block{% else %}none{% endif %};">
|
||||||
<select name="aspects[{{ i }}][definitionId]" class="form-select form-select-sm">
|
<select name="aspects[{{ i }}][definitionId]" class="form-select form-select-sm">
|
||||||
{% for def in allDefs %}
|
{% for def in allDefs %}
|
||||||
|
|
@ -104,8 +138,6 @@
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Create: name + type #}
|
|
||||||
<div class="section-create-{{ i }}" style="display:{% if row.action == 'create' %}block{% else %}none{% endif %};">
|
<div class="section-create-{{ i }}" style="display:{% if row.action == 'create' %}block{% else %}none{% endif %};">
|
||||||
<div class="d-flex gap-1">
|
<div class="d-flex gap-1">
|
||||||
<input type="text"
|
<input type="text"
|
||||||
|
|
@ -115,20 +147,17 @@
|
||||||
placeholder="Attribut-Name">
|
placeholder="Attribut-Name">
|
||||||
<select name="aspects[{{ i }}][type]" class="form-select form-select-sm" style="width:auto;">
|
<select name="aspects[{{ i }}][type]" class="form-select form-select-sm" style="width:auto;">
|
||||||
<option value="string" {% if row.suggestedType == 'string' %}selected{% endif %}>Text</option>
|
<option value="string" {% if row.suggestedType == 'string' %}selected{% endif %}>Text</option>
|
||||||
<option value="select" {% if row.suggestedType == 'select' %}selected{% endif %}>Select ({{ aspect.values|length }} Werte)</option>
|
<option value="select" {% if row.suggestedType == 'select' %}selected{% endif %}>Select ({{ aspect.values|length }})</option>
|
||||||
<option value="int">Int</option>
|
<option value="int">Int</option>
|
||||||
<option value="float">Float</option>
|
<option value="float">Float</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Skip: placeholder #}
|
|
||||||
<div class="section-skip-{{ i }}" style="display:{% if row.action == 'skip' %}block{% else %}none{% endif %};">
|
<div class="section-skip-{{ i }}" style="display:{% if row.action == 'skip' %}block{% else %}none{% endif %};">
|
||||||
<span class="text-muted small">—</span>
|
<span class="text-muted small">—</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{# Required checkbox #}
|
|
||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
<div class="section-req-{{ i }}" style="display:{% if row.action != 'skip' %}block{% else %}none{% endif %};">
|
<div class="section-req-{{ i }}" style="display:{% if row.action != 'skip' %}block{% else %}none{% endif %};">
|
||||||
<input type="checkbox"
|
<input type="checkbox"
|
||||||
|
|
@ -154,8 +183,85 @@
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<i class="fa fa-arrow-up me-2"></i>Kategorie oben suchen und auswählen, um die eBay-Aspekte zu laden.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
|
const searchInput = document.getElementById('category-search-input');
|
||||||
|
const dropdown = document.getElementById('category-dropdown');
|
||||||
|
const hiddenId = document.getElementById('selected-category-id');
|
||||||
|
const selectForm = document.getElementById('category-select-form');
|
||||||
|
const searchUrl = {{ searchUrl|json_encode|raw }};
|
||||||
|
|
||||||
|
let debounceTimer = null;
|
||||||
|
|
||||||
|
searchInput.addEventListener('focus', function () {
|
||||||
|
if (hiddenId.value) {
|
||||||
|
this.value = '';
|
||||||
|
hiddenId.value = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
searchInput.addEventListener('input', function () {
|
||||||
|
clearTimeout(debounceTimer);
|
||||||
|
const q = this.value.trim();
|
||||||
|
if (q.length < 2) { hideDropdown(); return; }
|
||||||
|
debounceTimer = setTimeout(() => fetchSuggestions(q), 250);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', e => {
|
||||||
|
if (!searchInput.contains(e.target) && !dropdown.contains(e.target)) {
|
||||||
|
hideDropdown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function fetchSuggestions(q) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(searchUrl + '?q=' + encodeURIComponent(q));
|
||||||
|
const items = await res.json();
|
||||||
|
renderDropdown(items);
|
||||||
|
} catch { hideDropdown(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDropdown(items) {
|
||||||
|
if (!items.length) { hideDropdown(); return; }
|
||||||
|
|
||||||
|
dropdown.innerHTML = items.map(item => {
|
||||||
|
const path = item.path ? `<div class="text-muted small">${e(item.path)}</div>` : '';
|
||||||
|
return `<div class="px-3 py-2 border-bottom category-option" style="cursor:pointer;"
|
||||||
|
data-id="${e(item.id)}" data-name="${e(item.name)}">
|
||||||
|
<span class="fw-medium">${e(item.name)}</span>
|
||||||
|
<span class="badge bg-light text-dark border ms-1">${e(item.id)}</span>
|
||||||
|
${path}
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
dropdown.querySelectorAll('.category-option').forEach(opt => {
|
||||||
|
opt.addEventListener('mouseenter', () => opt.style.background = '#f8f9fa');
|
||||||
|
opt.addEventListener('mouseleave', () => opt.style.background = '');
|
||||||
|
opt.addEventListener('click', () => selectCategory(opt.dataset.id, opt.dataset.name));
|
||||||
|
});
|
||||||
|
|
||||||
|
dropdown.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectCategory(id, name) {
|
||||||
|
searchInput.value = name + ' (ID: ' + id + ')';
|
||||||
|
hiddenId.value = id;
|
||||||
|
hideDropdown();
|
||||||
|
selectForm.submit();
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideDropdown() { dropdown.style.display = 'none'; }
|
||||||
|
function e(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||||
|
|
||||||
|
/* ── Aspect table JS ─────────────────────────────────────────── */
|
||||||
function syncRow(index, action) {
|
function syncRow(index, action) {
|
||||||
['match', 'create', 'skip'].forEach(s => {
|
['match', 'create', 'skip'].forEach(s => {
|
||||||
const el = document.querySelector(`.section-${s}-${index}`);
|
const el = document.querySelector(`.section-${s}-${index}`);
|
||||||
|
|
@ -166,22 +272,18 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
document.querySelectorAll('.aspect-action').forEach(sel => {
|
document.querySelectorAll('.aspect-action').forEach(sel => {
|
||||||
sel.addEventListener('change', function () {
|
sel.addEventListener('change', function () { syncRow(this.dataset.index, this.value); });
|
||||||
syncRow(this.dataset.index, this.value);
|
});
|
||||||
|
|
||||||
|
document.getElementById('btn-select-all-create')?.addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('.aspect-action').forEach(sel => {
|
||||||
|
sel.value = 'create'; syncRow(sel.dataset.index, 'create');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('btn-select-all-create').addEventListener('click', () => {
|
document.getElementById('btn-select-all-skip')?.addEventListener('click', () => {
|
||||||
document.querySelectorAll('.aspect-action').forEach(sel => {
|
document.querySelectorAll('.aspect-action').forEach(sel => {
|
||||||
sel.value = 'create';
|
sel.value = 'skip'; syncRow(sel.dataset.index, 'skip');
|
||||||
syncRow(sel.dataset.index, 'create');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('btn-select-all-skip').addEventListener('click', () => {
|
|
||||||
document.querySelectorAll('.aspect-action').forEach(sel => {
|
|
||||||
sel.value = 'skip';
|
|
||||||
syncRow(sel.dataset.index, 'skip');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue