Compare commits

..

No commits in common. "19d138632acd3df86a629e2ebb2adba8b78de0e9" and "673d5b5e83108e94d16433c5b081af19337f8fa9" have entirely different histories.

17 changed files with 225 additions and 1779 deletions

View file

@ -3,16 +3,14 @@ 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,
quickApplyUrl: String,
quickApplyAllUrl: String
markPendingUrl: String
}
connect() {
@ -121,11 +119,13 @@ 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'
method: 'POST',
body: JSON.stringify({ reason })
})
if (data.success) {
@ -321,94 +321,6 @@ 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,13 +70,6 @@ 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()
}
@ -126,18 +119,6 @@ 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

58
composer.lock generated
View file

@ -318,16 +318,16 @@
},
{
"name": "amphp/hpack",
"version": "v3.2.2",
"version": "v3.2.1",
"source": {
"type": "git",
"url": "https://github.com/amphp/hpack.git",
"reference": "291da27078e7e149a9bad4d08ff05bf7d81c89f4"
"reference": "4f293064b15682a2b178b1367ddf0b8b5feb0239"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/amphp/hpack/zipball/291da27078e7e149a9bad4d08ff05bf7d81c89f4",
"reference": "291da27078e7e149a9bad4d08ff05bf7d81c89f4",
"url": "https://api.github.com/repos/amphp/hpack/zipball/4f293064b15682a2b178b1367ddf0b8b5feb0239",
"reference": "4f293064b15682a2b178b1367ddf0b8b5feb0239",
"shasum": ""
},
"require": {
@ -336,7 +336,7 @@
"require-dev": {
"amphp/php-cs-fixer-config": "^2",
"http2jp/hpack-test-case": "^1",
"nikic/php-fuzzer": "^0.0.11",
"nikic/php-fuzzer": "^0.0.10",
"phpunit/phpunit": "^7 | ^8 | ^9"
},
"type": "library",
@ -380,7 +380,7 @@
],
"support": {
"issues": "https://github.com/amphp/hpack/issues",
"source": "https://github.com/amphp/hpack/tree/v3.2.2"
"source": "https://github.com/amphp/hpack/tree/v3.2.1"
},
"funding": [
{
@ -388,7 +388,7 @@
"type": "github"
}
],
"time": "2026-05-03T19:28:59+00:00"
"time": "2024-03-21T19:00:16+00:00"
},
{
"name": "amphp/http",
@ -17374,16 +17374,16 @@
},
{
"name": "symplify/easy-coding-standard",
"version": "13.1.2",
"version": "13.0.4",
"source": {
"type": "git",
"url": "https://github.com/easy-coding-standard/ecs.git",
"reference": "6d22473d1f36945884d8cb291777166020a47770"
"url": "https://github.com/easy-coding-standard/easy-coding-standard.git",
"reference": "5c7e7a07e5d6a98b9dd2e6fc0a9155efb7c166c8"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/easy-coding-standard/ecs/zipball/6d22473d1f36945884d8cb291777166020a47770",
"reference": "6d22473d1f36945884d8cb291777166020a47770",
"url": "https://api.github.com/repos/easy-coding-standard/easy-coding-standard/zipball/5c7e7a07e5d6a98b9dd2e6fc0a9155efb7c166c8",
"reference": "5c7e7a07e5d6a98b9dd2e6fc0a9155efb7c166c8",
"shasum": ""
},
"require": {
@ -17418,8 +17418,8 @@
"static analysis"
],
"support": {
"issues": "https://github.com/easy-coding-standard/ecs/issues",
"source": "https://github.com/easy-coding-standard/ecs/tree/13.1.2"
"issues": "https://github.com/easy-coding-standard/easy-coding-standard/issues",
"source": "https://github.com/easy-coding-standard/easy-coding-standard/tree/13.0.4"
},
"funding": [
{
@ -17431,7 +17431,7 @@
"type": "github"
}
],
"time": "2026-05-03T22:05:09+00:00"
"time": "2026-01-05T09:10:04+00:00"
},
{
"name": "tecnickcom/tc-lib-barcode",
@ -18479,16 +18479,16 @@
},
{
"name": "web-auth/cose-lib",
"version": "4.5.2",
"version": "4.5.1",
"source": {
"type": "git",
"url": "https://github.com/web-auth/cose-lib.git",
"reference": "5b38660f90070a8e45f3dbc9528ade3b608dd77d"
"reference": "3185af4df10dc537b65c140c315b88d15ae15b80"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/web-auth/cose-lib/zipball/5b38660f90070a8e45f3dbc9528ade3b608dd77d",
"reference": "5b38660f90070a8e45f3dbc9528ade3b608dd77d",
"url": "https://api.github.com/repos/web-auth/cose-lib/zipball/3185af4df10dc537b65c140c315b88d15ae15b80",
"reference": "3185af4df10dc537b65c140c315b88d15ae15b80",
"shasum": ""
},
"require": {
@ -18534,7 +18534,7 @@
],
"support": {
"issues": "https://github.com/web-auth/cose-lib/issues",
"source": "https://github.com/web-auth/cose-lib/tree/4.5.2"
"source": "https://github.com/web-auth/cose-lib/tree/4.5.1"
},
"funding": [
{
@ -18546,11 +18546,11 @@
"type": "patreon"
}
],
"time": "2026-05-03T09:49:50+00:00"
"time": "2026-04-01T12:47:39+00:00"
},
{
"name": "web-auth/webauthn-lib",
"version": "5.3.2",
"version": "5.3.1",
"source": {
"type": "git",
"url": "https://github.com/web-auth/webauthn-lib.git",
@ -18620,7 +18620,7 @@
"webauthn"
],
"support": {
"source": "https://github.com/web-auth/webauthn-lib/tree/5.3.2"
"source": "https://github.com/web-auth/webauthn-lib/tree/5.3.1"
},
"funding": [
{
@ -18636,16 +18636,16 @@
},
{
"name": "web-auth/webauthn-symfony-bundle",
"version": "5.3.2",
"version": "5.3.1",
"source": {
"type": "git",
"url": "https://github.com/web-auth/webauthn-symfony-bundle.git",
"reference": "1d20af98b50810e8776c52b671201b6bb73ea981"
"reference": "40f5ae033d4bea090aaa395b906ebfa7d7fe6055"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/web-auth/webauthn-symfony-bundle/zipball/1d20af98b50810e8776c52b671201b6bb73ea981",
"reference": "1d20af98b50810e8776c52b671201b6bb73ea981",
"url": "https://api.github.com/repos/web-auth/webauthn-symfony-bundle/zipball/40f5ae033d4bea090aaa395b906ebfa7d7fe6055",
"reference": "40f5ae033d4bea090aaa395b906ebfa7d7fe6055",
"shasum": ""
},
"require": {
@ -18703,7 +18703,7 @@
"webauthn"
],
"support": {
"source": "https://github.com/web-auth/webauthn-symfony-bundle/tree/5.3.2"
"source": "https://github.com/web-auth/webauthn-symfony-bundle/tree/5.3.1"
},
"funding": [
{
@ -18715,7 +18715,7 @@
"type": "patreon"
}
],
"time": "2026-05-04T08:08:16+00:00"
"time": "2026-05-01T12:14:37+00:00"
},
{
"name": "webmozart/assert",

View file

@ -1,4 +1,4 @@
# Generated on Mon May 4 05:40:05 UTC 2026
# Generated on Mon Apr 13 05:19:27 UTC 2026
# This file contains all footprints available in the offical KiCAD library
Audio_Module:Reverb_BTDR-1H
Audio_Module:Reverb_BTDR-1V
@ -8366,7 +8366,6 @@ Converter_DCDC:Converter_DCDC_TRACO_TMR-1SM_SMD
Converter_DCDC:Converter_DCDC_TRACO_TMR10-24xxWIR_48xxWIR_72xxWIR_THT
Converter_DCDC:Converter_DCDC_TRACO_TMR2-xxxxWI_THT
Converter_DCDC:Converter_DCDC_TRACO_TMR4-xxxxWI_THT
Converter_DCDC:Converter_DCDC_TRACO_TMR8-xxxxWI_THT
Converter_DCDC:Converter_DCDC_TRACO_TMU3-05xx_12xx_THT
Converter_DCDC:Converter_DCDC_TRACO_TMU3-24xx_THT
Converter_DCDC:Converter_DCDC_TRACO_TMV-051xD_121xD_Dual_THT
@ -11979,8 +11978,6 @@ Package_DFN_QFN:VQFN-48-1EP_7x7mm_P0.5mm_EP4.2x4.2mm
Package_DFN_QFN:VQFN-48-1EP_7x7mm_P0.5mm_EP4.2x4.2mm_ThermalVias
Package_DFN_QFN:VQFN-48-1EP_7x7mm_P0.5mm_EP5.15x5.15mm
Package_DFN_QFN:VQFN-48-1EP_7x7mm_P0.5mm_EP5.15x5.15mm_ThermalVias
Package_DFN_QFN:VQFN-52-1EP_6x6mm_P0.4mm_EP4.7x4.7mm
Package_DFN_QFN:VQFN-52-1EP_6x6mm_P0.4mm_EP4.7x4.7mm_ThermalVias
Package_DFN_QFN:VQFN-56-1EP_8x8mm_P0.5mm_EP5.1x4.96mm
Package_DFN_QFN:VQFN-56-1EP_8x8mm_P0.5mm_EP5.1x4.96mm_ThermalVias
Package_DFN_QFN:VQFN-56-1EP_8x8mm_P0.5mm_EP5.5x5.06mm
@ -12031,8 +12028,6 @@ Package_DFN_QFN:WQFN-24-1EP_4x4mm_P0.5mm_EP2.45x2.45mm
Package_DFN_QFN:WQFN-24-1EP_4x4mm_P0.5mm_EP2.45x2.45mm_ThermalVias
Package_DFN_QFN:WQFN-24-1EP_4x4mm_P0.5mm_EP2.6x2.6mm
Package_DFN_QFN:WQFN-24-1EP_4x4mm_P0.5mm_EP2.6x2.6mm_ThermalVias
Package_DFN_QFN:WQFN-28-1EP_3.5x5.5mm_P0.5mm_EP2.05x4.05mm
Package_DFN_QFN:WQFN-28-1EP_3.5x5.5mm_P0.5mm_EP2.05x4.05mm_ThermalVias
Package_DFN_QFN:WQFN-28-1EP_4x4mm_P0.4mm_EP2.7x2.7mm
Package_DFN_QFN:WQFN-28-1EP_4x4mm_P0.4mm_EP2.7x2.7mm_ThermalVias
Package_DFN_QFN:WQFN-32-1EP_5x5mm_P0.5mm_EP3.1x3.1mm

View file

@ -1,4 +1,4 @@
# Generated on Mon May 4 05:40:43 UTC 2026
# Generated on Mon Apr 13 05:20:06 UTC 2026
# This file contains all symbols available in the offical KiCAD library
4xxx:14528
4xxx:14529
@ -8845,7 +8845,6 @@ Interface_USB:CH343G
Interface_USB:CH343P
Interface_USB:CH344Q
Interface_USB:CH9102F
Interface_USB:CP2102C-Axx-xQFN24
Interface_USB:CP2102N-Axx-xQFN20
Interface_USB:CP2102N-Axx-xQFN24
Interface_USB:CP2102N-Axx-xQFN28

View file

@ -229,37 +229,24 @@ class DBPlatformConvertCommand extends Command
if ($platform instanceof PostgreSQLPlatform) {
$connection->executeStatement(
//See https://github.com/Part-DB/Part-DB-server/issues/1362
//From: https://wiki.postgresql.org/wiki/Fixing_Sequences
<<<SQL
DO $$
DECLARE
rec RECORD;
max_id BIGINT;
seq TEXT;
BEGIN
FOR rec IN
SELECT c.table_name
FROM information_schema.columns c
JOIN pg_tables t
ON t.tablename = c.table_name AND t.schemaname = 'public'
WHERE c.column_name = 'id'
AND c.table_schema = 'public'
LOOP
BEGIN
seq := pg_get_serial_sequence(rec.table_name, 'id');
IF seq IS NOT NULL THEN
EXECUTE format('SELECT MAX(id) FROM %I', rec.table_name) INTO max_id;
IF max_id IS NOT NULL THEN
PERFORM setval(seq, max_id);
RAISE NOTICE 'Reset: %.id → %', rec.table_name, max_id;
END IF;
END IF;
EXCEPTION WHEN OTHERS THEN
RAISE NOTICE 'Skipped %: %', rec.table_name, SQLERRM;
END;
END LOOP;
END;
$$;
SELECT 'SELECT SETVAL(' ||
quote_literal(quote_ident(PGT.schemaname) || '.' || quote_ident(S.relname)) ||
', COALESCE(MAX(' ||quote_ident(C.attname)|| '), 1) ) FROM ' ||
quote_ident(PGT.schemaname)|| '.'||quote_ident(T.relname)|| ';'
FROM pg_class AS S,
pg_depend AS D,
pg_class AS T,
pg_attribute AS C,
pg_tables AS PGT
WHERE S.relkind = 'S'
AND S.oid = D.objid
AND D.refobjid = T.oid
AND D.refobjid = C.attrelid
AND D.refobjsubid = C.attnum
AND T.relname = PGT.tablename
ORDER BY S.relname;
SQL);
}
}

View file

@ -29,14 +29,11 @@ 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;
@ -69,10 +66,6 @@ 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;
@ -283,8 +276,8 @@ class BulkInfoProviderImportController extends AbstractController
$updatedJobs = true;
}
// Mark jobs with no results for deletion (failed searches or stale pending)
if ($job->getResultCount() === 0 && ($job->isInProgress() || $job->isPending())) {
// Mark jobs with no results for deletion (failed searches)
if ($job->getResultCount() === 0 && $job->isInProgress()) {
$jobsToDelete[] = $job;
}
}
@ -304,23 +297,9 @@ 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', [
'active_jobs' => $activeJobs,
'finished_jobs' => $finishedJobs,
'jobs' => $this->entityManager->getRepository(BulkInfoProviderImportJob::class)
->findBy([], ['createdAt' => 'DESC']) // Refetch after cleanup
]);
}
@ -491,13 +470,22 @@ class BulkInfoProviderImportController extends AbstractController
$fieldMappingDtos = $job->getFieldMappings();
$prefetchDetails = $job->isPrefetchDetails();
$searchResultsDto = $this->bulkService->performBulkSearch([$part], $fieldMappingDtos, $prefetchDetails);
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;
}
}
// Update the job's search results for this specific part efficiently
$this->updatePartSearchResults($job, $searchResultsDto[0] ?? null);
// Prefetch details if requested
if ($prefetchDetails) {
if ($prefetchDetails && $searchResultsDto !== null) {
$this->bulkService->prefetchDetailsForResults($searchResultsDto);
}
@ -527,191 +515,6 @@ 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

@ -36,7 +36,6 @@ use App\Entity\PriceInformations\Orderdetail;
use App\Entity\ProjectSystem\Project;
use App\Exceptions\AttachmentDownloadException;
use App\Form\Part\PartBaseType;
use App\Form\Part\PartLotType;
use App\Services\Attachments\AttachmentSubmitHandler;
use App\Services\Attachments\PartPreviewGenerator;
use App\Services\EntityMergers\Mergers\PartMerger;
@ -129,17 +128,6 @@ final class PartController extends AbstractController
$table = null;
}
// Build the add-lot form for the INFO page modal (only when not in time-travel mode)
$addLotForm = null;
if ($timeTravel_timestamp === null && $this->isGranted('edit', $part)) {
$newLot = new PartLot();
$newLot->setPart($part);
$addLotForm = $this->createForm(PartLotType::class, $newLot, [
'measurement_unit' => $part->getPartUnit(),
'action' => $this->generateUrl('part_lot_add', ['id' => $part->getID()]),
]);
}
return $this->render(
'parts/info/show_part_info.html.twig',
[
@ -152,39 +140,10 @@ final class PartController extends AbstractController
'comment_params' => $this->partInfoSettings->extractParamsFromNotes ? $parameterExtractor->extractParameters($part->getComment()) : [],
'withdraw_add_helper' => $withdrawAddHelper,
'highlightLotId' => $request->query->getInt('highlightLot', 0),
'add_lot_form' => $addLotForm,
]
);
}
#[Route(path: '/{id}/add_lot', name: 'part_lot_add', methods: ['POST'])]
public function addLot(Part $part, Request $request, EntityManagerInterface $em): Response
{
$this->denyAccessUnlessGranted('edit', $part);
$newLot = new PartLot();
$newLot->setPart($part);
$form = $this->createForm(PartLotType::class, $newLot, [
'measurement_unit' => $part->getPartUnit(),
]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$em->persist($newLot);
$em->flush();
$this->addFlash('success', 'part.edited_flash');
return $this->redirectToRoute('part_info', [
'id' => $part->getID(),
'highlightLot' => $newLot->getID(),
]);
}
$this->addFlash('error', 'part.created_flash.invalid');
return $this->redirectToRoute('part_info', ['id' => $part->getID()]);
}
#[Route(path: '/{id}/edit', name: 'part_edit')]
public function edit(Part $part, Request $request): Response
{

View file

@ -46,6 +46,7 @@ final class BulkInfoProviderService
}
$partResults = [];
$hasAnyResults = false;
// Group providers by batch capability
$batchProviders = [];
@ -87,6 +88,7 @@ final class BulkInfoProviderService
);
if (!empty($allResults)) {
$hasAnyResults = true;
$searchResults = $this->formatSearchResults($allResults);
}
@ -97,6 +99,10 @@ 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,7 +397,6 @@ 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,130 +22,103 @@
</p>
</div>
{% if active_jobs is empty and finished_jobs is empty %}
{% 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 %}
<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,42 +9,22 @@
{% block card_title %}
<i class="fas fa-search"></i> {% trans %}info_providers.bulk_import.step2.title{% endtrans %}
<span class="badge bg-secondary">#{{ job.id }} - {{ job.displayNameKey|trans(job.displayNameParams) }}</span>
<span class="badge bg-secondary">{{ job.displayNameKey|trans(job.displayNameParams) }} - {{ job.formattedTimestamp }}</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__'}),
'quickApplyUrl': path('bulk_info_provider_quick_apply', {'jobId': job.id, 'partId': '__PART_ID__'}),
'quickApplyAllUrl': path('bulk_info_provider_quick_apply_all', {'jobId': job.id})
'markPendingUrl': path('bulk_info_provider_mark_pending', {'jobId': job.id, 'partId': '__PART_ID__'})
}) }}>
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h5 class="mb-1">#{{ job.id }} - {{ job.displayNameKey|trans(job.displayNameParams) }}</h5>
<h5 class="mb-1">{{ job.displayNameKey|trans(job.displayNameParams) }} - {{ job.formattedTimestamp }}</h5>
<small class="text-muted">
{{ job.partCount }} {% trans %}info_providers.bulk_import.parts{% endtrans %}
{{ job.resultCount }} {% trans %}info_providers.bulk_import.results{% endtrans %}
@ -115,13 +95,6 @@
<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>
@ -208,74 +181,39 @@
</tr>
</thead>
<tbody>
{% set sortedResults = part_result.resultsSortedByPriority %}
{% for result in sortedResults %}
{% for result in part_result.searchResults %}
{# @var result \App\Services\InfoProviderSystem\DTOs\BulkSearchPartResultDTO #}
{% set dto = result.searchResult %}
{% set localPart = result.localPart %}
{% set isTopResult = loop.first %}
<tr{% if isTopResult and not isCompleted %} class="table-success"{% endif %}>
<tr>
<td>
{% 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 %}
<img src="{{ dto.preview_image_url }}" data-thumbnail="{{ dto.preview_image_url }}"
class="hoverpic" style="max-width: 35px;" {{ stimulus_controller('elements/hoverpic') }}>
</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"{% if nameMatch %} class="fw-bold"{% endif %}>{{ dto.name }}</a>
<a href="{{ dto.provider_url }}" target="_blank" rel="noopener">{{ dto.name }}</a>
{% else %}
<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>
{{ dto.name }}
{% endif %}
{% if dto.mpn is not null %}
<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 %}
<br><small class="text-muted">{{ dto.mpn }}</small>
{% endif %}
</td>
<td>{{ dto.description }}</td>
<td>{{ dto.manufacturer ?? '' }}</td>
<td>
{{ info_provider_label(dto.provider_key)|default(dto.provider_key) }}
<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 %}
<br><small class="text-muted">{{ dto.provider_id }}</small>
</td>
<td>
{% 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 %}
<span class="badge bg-info">{{ result.sourceField ?? 'unknown' }}</span>
{% if result.sourceKeyword %}
<br><small{% if anyMatch %} class="fw-bold text-success"{% endif %}>{{ result.sourceKeyword }}</small>
{% endif %}
<br><small class="text-muted">{{ 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 %}>

View file

@ -1,46 +0,0 @@
{% if add_lot_form is not null %}
{% form_theme add_lot_form 'form/extended_bootstrap_layout.html.twig' %}
<div class="modal fade" id="add-lot-modal" tabindex="-1" aria-labelledby="add-lot-modal-title" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
{{ form_start(add_lot_form) }}
<div class="modal-header">
<h1 class="modal-title fs-5" id="add-lot-modal-title">
<i class="fas fa-plus-square fa-fw"></i>
{% trans %}part_lot.create{% endtrans %}
</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
{{ form_row(add_lot_form.description) }}
{{ form_row(add_lot_form.storage_location) }}
{{ form_row(add_lot_form.amount) }}
{{ form_row(add_lot_form.instock_unknown) }}
{{ form_row(add_lot_form.needs_refill) }}
{{ form_row(add_lot_form.expiration_date) }}
<div>
<a class="btn btn-link btn-sm {{ offset_label }}" data-bs-toggle="collapse" href="#add-lot-advanced" role="button" aria-expanded="false" aria-controls="add-lot-advanced">
{% trans %}part_lot.edit.advanced{% endtrans %}
</a>
<div class="collapse" id="add-lot-advanced">
{{ form_row(add_lot_form.comment) }}
{{ form_row(add_lot_form.owner) }}
{{ form_row(add_lot_form.user_barcode) }}
{{ form_row(add_lot_form.last_stocktake_at) }}
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% trans %}modal.close{% endtrans %}</button>
<button type="submit" class="btn btn-success">
<i class="fas fa-plus-square fa-fw"></i>
{% trans %}part_lot.create{% endtrans %}
</button>
</div>
{{ form_end(add_lot_form) }}
</div>
</div>
</div>
{% endif %}

View file

@ -3,7 +3,6 @@
{% include "parts/info/_withdraw_modal.html.twig" %}
{% include "parts/info/_stocktake_modal.html.twig" %}
{% include "parts/info/_add_lot_modal.html.twig" %}
<div class="table-responsive">
<table class="table table-striped table-hover">
@ -127,10 +126,3 @@
</table>
</div>
{% if add_lot_form is not null %}
<button type="button" class="btn btn-outline-success" data-bs-toggle="modal" data-bs-target="#add-lot-modal">
<i class="fas fa-plus-square fa-fw"></i>
{% trans %}part_lot.create{% endtrans %}
</button>
{% endif %}

File diff suppressed because it is too large Load diff

View file

@ -11211,96 +11211,6 @@ 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>

View file

@ -2221,11 +2221,11 @@ bail@^2.0.0:
integrity sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==
barcode-detector@^3.0.0, barcode-detector@^3.0.5:
version "3.1.3"
resolved "https://registry.yarnpkg.com/barcode-detector/-/barcode-detector-3.1.3.tgz#ea3224c8cf106b91e4f05a25ff0d798cb2b380a9"
integrity sha512-omL3/x26oU9jlR0gUQcGdXIjQtMlrUGKF7xRFO1RwrQkRkRU7WLz0mgQEsdUtYBm2uX3JH+HQLrKlyTS/BxZRw==
version "3.1.2"
resolved "https://registry.yarnpkg.com/barcode-detector/-/barcode-detector-3.1.2.tgz#8032a211ebb6cb5cc25724c5c56322c77ed02503"
integrity sha512-Q5kjXpVH5I3ItykNzbWmfWnNryFN1ZTWp10k9/PKJuS0RnoKR7jTrHEJODR4fn04bRomq7TJwie/Dr9fj/GoGQ==
dependencies:
zxing-wasm "3.0.3"
zxing-wasm "3.0.2"
base64-js@0.0.8:
version "0.0.8"
@ -2238,9 +2238,9 @@ base64-js@^1.1.2, base64-js@^1.3.0:
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
baseline-browser-mapping@^2.10.12:
version "2.10.27"
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.27.tgz#fee941c2a0b42cdf83c6427e4c830b1d0bdab2c3"
integrity sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==
version "2.10.25"
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.25.tgz#183b45c0d3bdd12addb352426555fb3627eb022a"
integrity sha512-QO/VHsXCQdnzADMfmkeOPvHdIAkoB7i0/rGjINPJEetLx75hNttVWGQ/jycHUDP9zZ9rupbm60WRxcwViB0MiA==
big.js@^5.2.2:
version "5.2.2"
@ -2650,10 +2650,10 @@ cssesc@^3.0.0:
resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee"
integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==
cssnano-preset-default@^7.0.17:
version "7.0.17"
resolved "https://registry.yarnpkg.com/cssnano-preset-default/-/cssnano-preset-default-7.0.17.tgz#6c239741cb8fd77556d0c55575de95c38f3a2537"
integrity sha512-11qO63A+czwguQFJCaTdICvbaxn0pJzz/XghLlv+OT7WyToDxAMR0Xb3/26/l0y0hQJywwNbj/SLSQlGBHE1OA==
cssnano-preset-default@^7.0.16:
version "7.0.16"
resolved "https://registry.yarnpkg.com/cssnano-preset-default/-/cssnano-preset-default-7.0.16.tgz#b0c9576c87488dfa00388e3a0c8b27ee946aa352"
integrity sha512-W0hiFi/ca/u2OTptL11OdApaz1vh9jyfd2ku9dMjou6KdpdgbMTagaXHKNl5kaEyRSCu9GIIaPRp5YLdqRAZMw==
dependencies:
browserslist "^4.28.2"
css-declaration-sorter "^7.2.0"
@ -2670,7 +2670,7 @@ cssnano-preset-default@^7.0.17:
postcss-minify-font-values "^7.0.3"
postcss-minify-gradients "^7.0.5"
postcss-minify-params "^7.0.9"
postcss-minify-selectors "^7.1.2"
postcss-minify-selectors "^7.1.1"
postcss-normalize-charset "^7.0.3"
postcss-normalize-display-values "^7.0.3"
postcss-normalize-positions "^7.0.4"
@ -2692,11 +2692,11 @@ cssnano-utils@^5.0.3:
integrity sha512-ynIREMICLxkxm7e9bCR9sh75s4Q5drICi0ua1yxo5jH2XPBqSKkl4dOh4EbFqtUmnTMhRffHgYL0EKKkMjtJTg==
cssnano@^7.0.4:
version "7.1.9"
resolved "https://registry.yarnpkg.com/cssnano/-/cssnano-7.1.9.tgz#1e8b5db528ae7cb175da0197adfbc559170f845e"
integrity sha512-uPR75+5Dk/WJ/YSPR1/YDHdwMM9c5FsaARljfKWgeCKLKOtJ0we21xy/RcCjn53fZnD/f6yYEIZ8pu18+GnbNQ==
version "7.1.8"
resolved "https://registry.yarnpkg.com/cssnano/-/cssnano-7.1.8.tgz#a6508bd13f3b206676c6594fa19ef45a89acc9dc"
integrity sha512-OGXtXqXmwEoIGfXM2QoD35vweUAtx+J8ZvLSZHOEV0Jv9Hs9ScTdGGjRzZXun5J4PEZhEoytKig2O2NR8NXxKw==
dependencies:
cssnano-preset-default "^7.0.17"
cssnano-preset-default "^7.0.16"
lilconfig "^3.1.3"
csso@^5.0.5:
@ -3039,9 +3039,9 @@ fast-deep-equal@^3.1.3:
integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
fast-uri@^3.0.1:
version "3.1.1"
resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.1.1.tgz#dd085fec2494a2a33bac6e61277374669e1dd774"
integrity sha512-h2r7rcm6Ee/J8o0LD5djLuFVcfbZxhvho4vvsbeV0aMvXjUgqv4YpxpkEx0d68l6+IleVfLAdVEfhR7QNMkGHQ==
version "3.1.0"
resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.1.0.tgz#66eecff6c764c0df9b762e62ca7edcfb53b4edfa"
integrity sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==
fastest-levenshtein@1.0.16, fastest-levenshtein@^1.0.12, fastest-levenshtein@^1.0.16:
version "1.0.16"
@ -4314,10 +4314,10 @@ postcss-minify-params@^7.0.9:
cssnano-utils "^5.0.3"
postcss-value-parser "^4.2.0"
postcss-minify-selectors@^7.1.2:
version "7.1.2"
resolved "https://registry.yarnpkg.com/postcss-minify-selectors/-/postcss-minify-selectors-7.1.2.tgz#9cef2eb836fb95c49c11ea6d0921743c9d2f7eb3"
integrity sha512-aQtrEWKwqafNlExcKHQvPGsXR2+vlUqqJtf5XsCQcgsSb5PL4wlujWBYDJuWsP4UnQX1YHDHU8qRlD+1PzTQ+Q==
postcss-minify-selectors@^7.1.1:
version "7.1.1"
resolved "https://registry.yarnpkg.com/postcss-minify-selectors/-/postcss-minify-selectors-7.1.1.tgz#2de3a0f9fc07d745d4164adc59865b3a0c9b1b87"
integrity sha512-MZWXwSTfcpmNVJIs7tddar/275a4/zT5nG9/gEndHPRZGTAQNpiSkk8s/dq+yZVX2jKfvVn1d5X8Z5SJHWnDoQ==
dependencies:
browserslist "^4.28.1"
caniuse-api "^3.0.0"
@ -4466,9 +4466,9 @@ postcss-value-parser@^4.1.0, postcss-value-parser@^4.2.0:
integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==
postcss@^8.2.14, postcss@^8.4.40:
version "8.5.14"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.14.tgz#a66c2d7808fadf69ebb5b84a03f8bafd76c4919c"
integrity sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==
version "8.5.13"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.13.tgz#6cfaf647f2e7ef69850208eccd849e0d3f65d420"
integrity sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==
dependencies:
nanoid "^3.3.11"
picocolors "^1.1.1"
@ -5017,7 +5017,7 @@ tslib@^2.8.0:
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f"
integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
type-fest@^5.6.0:
type-fest@^5.5.0:
version "5.6.0"
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-5.6.0.tgz#502f7a003b7309e96a7e17052cc2ab2c7e5c7a31"
integrity sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==
@ -5345,10 +5345,10 @@ zwitch@^2.0.0, zwitch@^2.0.4:
resolved "https://registry.yarnpkg.com/zwitch/-/zwitch-2.0.4.tgz#c827d4b0acb76fc3e685a4c6ec2902d51070e9d7"
integrity sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==
zxing-wasm@3.0.3:
version "3.0.3"
resolved "https://registry.yarnpkg.com/zxing-wasm/-/zxing-wasm-3.0.3.tgz#f87a45f7f90420e0f8c1a587147384d1cfeb4759"
integrity sha512-DdOn/G5F+qvZELWeO5ZFFwcN611TfMybxPV0LUUoutUmiH2t47MZSB7gLV9O9YLhvudBdnzQNAoFOu4Xz8eOrQ==
zxing-wasm@3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/zxing-wasm/-/zxing-wasm-3.0.2.tgz#3c39821f4a5d20b02bc5bacaefe7e6725d9520dd"
integrity sha512-2YMAriaYHX9wrBY2k7H0epSo+dyCaCZg/vOtt+nEDXM9ul480gkXz/9SkwpOeHcD2H5qqDG8lWDSBwpTcZpa6w==
dependencies:
"@types/emscripten" "^1.41.5"
type-fest "^5.6.0"
type-fest "^5.5.0"