Add Quick Apply and batch update to bulk info provider import (#1316)

* Add Quick Apply and Apply All buttons to bulk info provider import

Adds the ability to apply provider search results to parts directly
from the bulk import step 2 page without navigating to individual
part edit forms. Includes per-result Quick Apply buttons and an
Apply All button for batch operations.

* Add navigation buttons and completion banner to bulk import step2

Adds Back to Jobs / Back to Parts buttons at the top of the page
and a success banner when the job is completed, so users aren't
stuck on the page after applying all parts.

* Highlight top search result and remove skip reason prompt

- Highlight the recommended/top priority result row with table-success class
- Add "Top" badge to the recommended Quick Apply button
- Use outline style for non-top Quick Apply buttons to differentiate
- Remove the annoying "reason for skipping" prompt popup

* Fix 500 error when field mapping has null field or no search results

- Skip field mappings with null/empty field values in convertFieldMappingsToDto
- Return empty DTO instead of throwing when no search results found
- Remove unnecessary try/catch workaround in researchPart

* Fix PHPStan error: remove redundant null check on BulkSearchResponseDTO

* Improve bulk import UI: split active/history jobs, fix text visibility, add match highlighting

- Split manage page into Active Jobs and History sections
- Fix source keyword text color (remove text-muted for better visibility)
- Add exact match indicators: green check badge when name or MPN matches
- Add translation keys for new UI elements

* Fix spinning icon, text visibility, auto-priority, and SPN match highlighting

- Replace spinning icon with static icon on Active Jobs header
- Match highlighting now checks source keyword against name, MPN, AND provider ID (SPN)
- Show green "Match" badge in source field column when any field matches 100%
- Auto-increment priority when adding new field mapping rows
- Fix text-muted visibility issues on table-success background

* Fix broken images and improve match highlighting consistency

- Hide broken external provider images with onerror fallback
- Make source keyword text green when any match is detected
- All matched fields (name, MPN, SPN, or any source keyword) show green text

* Fix TypeError in LCSCProvider when keyword is numeric string

PHP auto-casts numeric string array keys to int. When a search keyword
is a pure number (e.g., a part number like "12345"), the foreach loop
passes an int to processSearchResponse() which expects string. Cast
keyword to string explicitly.

* Clean up stale pending jobs and add job ID to display

- Auto-delete pending jobs with 0 results (from failed searches/500 errors)
- Show job ID (#N) in manage page and step2 to distinguish identical jobs
- Move timestamp to subtitle line on manage page for cleaner layout

* Fix tests to match updated bulk search behavior (no more RuntimeException)

The bulk search service now returns empty response DTOs instead of
throwing RuntimeException when no results are found. Updated tests
to use assertFalse(hasAnyResults()) instead of catching exceptions.

* Add comprehensive test coverage for bulk import controller

Covers Quick Apply, Apply All, delete, stop, mark completed/skipped/pending,
manage page active/history split, stale job cleanup, research endpoints,
and various error paths. Increases patch coverage significantly.

* Fix duplicate test method names in bulk import tests

* Fix last duplicate test method name (testQuickApplyWithNoSearchResults)

* Fixed translation key in translation messages

* Moved table rendering logic into macro

* fixed visual glitch with button success outline

* Use native httpfoundation method to convert json to an array

* Show a more user friendly error message, when

* Allow to automatically create new manufacturers within quick apply

---------

Co-authored-by: Jan Böhmer <mail@jan-boehmer.de>
This commit is contained in:
Sebastian Almberg 2026-05-04 21:56:18 +02:00 committed by GitHub
parent 0ddf4f903e
commit ce2b7d11a9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 1585 additions and 145 deletions

View file

@ -3,14 +3,16 @@ import { generateCsrfHeaders } from "./csrf_protection_controller"
export default class extends Controller {
static targets = ["progressBar", "progressText"]
static values = {
static values = {
jobId: Number,
partId: Number,
researchUrl: String,
researchAllUrl: String,
markCompletedUrl: String,
markSkippedUrl: String,
markPendingUrl: String
markPendingUrl: String,
quickApplyUrl: String,
quickApplyAllUrl: String
}
connect() {
@ -119,13 +121,11 @@ export default class extends Controller {
async markSkipped(event) {
const partId = event.currentTarget.dataset.partId
const reason = prompt('Reason for skipping (optional):') || ''
try {
const url = this.markSkippedUrlValue.replace('__PART_ID__', partId)
const data = await this.fetchWithErrorHandling(url, {
method: 'POST',
body: JSON.stringify({ reason })
method: 'POST'
})
if (data.success) {
@ -321,6 +321,94 @@ export default class extends Controller {
}
}
async quickApply(event) {
event.preventDefault()
event.stopPropagation()
const partId = event.currentTarget.dataset.partId
const providerKey = event.currentTarget.dataset.providerKey
const providerId = event.currentTarget.dataset.providerId
const button = event.currentTarget
const originalHtml = button.innerHTML
button.disabled = true
button.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Applying...'
try {
const url = this.quickApplyUrlValue.replace('__PART_ID__', partId)
const data = await this.fetchWithErrorHandling(url, {
method: 'POST',
body: JSON.stringify({ providerKey, providerId })
}, 60000)
if (data.success) {
this.updateProgressDisplay(data)
this.showSuccessMessage(data.message || 'Part updated successfully')
sessionStorage.setItem('bulkImportScrollPosition', window.scrollY.toString())
window.location.reload()
} else {
this.showErrorMessage(data.error || 'Quick apply failed')
button.innerHTML = originalHtml
button.disabled = false
}
} catch (error) {
console.error('Error in quick apply:', error)
this.showErrorMessage(error.message || 'Quick apply failed')
button.innerHTML = originalHtml
button.disabled = false
}
}
async quickApplyAll(event) {
event.preventDefault()
event.stopPropagation()
if (!confirm('This will apply the top search result to all pending parts without individual review. Continue?')) {
return
}
const button = event.currentTarget
const spinner = document.getElementById('quick-apply-all-spinner')
const originalHtml = button.innerHTML
button.disabled = true
if (spinner) {
spinner.style.display = 'inline-block'
}
try {
const data = await this.fetchWithErrorHandling(this.quickApplyAllUrlValue, {
method: 'POST'
}, 300000)
if (data.success) {
this.updateProgressDisplay(data)
let message = data.message || 'Bulk apply completed'
if (data.errors && data.errors.length > 0) {
message += '\nErrors:\n' + data.errors.join('\n')
}
this.showSuccessMessage(message)
sessionStorage.setItem('bulkImportScrollPosition', window.scrollY.toString())
window.location.reload()
} else {
this.showErrorMessage(data.error || 'Bulk apply failed')
button.innerHTML = originalHtml
button.disabled = false
}
} catch (error) {
console.error('Error in quick apply all:', error)
this.showErrorMessage(error.message || 'Bulk apply failed')
button.innerHTML = originalHtml
button.disabled = false
} finally {
if (spinner) {
spinner.style.display = 'none'
}
}
}
showSuccessMessage(message) {
this.showToast('success', message)
}

View file

@ -70,6 +70,13 @@ export default class extends Controller {
newFieldSelect.addEventListener('change', this.updateFieldOptions.bind(this))
}
// Auto-increment priority based on existing mappings
const nextPriority = this.getNextPriority()
const priorityInput = newRow.querySelector('input[name*="[priority]"]')
if (priorityInput) {
priorityInput.value = nextPriority
}
this.updateFieldOptions()
this.updateAddButtonState()
}
@ -119,6 +126,18 @@ export default class extends Controller {
}
}
getNextPriority() {
const priorityInputs = this.tbodyTarget.querySelectorAll('input[name*="[priority]"]')
let maxPriority = 0
priorityInputs.forEach(input => {
const val = parseInt(input.value, 10)
if (!isNaN(val) && val > maxPriority) {
maxPriority = val
}
})
return Math.min(maxPriority + 1, 10)
}
handleFormSubmit(event) {
if (this.hasSubmitButtonTarget) {
this.submitButtonTarget.disabled = true

View file

@ -29,11 +29,14 @@ use App\Entity\Parts\Part;
use App\Entity\Parts\Supplier;
use App\Entity\UserSystem\User;
use App\Form\InfoProviderSystem\GlobalFieldMappingType;
use App\Services\EntityMergers\Mergers\PartMerger;
use App\Services\InfoProviderSystem\BulkInfoProviderService;
use App\Services\InfoProviderSystem\DTOs\BulkSearchFieldMappingDTO;
use App\Services\InfoProviderSystem\DTOs\BulkSearchPartResultsDTO;
use App\Services\InfoProviderSystem\DTOs\BulkSearchResponseDTO;
use App\Services\InfoProviderSystem\PartInfoRetriever;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\ORMInvalidArgumentException;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
@ -66,6 +69,10 @@ class BulkInfoProviderImportController extends AbstractController
{
$dtos = [];
foreach ($fieldMappings as $mapping) {
// Skip entries where field is null/empty (e.g. user added a row but didn't select a field)
if (empty($mapping['field'])) {
continue;
}
$dtos[] = new BulkSearchFieldMappingDTO(field: $mapping['field'], providers: $mapping['providers'], priority: $mapping['priority'] ?? 1);
}
return $dtos;
@ -276,8 +283,8 @@ class BulkInfoProviderImportController extends AbstractController
$updatedJobs = true;
}
// Mark jobs with no results for deletion (failed searches)
if ($job->getResultCount() === 0 && $job->isInProgress()) {
// Mark jobs with no results for deletion (failed searches or stale pending)
if ($job->getResultCount() === 0 && ($job->isInProgress() || $job->isPending())) {
$jobsToDelete[] = $job;
}
}
@ -297,9 +304,23 @@ class BulkInfoProviderImportController extends AbstractController
}
}
// Refetch after cleanup and split into active vs finished
$allJobs = $this->entityManager->getRepository(BulkInfoProviderImportJob::class)
->findBy([], ['createdAt' => 'DESC']);
$activeJobs = [];
$finishedJobs = [];
foreach ($allJobs as $job) {
if ($job->isCompleted() || $job->isFailed() || $job->isStopped()) {
$finishedJobs[] = $job;
} else {
$activeJobs[] = $job;
}
}
return $this->render('info_providers/bulk_import/manage.html.twig', [
'jobs' => $this->entityManager->getRepository(BulkInfoProviderImportJob::class)
->findBy([], ['createdAt' => 'DESC']) // Refetch after cleanup
'active_jobs' => $activeJobs,
'finished_jobs' => $finishedJobs,
]);
}
@ -470,22 +491,13 @@ class BulkInfoProviderImportController extends AbstractController
$fieldMappingDtos = $job->getFieldMappings();
$prefetchDetails = $job->isPrefetchDetails();
try {
$searchResultsDto = $this->bulkService->performBulkSearch([$part], $fieldMappingDtos, $prefetchDetails);
} catch (\Exception $searchException) {
// Handle "no search results found" as a normal case, not an error
if (str_contains($searchException->getMessage(), 'No search results found')) {
$searchResultsDto = null;
} else {
throw $searchException;
}
}
$searchResultsDto = $this->bulkService->performBulkSearch([$part], $fieldMappingDtos, $prefetchDetails);
// Update the job's search results for this specific part efficiently
$this->updatePartSearchResults($job, $searchResultsDto[0] ?? null);
// Prefetch details if requested
if ($prefetchDetails && $searchResultsDto !== null) {
if ($prefetchDetails) {
$this->bulkService->prefetchDetailsForResults($searchResultsDto);
}
@ -515,6 +527,191 @@ class BulkInfoProviderImportController extends AbstractController
}
}
#[Route('/job/{jobId}/part/{partId}/quick-apply', name: 'bulk_info_provider_quick_apply', methods: ['POST'])]
public function quickApply(
int $jobId,
int $partId,
Request $request,
PartInfoRetriever $infoRetriever,
PartMerger $partMerger
): JsonResponse {
$job = $this->validateJobAccess($jobId);
if (!$job) {
return $this->createErrorResponse('Job not found or access denied', 404, ['job_id' => $jobId]);
}
/** @var Part $part */
$part = $this->entityManager->getRepository(Part::class)->find($partId);
if (!$part) {
return $this->createErrorResponse('Part not found', 404, ['part_id' => $partId]);
}
$this->denyAccessUnlessGranted('edit', $part);
// Get provider key/id from request body, or fall back to top search result
$body = $request->toArray();
$providerKey = $body['providerKey'] ?? null;
$providerId = $body['providerId'] ?? null;
if (!$providerKey || !$providerId) {
$searchResults = $job->getSearchResults($this->entityManager);
foreach ($searchResults->partResults as $partResult) {
if ($partResult->part->getId() === $partId) {
$sorted = $partResult->getResultsSortedByPriority();
if (!empty($sorted)) {
$providerKey = $sorted[0]->searchResult->provider_key;
$providerId = $sorted[0]->searchResult->provider_id;
}
break;
}
}
}
if (!$providerKey || !$providerId) {
return $this->createErrorResponse('No search result available for this part', 400, ['part_id' => $partId]);
}
try {
$dto = $infoRetriever->getDetails($providerKey, $providerId);
$providerPart = $infoRetriever->dtoToPart($dto);
$partMerger->merge($part, $providerPart);
//Persist part manufacturer and supplier if they are new, to avoid issues with detached entities during merge
//Do not footprints here, as it might pollute the database with unwanted formatting footprints from the provider,
$this->entityManager->persist($part->getManufacturer());
foreach ($part->getOrderdetails() as $orderdetail) {
$this->entityManager->persist($orderdetail->getSupplier());
}
try {
$this->entityManager->flush();
} catch (ORMInvalidArgumentException $exception) {
if (str_contains($exception->getMessage(), 'not configured to cascade persist operations')) {
throw new \RuntimeException('Failed to persist merged part, as it would create new datastructures! Review the provider data by yourself.');
}
throw $exception; // Re-throw if it's a different ORM error
}
$job->markPartAsCompleted($partId);
if ($job->isAllPartsCompleted() && !$job->isCompleted()) {
$job->markAsCompleted();
}
$this->entityManager->flush();
return $this->json([
'success' => true,
'message' => sprintf('Applied provider data to "%s"', $part->getName()),
'part_id' => $partId,
'provider_key' => $providerKey,
'provider_id' => $providerId,
'progress' => $job->getProgressPercentage(),
'completed_count' => $job->getCompletedPartsCount(),
'total_count' => $job->getPartCount(),
'job_completed' => $job->isCompleted(),
]);
} catch (\Exception $e) {
$this->logger->error($e);
return $this->createErrorResponse(
'Quick apply failed: ' . $e->getMessage(),
500,
['job_id' => $jobId, 'part_id' => $partId, 'exception' => $e->getMessage()]
);
}
}
#[Route('/job/{jobId}/quick-apply-all', name: 'bulk_info_provider_quick_apply_all', methods: ['POST'])]
public function quickApplyAll(
int $jobId,
PartInfoRetriever $infoRetriever,
PartMerger $partMerger
): JsonResponse {
set_time_limit(600);
$job = $this->validateJobAccess($jobId);
if (!$job) {
return $this->createErrorResponse('Job not found or access denied', 404, ['job_id' => $jobId]);
}
$searchResults = $job->getSearchResults($this->entityManager);
$applied = 0;
$failed = 0;
$noResults = 0;
$errors = [];
foreach ($job->getJobParts() as $jobPart) {
if ($jobPart->isCompleted() || $jobPart->isSkipped()) {
continue;
}
$part = $jobPart->getPart();
if (!$this->isGranted('edit', $part)) {
$errors[] = sprintf('No edit permission for "%s"', $part->getName());
$failed++;
continue;
}
// Find top search result for this part
$providerKey = null;
$providerId = null;
foreach ($searchResults->partResults as $partResult) {
if ($partResult->part->getId() === $part->getId()) {
$sorted = $partResult->getResultsSortedByPriority();
if (!empty($sorted)) {
$providerKey = $sorted[0]->searchResult->provider_key;
$providerId = $sorted[0]->searchResult->provider_id;
}
break;
}
}
if (!$providerKey || !$providerId) {
$noResults++;
continue;
}
try {
$dto = $infoRetriever->getDetails($providerKey, $providerId);
$providerPart = $infoRetriever->dtoToPart($dto);
$partMerger->merge($part, $providerPart);
$this->entityManager->flush();
$job->markPartAsCompleted($part->getId());
$applied++;
} catch (\Exception $e) {
$this->logger->error('Quick apply failed for part', [
'part_id' => $part->getId(),
'part_name' => $part->getName(),
'error' => $e->getMessage(),
]);
$errors[] = sprintf('Failed for "%s": %s', $part->getName(), $e->getMessage());
$failed++;
}
}
if ($job->isAllPartsCompleted() && !$job->isCompleted()) {
$job->markAsCompleted();
}
$this->entityManager->flush();
return $this->json([
'success' => true,
'applied' => $applied,
'failed' => $failed,
'no_results' => $noResults,
'errors' => $errors,
'message' => sprintf('Applied to %d parts, %d failed, %d had no results', $applied, $failed, $noResults),
'progress' => $job->getProgressPercentage(),
'completed_count' => $job->getCompletedPartsCount(),
'total_count' => $job->getPartCount(),
'job_completed' => $job->isCompleted(),
]);
}
#[Route('/job/{jobId}/research-all', name: 'bulk_info_provider_research_all', methods: ['POST'])]
public function researchAllParts(int $jobId): JsonResponse
{

View file

@ -46,7 +46,6 @@ final class BulkInfoProviderService
}
$partResults = [];
$hasAnyResults = false;
// Group providers by batch capability
$batchProviders = [];
@ -88,7 +87,6 @@ final class BulkInfoProviderService
);
if (!empty($allResults)) {
$hasAnyResults = true;
$searchResults = $this->formatSearchResults($allResults);
}
@ -99,10 +97,6 @@ final class BulkInfoProviderService
);
}
if (!$hasAnyResults) {
throw new \RuntimeException('No search results found for any of the selected parts');
}
$response = new BulkSearchResponseDTO($partResults);
// Prefetch details if requested

View file

@ -397,6 +397,7 @@ class LCSCProvider implements BatchInfoProviderInterface, URLHandlerInfoProvider
// Now collect all results (like .then() in JavaScript)
foreach ($responses as $keyword => $response) {
try {
$keyword = (string) $keyword;
$arr = $response->toArray(); // This waits for the response
$results[$keyword] = $this->processSearchResponse($arr, $keyword);
} catch (\Exception $e) {

View file

@ -22,103 +22,130 @@
</p>
</div>
{% if jobs is not empty %}
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>{% trans %}info_providers.bulk_import.job_name{% endtrans %}</th>
<th>{% trans %}info_providers.bulk_import.parts_count{% endtrans %}</th>
<th>{% trans %}info_providers.bulk_import.results_count{% endtrans %}</th>
<th>{% trans %}info_providers.bulk_import.progress{% endtrans %}</th>
<th>{% trans %}info_providers.bulk_import.status{% endtrans %}</th>
<th>{% trans %}info_providers.bulk_import.created_by{% endtrans %}</th>
<th>{% trans %}info_providers.bulk_import.created_at{% endtrans %}</th>
<th>{% trans %}info_providers.bulk_import.completed_at{% endtrans %}</th>
<th>{% trans %}info_providers.bulk_import.action.label{% endtrans %}</th>
</tr>
</thead>
<tbody>
{% for job in jobs %}
<tr>
<td>
<strong>{{ job.displayNameKey|trans(job.displayNameParams) }} - {{ job.formattedTimestamp }}</strong>
{% if job.isInProgress %}
<span class="badge bg-info ms-2">Active</span>
{% endif %}
</td>
<td>{{ job.partCount }}</td>
<td>{{ job.resultCount }}</td>
<td>
<div class="d-flex align-items-center">
<div class="progress me-2" style="width: 80px; height: 12px;">
<div class="progress-bar {% if job.isCompleted %}bg-success{% elseif job.isFailed %}bg-danger{% else %}bg-info{% endif %}"
role="progressbar"
style="width: {{ job.progressPercentage }}%"
aria-valuenow="{{ job.progressPercentage }}"
aria-valuemin="0" aria-valuemax="100">
</div>
</div>
<small class="text-muted">{{ job.progressPercentage }}%</small>
</div>
<small class="text-muted">
{% trans with {'%current%': job.completedPartsCount + job.skippedPartsCount, '%total%': job.partCount} %}info_providers.bulk_import.progress_label{% endtrans %}
</small>
</td>
<td>
{% if job.isPending %}
<span class="badge bg-warning">{% trans %}info_providers.bulk_import.status.pending{% endtrans %}</span>
{% elseif job.isInProgress %}
<span class="badge bg-info">{% trans %}info_providers.bulk_import.status.in_progress{% endtrans %}</span>
{% elseif job.isCompleted %}
<span class="badge bg-success">{% trans %}info_providers.bulk_import.status.completed{% endtrans %}</span>
{% elseif job.isStopped %}
<span class="badge bg-secondary">{% trans %}info_providers.bulk_import.status.stopped{% endtrans %}</span>
{% elseif job.isFailed %}
<span class="badge bg-danger">{% trans %}info_providers.bulk_import.status.failed{% endtrans %}</span>
{% endif %}
</td>
<td>{{ job.createdBy.fullName(true) }}</td>
<td>{{ job.createdAt|format_datetime('short') }}</td>
<td>
{% if job.completedAt %}
{{ job.completedAt|format_datetime('short') }}
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td>
<div class="btn-group btn-group-sm" role="group">
{% if job.isInProgress or job.isCompleted or job.isStopped %}
<a href="{{ path('bulk_info_provider_step2', {'jobId': job.id}) }}" class="btn btn-primary">
<i class="fas fa-eye"></i> {% trans %}info_providers.bulk_import.view_results{% endtrans %}
</a>
{% endif %}
{% if job.canBeStopped %}
<button type="button" class="btn btn-warning" data-action="click->bulk-job-manage#stopJob" data-job-id="{{ job.id }}">
<i class="fas fa-stop"></i> {% trans %}info_providers.bulk_import.action.stop{% endtrans %}
</button>
{% endif %}
{% if job.isCompleted or job.isFailed or job.isStopped %}
<button type="button" class="btn btn-danger" data-action="click->bulk-job-manage#deleteJob" data-job-id="{{ job.id }}">
<i class="fas fa-trash"></i> {% trans %}info_providers.bulk_import.action.delete{% endtrans %}
</button>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
{% if active_jobs is empty and finished_jobs is empty %}
<div class="alert alert-info" role="alert">
<i class="fas fa-info-circle"></i>
{% trans %}info_providers.bulk_import.no_jobs_found{% endtrans %}<br>
{% trans %}info_providers.bulk_import.create_first_job{% endtrans %}
</div>
{% else %}
{# Active Jobs #}
{% if active_jobs is not empty %}
<h5 class="mb-3">
<i class="fas fa-tasks me-1"></i> {% trans %}info_providers.bulk_import.active_jobs{% endtrans %}
<span class="badge bg-primary">{{ active_jobs|length }}</span>
</h5>
{{ _self.job_table(active_jobs, false) }}
{% endif %}
{# Finished Jobs (History) #}
{% if finished_jobs is not empty %}
<h5 class="mb-3">
<i class="fas fa-history me-1"></i> {% trans %}info_providers.bulk_import.finished_jobs{% endtrans %}
<span class="badge bg-secondary">{{ finished_jobs|length }}</span>
</h5>
{{ _self.job_table(finished_jobs, true) }}
{% endif %}
{% endif %}
</div>
{% endblock %}
{% macro job_table(jobs, showCompletedAt) %}
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>{% trans %}info_providers.bulk_import.job_name{% endtrans %}</th>
<th>{% trans %}info_providers.bulk_import.parts_count{% endtrans %}</th>
<th>{% trans %}info_providers.bulk_import.results_count{% endtrans %}</th>
<th>{% trans %}info_providers.bulk_import.progress{% endtrans %}</th>
<th>{% trans %}info_providers.bulk_import.status{% endtrans %}</th>
<th>{% trans %}info_providers.bulk_import.created_by{% endtrans %}</th>
<th>{% trans %}info_providers.bulk_import.created_at{% endtrans %}</th>
{% if showCompletedAt %}
<th>{% trans %}info_providers.bulk_import.completed_at{% endtrans %}</th>
{% endif %}
<th>{% trans %}info_providers.bulk_import.action.label{% endtrans %}</th>
</tr>
</thead>
<tbody>
{% for job in jobs %}
{{ _self.job_row(job, showCompletedAt) }}
{% endfor %}
</tbody>
</table>
</div>
{% endmacro %}
{% macro job_row(job, showCompletedAt) %}
{% set showCompletedAt = showCompletedAt|default(false) %}
<tr>
<td>
<strong>#{{ job.id }} - {{ job.displayNameKey|trans(job.displayNameParams) }}</strong>
<br><small class="text-muted">{{ job.formattedTimestamp }}</small>
</td>
<td>{{ job.partCount }}</td>
<td>{{ job.resultCount }}</td>
<td>
<div class="d-flex align-items-center">
<div class="progress me-2" style="width: 80px; height: 12px;">
<div class="progress-bar {% if job.isCompleted %}bg-success{% elseif job.isFailed %}bg-danger{% else %}bg-info{% endif %}"
role="progressbar"
style="width: {{ job.progressPercentage }}%"
aria-valuenow="{{ job.progressPercentage }}"
aria-valuemin="0" aria-valuemax="100">
</div>
</div>
<small class="text-muted">{{ job.progressPercentage }}%</small>
</div>
<small class="text-muted">
{% trans with {'%current%': job.completedPartsCount + job.skippedPartsCount, '%total%': job.partCount} %}info_providers.bulk_import.progress_label{% endtrans %}
</small>
</td>
<td>
{% if job.isPending %}
<span class="badge bg-warning">{% trans %}info_providers.bulk_import.status.pending{% endtrans %}</span>
{% elseif job.isInProgress %}
<span class="badge bg-info">{% trans %}info_providers.bulk_import.status.in_progress{% endtrans %}</span>
{% elseif job.isCompleted %}
<span class="badge bg-success">{% trans %}info_providers.bulk_import.status.completed{% endtrans %}</span>
{% elseif job.isStopped %}
<span class="badge bg-secondary">{% trans %}info_providers.bulk_import.status.stopped{% endtrans %}</span>
{% elseif job.isFailed %}
<span class="badge bg-danger">{% trans %}info_providers.bulk_import.status.failed{% endtrans %}</span>
{% endif %}
</td>
<td>{{ job.createdBy.fullName(true) }}</td>
<td>{{ job.createdAt|format_datetime('short') }}</td>
{% if showCompletedAt %}
<td>
{% if job.completedAt %}
{{ job.completedAt|format_datetime('short') }}
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
{% endif %}
<td>
<div class="btn-group btn-group-sm" role="group">
{% if job.isInProgress or job.isCompleted or job.isStopped %}
<a href="{{ path('bulk_info_provider_step2', {'jobId': job.id}) }}" class="btn btn-primary">
<i class="fas fa-eye"></i> {% trans %}info_providers.bulk_import.view_results{% endtrans %}
</a>
{% endif %}
{% if job.canBeStopped %}
<button type="button" class="btn btn-warning" data-action="click->bulk-job-manage#stopJob" data-job-id="{{ job.id }}">
<i class="fas fa-stop"></i> {% trans %}info_providers.bulk_import.action.stop{% endtrans %}
</button>
{% endif %}
{% if job.isCompleted or job.isFailed or job.isStopped %}
<button type="button" class="btn btn-danger" data-action="click->bulk-job-manage#deleteJob" data-job-id="{{ job.id }}">
<i class="fas fa-trash"></i> {% trans %}info_providers.bulk_import.action.delete{% endtrans %}
</button>
{% endif %}
</div>
</td>
</tr>
{% endmacro %}

View file

@ -9,22 +9,42 @@
{% block card_title %}
<i class="fas fa-search"></i> {% trans %}info_providers.bulk_import.step2.title{% endtrans %}
<span class="badge bg-secondary">{{ job.displayNameKey|trans(job.displayNameParams) }} - {{ job.formattedTimestamp }}</span>
<span class="badge bg-secondary">#{{ job.id }} - {{ job.displayNameKey|trans(job.displayNameParams) }}</span>
{% endblock %}
{% block card_content %}
<!-- Navigation -->
<div class="d-flex justify-content-between align-items-center mb-3">
<a href="{{ path('bulk_info_provider_manage') }}" class="btn btn-outline-secondary btn-sm">
<i class="fas fa-arrow-left"></i> {% trans %}info_providers.bulk_import.back_to_jobs{% endtrans %}
</a>
<a href="{{ path('parts_show_all') }}" class="btn btn-outline-primary btn-sm">
<i class="fas fa-list"></i> {% trans %}info_providers.bulk_import.back_to_parts{% endtrans %}
</a>
</div>
{% if job.isCompleted %}
<div class="alert alert-success mb-3" role="alert">
<i class="fas fa-check-circle"></i>
<strong>{% trans %}info_providers.bulk_import.job_completed{% endtrans %}</strong>
{% trans %}info_providers.bulk_import.job_completed.description{% endtrans %}
</div>
{% endif %}
<div {{ stimulus_controller('bulk-import', {
'jobId': job.id,
'researchUrl': path('bulk_info_provider_research_part', {'jobId': job.id, 'partId': '__PART_ID__'}),
'researchAllUrl': path('bulk_info_provider_research_all', {'jobId': job.id}),
'markCompletedUrl': path('bulk_info_provider_mark_completed', {'jobId': job.id, 'partId': '__PART_ID__'}),
'markSkippedUrl': path('bulk_info_provider_mark_skipped', {'jobId': job.id, 'partId': '__PART_ID__'}),
'markPendingUrl': path('bulk_info_provider_mark_pending', {'jobId': job.id, 'partId': '__PART_ID__'})
'markPendingUrl': path('bulk_info_provider_mark_pending', {'jobId': job.id, 'partId': '__PART_ID__'}),
'quickApplyUrl': path('bulk_info_provider_quick_apply', {'jobId': job.id, 'partId': '__PART_ID__'}),
'quickApplyAllUrl': path('bulk_info_provider_quick_apply_all', {'jobId': job.id})
}) }}>
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h5 class="mb-1">{{ job.displayNameKey|trans(job.displayNameParams) }} - {{ job.formattedTimestamp }}</h5>
<h5 class="mb-1">#{{ job.id }} - {{ job.displayNameKey|trans(job.displayNameParams) }}</h5>
<small class="text-muted">
{{ job.partCount }} {% trans %}info_providers.bulk_import.parts{% endtrans %}
{{ job.resultCount }} {% trans %}info_providers.bulk_import.results{% endtrans %}
@ -95,6 +115,13 @@
<span class="spinner-border spinner-border-sm me-1" style="display: none;" id="research-all-spinner"></span>
<i class="fas fa-search"></i> {% trans %}info_providers.bulk_import.research.all_pending{% endtrans %}
</button>
<button type="button" class="btn btn-success btn-sm"
data-action="click->bulk-import#quickApplyAll"
id="quick-apply-all-btn"
title="{% trans %}info_providers.bulk_import.quick_apply_all.tooltip{% endtrans %}">
<span class="spinner-border spinner-border-sm me-1" style="display: none;" id="quick-apply-all-spinner"></span>
<i class="fas fa-bolt"></i> {% trans %}info_providers.bulk_import.quick_apply_all{% endtrans %}
</button>
</div>
</div>
</div>
@ -181,39 +208,74 @@
</tr>
</thead>
<tbody>
{% for result in part_result.searchResults %}
{% set sortedResults = part_result.resultsSortedByPriority %}
{% for result in sortedResults %}
{# @var result \App\Services\InfoProviderSystem\DTOs\BulkSearchPartResultDTO #}
{% set dto = result.searchResult %}
{% set localPart = result.localPart %}
<tr>
{% set isTopResult = loop.first %}
<tr{% if isTopResult and not isCompleted %} class="table-success"{% endif %}>
<td>
<img src="{{ dto.preview_image_url }}" data-thumbnail="{{ dto.preview_image_url }}"
class="hoverpic" style="max-width: 35px;" {{ stimulus_controller('elements/hoverpic') }}>
{% if dto.preview_image_url %}
<img src="{{ dto.preview_image_url }}" data-thumbnail="{{ dto.preview_image_url }}"
class="hoverpic" style="max-width: 35px;" {{ stimulus_controller('elements/hoverpic') }}
onerror="this.style.display='none'">
{% endif %}
</td>
<td>
{# Check for matches against source keyword (what was searched) #}
{% set sourceKw = result.sourceKeyword|default('')|lower %}
{% set nameMatch = sourceKw is not empty and dto.name is not null and dto.name|lower == sourceKw %}
{% set mpnMatch = sourceKw is not empty and dto.mpn is not null and dto.mpn|lower == sourceKw %}
{% set spnMatch = sourceKw is not empty and dto.provider_id is not null and dto.provider_id|lower == sourceKw %}
{% set anyMatch = nameMatch or mpnMatch or spnMatch %}
{% if dto.provider_url is not null %}
<a href="{{ dto.provider_url }}" target="_blank" rel="noopener">{{ dto.name }}</a>
<a href="{{ dto.provider_url }}" target="_blank" rel="noopener"{% if nameMatch %} class="fw-bold"{% endif %}>{{ dto.name }}</a>
{% else %}
{{ dto.name }}
<span{% if nameMatch %} class="fw-bold"{% endif %}>{{ dto.name }}</span>
{% endif %}
{% if nameMatch %}
<span class="badge bg-success ms-1" title="{% trans %}info_providers.bulk_import.exact_match{% endtrans %}"><i class="fas fa-check-circle"></i></span>
{% endif %}
{% if dto.mpn is not null %}
<br><small class="text-muted">{{ dto.mpn }}</small>
<br><small{% if mpnMatch %} class="fw-bold text-success"{% endif %}>{{ dto.mpn }}</small>
{% if mpnMatch %}
<span class="badge bg-success ms-1" style="font-size: 0.65em;" title="{% trans %}info_providers.bulk_import.mpn_match{% endtrans %}">MPN <i class="fas fa-check-circle"></i></span>
{% endif %}
{% endif %}
</td>
<td>{{ dto.description }}</td>
<td>{{ dto.manufacturer ?? '' }}</td>
<td>
{{ info_provider_label(dto.provider_key)|default(dto.provider_key) }}
<br><small class="text-muted">{{ dto.provider_id }}</small>
<br><small{% if spnMatch %} class="fw-bold text-success"{% endif %}>{{ dto.provider_id }}</small>
{% if spnMatch %}
<span class="badge bg-success ms-1" style="font-size: 0.65em;" title="{% trans %}info_providers.bulk_import.spn_match{% endtrans %}">SPN <i class="fas fa-check-circle"></i></span>
{% endif %}
</td>
<td>
<span class="badge bg-info">{{ result.sourceField ?? 'unknown' }}</span>
{% if anyMatch %}
<span class="badge bg-success">{% trans %}info_providers.bulk_import.match{% endtrans %}</span>
{% else %}
<span class="badge bg-info">{{ result.sourceField ?? 'unknown' }}</span>
{% endif %}
{% if result.sourceKeyword %}
<br><small class="text-muted">{{ result.sourceKeyword }}</small>
{% endif %}
<br><small{% if anyMatch %} class="fw-bold text-success"{% endif %}>{{ result.sourceKeyword }}</small>
{% endif %}
</td>
<td>
<div class="btn-group-vertical btn-group-sm" role="group">
{% if not isCompleted %}
<button type="button" class="btn {% if not isTopResult %} btn-outline-success{% else %}btn-success{% endif %}"
data-action="click->bulk-import#quickApply"
data-part-id="{{ part.id }}"
data-provider-key="{{ dto.provider_key }}"
data-provider-id="{{ dto.provider_id }}"
title="{% trans %}info_providers.bulk_import.quick_apply.tooltip{% endtrans %}">
<i class="fas fa-bolt"></i> {% trans %}info_providers.bulk_import.quick_apply{% endtrans %}
{% if isTopResult %}<span class="badge bg-light text-success ms-1">{% trans %}info_providers.bulk_import.recommended{% endtrans %}</span>{% endif %}
</button>
{% endif %}
{% set updateHref = path('info_providers_update_part',
{'id': part.id, 'providerKey': dto.provider_key, 'providerId': dto.provider_id}) ~ '?jobId=' ~ job.id %}
<a class="btn btn-primary{% if isCompleted %} disabled{% endif %}" href="{% if not isCompleted %}{{ updateHref }}{% else %}#{% endif %}"{% if isCompleted %} aria-disabled="true"{% endif %}>

File diff suppressed because it is too large Load diff

View file

@ -11211,6 +11211,96 @@ Please note, that you can not impersonate a disabled user. If you try you will g
<target>Update Part</target>
</segment>
</unit>
<unit id="_sWGLGs" name="info_providers.bulk_import.back_to_jobs">
<segment state="translated">
<source>info_providers.bulk_import.back_to_jobs</source>
<target>Back to Jobs</target>
</segment>
</unit>
<unit id="2DCRx_T" name="info_providers.bulk_import.back_to_parts">
<segment state="translated">
<source>info_providers.bulk_import.back_to_parts</source>
<target>Back to Parts</target>
</segment>
</unit>
<unit id="9OAohXg" name="info_providers.bulk_import.job_completed">
<segment state="translated">
<source>info_providers.bulk_import.job_completed</source>
<target>Job completed!</target>
</segment>
</unit>
<unit id="hwkbU38" name="info_providers.bulk_import.job_completed.description">
<segment state="translated">
<source>info_providers.bulk_import.job_completed.description</source>
<target>All parts have been processed. You can review the results below or navigate back to the parts list.</target>
</segment>
</unit>
<unit id="ahbWfwA" name="info_providers.bulk_import.recommended">
<segment state="translated">
<source>info_providers.bulk_import.recommended</source>
<target>Top</target>
</segment>
</unit>
<unit id="tFJOMYX" name="info_providers.bulk_import.exact_match">
<segment state="translated">
<source>info_providers.bulk_import.exact_match</source>
<target>Exact name match</target>
</segment>
</unit>
<unit id="mBAxdTx" name="info_providers.bulk_import.mpn_match">
<segment state="translated">
<source>info_providers.bulk_import.mpn_match</source>
<target>MPN matches</target>
</segment>
</unit>
<unit id="W1HbYWX" name="info_providers.bulk_import.active_jobs">
<segment state="translated">
<source>info_providers.bulk_import.active_jobs</source>
<target>Active Jobs</target>
</segment>
</unit>
<unit id="tZSOzU1" name="info_providers.bulk_import.finished_jobs">
<segment state="translated">
<source>info_providers.bulk_import.finished_jobs</source>
<target>History</target>
</segment>
</unit>
<unit id="noEU4s7" name="info_providers.bulk_import.spn_match">
<segment state="translated">
<source>info_providers.bulk_import.spn_match</source>
<target>SPN matches</target>
</segment>
</unit>
<unit id="RiHOuLh" name="info_providers.bulk_import.match">
<segment state="translated">
<source>info_providers.bulk_import.match</source>
<target>Match</target>
</segment>
</unit>
<unit id="UCKGkQ3" name="info_providers.bulk_import.quick_apply">
<segment state="translated">
<source>info_providers.bulk_import.quick_apply</source>
<target>Quick Apply</target>
</segment>
</unit>
<unit id="4uMgGbn" name="info_providers.bulk_import.quick_apply.tooltip">
<segment state="translated">
<source>info_providers.bulk_import.quick_apply.tooltip</source>
<target>Apply this provider result to the part without opening the edit form</target>
</segment>
</unit>
<unit id="a8kwuvb" name="info_providers.bulk_import.quick_apply_all">
<segment state="translated">
<source>info_providers.bulk_import.quick_apply_all</source>
<target>Apply All (Top Results)</target>
</segment>
</unit>
<unit id=".iZc63I" name="info_providers.bulk_import.quick_apply_all.tooltip">
<segment state="translated">
<source>info_providers.bulk_import.quick_apply_all.tooltip</source>
<target>Apply the top-ranked search result to all pending parts without individual review</target>
</segment>
</unit>
<unit id="e_DDQ2u" name="info_providers.bulk_import.prefetch_details">
<segment state="translated">
<source>info_providers.bulk_import.prefetch_details</source>