From a3984adbedaef96ea6c4f112fb02bdd4000db9d3 Mon Sep 17 00:00:00 2001 From: Simon Kuehn Date: Mon, 18 May 2026 07:48:26 +0000 Subject: [PATCH] feat: full DE/EN i18n with browser language detection and confirmation dialogs - Add LocaleSubscriber: detects browser language, honours session override (priority 20) - Add LocaleSwitchController: stores locale in session, linked from user menu - Add admin.en.yaml / admin.de.yaml translation files (95 keys each) - Wire translation fallback to EN in config/packages/translation.yaml - Replace all hard-coded strings in CRUD controllers with TranslatableMessage - Inject TranslatorInterface into DashboardController, ArticleCrudController, AIPipelineJobCrudController and PipelineStreamController; add locale switcher links (English / Deutsch) to the user menu - Add confirmation dialog to "Re-run AI" and "Retry" pipeline actions Co-Authored-By: Claude Sonnet 4.6 --- config/packages/translation.yaml | 1 + .../EventListener/LocaleSubscriber.php | 42 +++++++ .../Admin/AIPipelineJobCrudController.php | 26 ++-- .../Admin/ArticleCrudController.php | 56 +++++---- .../ArticleTypeAttributeCrudController.php | 5 +- .../Admin/ArticleTypeCrudController.php | 3 +- .../AttributeDefinitionCrudController.php | 17 +-- .../Admin/CustomerCrudController.php | 5 +- .../Controller/Admin/DashboardController.php | 44 ++++--- .../Admin/InvoiceCrudController.php | 5 +- .../Admin/LocaleSwitchController.php | 31 +++++ .../Admin/LogEntryCrudController.php | 5 +- .../Controller/Admin/OrderCrudController.php | 5 +- .../Admin/PipelineArchiveCrudController.php | 5 +- .../Admin/PipelineStreamController.php | 38 +++--- .../Admin/PromptTemplateCrudController.php | 15 +-- .../Controller/Admin/UserCrudController.php | 3 +- translations/admin.de.yaml | 119 ++++++++++++++++++ translations/admin.en.yaml | 95 ++++++++++++++ 19 files changed, 424 insertions(+), 96 deletions(-) create mode 100644 src/Infrastructure/EventListener/LocaleSubscriber.php create mode 100644 src/Infrastructure/Http/Controller/Admin/LocaleSwitchController.php create mode 100644 translations/admin.de.yaml create mode 100644 translations/admin.en.yaml diff --git a/config/packages/translation.yaml b/config/packages/translation.yaml index 490bfc2..2d84fbe 100644 --- a/config/packages/translation.yaml +++ b/config/packages/translation.yaml @@ -2,4 +2,5 @@ framework: default_locale: en translator: default_path: '%kernel.project_dir%/translations' + fallbacks: [en] providers: diff --git a/src/Infrastructure/EventListener/LocaleSubscriber.php b/src/Infrastructure/EventListener/LocaleSubscriber.php new file mode 100644 index 0000000..07e26b8 --- /dev/null +++ b/src/Infrastructure/EventListener/LocaleSubscriber.php @@ -0,0 +1,42 @@ +setLocale() is effective. + return [KernelEvents::REQUEST => ['onRequest', 20]]; + } + + public function onRequest(RequestEvent $event): void + { + if (!$event->isMainRequest()) { + return; + } + + $request = $event->getRequest(); + + // 1. Explicit user override stored in session + $sessionLocale = $request->getSession()->get('_locale'); + if (\is_string($sessionLocale) && \in_array($sessionLocale, self::SUPPORTED, true)) { + $request->setLocale($sessionLocale); + + return; + } + + // 2. Browser's Accept-Language header + $preferred = $request->getPreferredLanguage(self::SUPPORTED); + $request->setLocale($preferred ?: 'en'); + } +} diff --git a/src/Infrastructure/Http/Controller/Admin/AIPipelineJobCrudController.php b/src/Infrastructure/Http/Controller/Admin/AIPipelineJobCrudController.php index 142a91b..d37f719 100644 --- a/src/Infrastructure/Http/Controller/Admin/AIPipelineJobCrudController.php +++ b/src/Infrastructure/Http/Controller/Admin/AIPipelineJobCrudController.php @@ -30,6 +30,8 @@ use EasyCorp\Bundle\EasyAdminBundle\Orm\EntityRepository; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Messenger\MessageBusInterface; +use Symfony\Component\Translation\TranslatableMessage; +use Symfony\Contracts\Translation\TranslatorInterface; /** @extends AbstractCrudController */ final class AIPipelineJobCrudController extends AbstractCrudController @@ -37,6 +39,7 @@ final class AIPipelineJobCrudController extends AbstractCrudController public function __construct( private readonly MessageBusInterface $bus, private readonly EntityManagerInterface $em, + private readonly TranslatorInterface $translator, ) { } @@ -48,17 +51,18 @@ final class AIPipelineJobCrudController extends AbstractCrudController public function configureCrud(Crud $crud): Crud { return $crud - ->setEntityLabelInSingular('Pipeline Job') - ->setEntityLabelInPlural('AI Pipeline — Active') + ->setEntityLabelInSingular(new TranslatableMessage('crud.pipeline_job.singular', [], 'admin')) + ->setEntityLabelInPlural(new TranslatableMessage('crud.pipeline_job.plural_active', [], 'admin')) ->setDefaultSort(['createdAt' => 'DESC']) ->showEntityActionsInlined(); } public function configureActions(Actions $actions): Actions { - $retryAction = Action::new('retry', 'Retry', 'fa fa-rotate-right') + $retryAction = Action::new('retry', new TranslatableMessage('action.retry', [], 'admin'), 'fa fa-rotate-right') ->linkToCrudAction('retryJob') ->setCssClass('btn btn-sm btn-warning') + ->setConfirmation(new TranslatableMessage('action.retry_confirm', [], 'admin')) ->displayIf(static fn (AIPipelineJob $job) => \in_array( $job->getStatus(), [AIPipelineJobStatus::NeedsReview, AIPipelineJobStatus::Failed, AIPipelineJobStatus::Processing], @@ -75,22 +79,22 @@ final class AIPipelineJobCrudController extends AbstractCrudController public function configureFields(string $pageName): iterable { yield IdField::new('id')->hideOnIndex(); - yield TextField::new('inventoryNumber', 'Inventory #') + yield TextField::new('inventoryNumber', new TranslatableMessage('field.inventory_number', [], 'admin')) ->hideOnForm() ->formatValue(static fn ($v, AIPipelineJob $job): string => (string) ($job->getInputData()['inventoryNumber'] ?? '—')); - yield TextField::new('statusLabel', 'Status') + yield TextField::new('statusLabel', new TranslatableMessage('field.status', [], 'admin')) ->hideOnForm() ->setCssClass('fw-bold'); - yield TextField::new('currentStep', 'Step') + yield TextField::new('currentStep', new TranslatableMessage('field.step', [], 'admin')) ->hideOnForm() ->formatValue(static fn ($v, AIPipelineJob $job): string => $job->getCurrentStep() ?? '—'); - yield IntegerField::new('attemptCount', 'Attempts')->hideOnForm(); - yield DateTimeField::new('createdAt', 'Started')->hideOnForm(); - yield TextField::new('errorMessage', 'Error') + yield IntegerField::new('attemptCount', new TranslatableMessage('field.attempts', [], 'admin'))->hideOnForm(); + yield DateTimeField::new('createdAt', new TranslatableMessage('field.started', [], 'admin'))->hideOnForm(); + yield TextField::new('errorMessage', new TranslatableMessage('field.error', [], 'admin')) ->hideOnIndex() ->hideOnForm() ->formatValue(static fn ($v, AIPipelineJob $job): string => $job->getErrorMessage() ?? ''); - yield TextareaField::new('aiResults', 'AI Results') + yield TextareaField::new('aiResults', new TranslatableMessage('field.ai_results', [], 'admin')) ->onlyOnDetail() ->formatValue(static fn ($v, AIPipelineJob $job): string => self::formatStepResults($job)); } @@ -158,7 +162,7 @@ final class AIPipelineJobCrudController extends AbstractCrudController $step = 'vision'; } - $this->addFlash('success', sprintf('Job %s re-queued from %s.', substr($jobId, 0, 8), $step)); + $this->addFlash('success', $this->translator->trans('flash.job_requeued', ['%id%' => substr($jobId, 0, 8), '%step%' => $step], 'admin')); return $this->redirect($context->getRequest()->headers->get('referer', $this->generateUrl('easyadmin'))); } diff --git a/src/Infrastructure/Http/Controller/Admin/ArticleCrudController.php b/src/Infrastructure/Http/Controller/Admin/ArticleCrudController.php index cc9f9f9..6f4e7cb 100644 --- a/src/Infrastructure/Http/Controller/Admin/ArticleCrudController.php +++ b/src/Infrastructure/Http/Controller/Admin/ArticleCrudController.php @@ -32,7 +32,9 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Routing\Attribute\Route; +use Symfony\Component\Translation\TranslatableMessage; use Symfony\Component\Uid\Uuid; +use Symfony\Contracts\Translation\TranslatorInterface; /** @extends AbstractCrudController
*/ final class ArticleCrudController extends AbstractCrudController @@ -42,6 +44,7 @@ final class ArticleCrudController extends AbstractCrudController private readonly EntityManagerInterface $em, private readonly AIPipelineJobRepositoryInterface $jobRepository, private readonly MessageBusInterface $bus, + private readonly TranslatorInterface $translator, ) { } @@ -53,26 +56,27 @@ final class ArticleCrudController extends AbstractCrudController public function configureCrud(Crud $crud): Crud { return $crud - ->setEntityLabelInSingular('Article') - ->setEntityLabelInPlural('Articles') + ->setEntityLabelInSingular(new TranslatableMessage('crud.article.singular', [], 'admin')) + ->setEntityLabelInPlural(new TranslatableMessage('crud.article.plural', [], 'admin')) ->showEntityActionsInlined(); } public function configureActions(Actions $actions): Actions { - $activate = Action::new('activate', 'Activate', 'fa fa-check') + $activate = Action::new('activate', new TranslatableMessage('action.activate', [], 'admin'), 'fa fa-check') ->linkToRoute('admin_article_activate', static fn (Article $a) => ['id' => $a->getId()->toRfc4122()]) ->setCssClass('btn btn-sm btn-success') ->displayIf(static fn (Article $a) => ArticleStatus::Draft === $a->getStatus()); - $markDraft = Action::new('markDraft', 'Mark as Draft', 'fa fa-pen-to-square') + $markDraft = Action::new('markDraft', new TranslatableMessage('action.mark_as_draft', [], 'admin'), 'fa fa-pen-to-square') ->linkToCrudAction('markAsDraft') ->setCssClass('btn btn-sm btn-secondary') ->displayIf(static fn (Article $a) => ArticleStatus::Ingesting === $a->getStatus()); - $rerunAi = Action::new('rerunAi', 'Re-run AI', 'fa fa-rotate') + $rerunAi = Action::new('rerunAi', new TranslatableMessage('action.rerun_ai', [], 'admin'), 'fa fa-rotate') ->linkToCrudAction('rerunAiPipeline') ->setCssClass('btn btn-sm btn-info') + ->setConfirmation(new TranslatableMessage('action.rerun_ai_confirm', [], 'admin')) ->displayIf(static fn (Article $a) => \in_array( $a->getStatus(), [ArticleStatus::Draft, ArticleStatus::Ingesting, ArticleStatus::NeedsReview], @@ -93,31 +97,30 @@ final class ArticleCrudController extends AbstractCrudController public function configureFields(string $pageName): iterable { yield IdField::new('id')->hideOnForm()->hideOnIndex(); - yield TextField::new('inventoryNumber', 'Inventory #')->hideOnForm(); + yield TextField::new('inventoryNumber', new TranslatableMessage('field.inventory_number', [], 'admin'))->hideOnForm(); yield AssociationField::new('articleType')->hideOnForm(); - yield TextField::new('ebayTitle', 'Description')->setRequired(false); - yield ChoiceField::new('status')->setChoices( + yield TextField::new('ebayTitle', new TranslatableMessage('field.description', [], 'admin'))->setRequired(false); + yield ChoiceField::new('status', new TranslatableMessage('field.status', [], 'admin'))->setChoices( array_combine( array_map(static fn ($s) => $s->value, ArticleStatus::cases()), ArticleStatus::cases(), ) )->hideOnForm(); - yield MoneyField::new('listingPrice', 'Price')->setCurrency('EUR')->setRequired(false); - yield ChoiceField::new('condition')->setChoices( + yield MoneyField::new('listingPrice', new TranslatableMessage('field.price', [], 'admin'))->setCurrency('EUR')->setRequired(false); + yield ChoiceField::new('condition', new TranslatableMessage('field.condition', [], 'admin'))->setChoices( array_combine( array_map(static fn ($c) => $c->value, ArticleCondition::cases()), ArticleCondition::cases(), ) )->hideOnIndex(); - yield TextField::new('manufacturer', 'Manufacturer')->setRequired(false)->hideOnIndex(); - yield TextField::new('modelNumber', 'Model #')->setRequired(false)->hideOnIndex(); + yield TextField::new('manufacturer', new TranslatableMessage('field.manufacturer', [], 'admin'))->setRequired(false)->hideOnIndex(); + yield TextField::new('modelNumber', new TranslatableMessage('field.model_number', [], 'admin'))->setRequired(false)->hideOnIndex(); yield TextField::new('sku')->hideOnForm()->hideOnIndex(); - yield TextField::new('serialNumber', 'Serial #')->setRequired(false)->hideOnIndex(); - yield TextField::new('conditionNotes', 'Condition Notes')->setRequired(false)->hideOnIndex(); - yield TextareaField::new('ebayDescription', 'eBay Description')->setRequired(false)->hideOnIndex(); + yield TextField::new('serialNumber', new TranslatableMessage('field.serial_number', [], 'admin'))->setRequired(false)->hideOnIndex(); + yield TextField::new('conditionNotes', new TranslatableMessage('field.condition_notes', [], 'admin'))->setRequired(false)->hideOnIndex(); + yield TextareaField::new('ebayDescription', new TranslatableMessage('field.ebay_description', [], 'admin'))->setRequired(false)->hideOnIndex(); - // Flat key-value display for detail view - yield TextField::new('attributeValues', 'Attributes') + yield TextField::new('attributeValues', new TranslatableMessage('field.attributes', [], 'admin')) ->onlyOnDetail() ->formatValue(static fn ($v, Article $a): string => implode( ' | ', @@ -130,7 +133,7 @@ final class ArticleCrudController extends AbstractCrudController ) )); - yield CollectionField::new('attributeValues', 'Attributes') + yield CollectionField::new('attributeValues', new TranslatableMessage('field.attributes', [], 'admin')) ->onlyOnForms() ->setEntryType(AttributeValueFormType::class) ->setEntryIsComplex(false) @@ -149,14 +152,14 @@ final class ArticleCrudController extends AbstractCrudController $referrer = $context->getRequest()->headers->get('referer', $this->generateUrl('easyadmin')); if (null === $originalJob) { - $this->addFlash('danger', 'No original pipeline job found — cannot determine which photo to use.'); + $this->addFlash('danger', $this->translator->trans('flash.pipeline_job_not_found', [], 'admin')); return $this->redirect($referrer); } $storedPhotoPath = (string) ($originalJob->getInputData()['storedPhotoPath'] ?? ''); if ('' === $storedPhotoPath || !file_exists($storedPhotoPath)) { - $this->addFlash('danger', 'Stored photo not found at: '.$storedPhotoPath); + $this->addFlash('danger', $this->translator->trans('flash.photo_not_found', ['%path%' => $storedPhotoPath], 'admin')); return $this->redirect($referrer); } @@ -179,9 +182,10 @@ final class ArticleCrudController extends AbstractCrudController originalFilename: (string) ($originalJob->getInputData()['originalFilename'] ?? ''), )); - $this->addFlash('success', sprintf( - 'AI pipeline re-queued for %s — attributes and eBay texts will be updated when complete.', - $article->getInventoryNumber(), + $this->addFlash('success', $this->translator->trans( + 'flash.pipeline_requeued', + ['%label%' => $article->getInventoryNumber()], + 'admin', )); return $this->redirect($referrer); @@ -197,7 +201,7 @@ final class ArticleCrudController extends AbstractCrudController if (ArticleStatus::Ingesting === $article->getStatus()) { $article->transitionTo(ArticleStatus::Draft); $this->em->flush(); - $this->addFlash('success', 'Article marked as draft.'); + $this->addFlash('success', $this->translator->trans('flash.article_marked_draft', [], 'admin')); } return $this->redirect($referrer); @@ -209,9 +213,9 @@ final class ArticleCrudController extends AbstractCrudController $result = $this->articleService->activate(Uuid::fromString($id)); if ([] !== $result['missing']) { - $this->addFlash('warning', 'Cannot activate: missing attributes: '.implode(', ', $result['missing'])); + $this->addFlash('warning', $this->translator->trans('flash.article_missing_attributes', ['%attrs%' => implode(', ', $result['missing'])], 'admin')); } else { - $this->addFlash('success', 'Article activated and queued for channel publishing.'); + $this->addFlash('success', $this->translator->trans('flash.article_activated', [], 'admin')); } return $this->redirectToRoute('easyadmin', [ diff --git a/src/Infrastructure/Http/Controller/Admin/ArticleTypeAttributeCrudController.php b/src/Infrastructure/Http/Controller/Admin/ArticleTypeAttributeCrudController.php index 319af87..1bc5570 100644 --- a/src/Infrastructure/Http/Controller/Admin/ArticleTypeAttributeCrudController.php +++ b/src/Infrastructure/Http/Controller/Admin/ArticleTypeAttributeCrudController.php @@ -12,6 +12,7 @@ use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController; use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField; use EasyCorp\Bundle\EasyAdminBundle\Field\BooleanField; use EasyCorp\Bundle\EasyAdminBundle\Field\IdField; +use Symfony\Component\Translation\TranslatableMessage; /** @extends AbstractCrudController */ final class ArticleTypeAttributeCrudController extends AbstractCrudController @@ -24,8 +25,8 @@ final class ArticleTypeAttributeCrudController extends AbstractCrudController public function configureCrud(Crud $crud): Crud { return $crud - ->setEntityLabelInSingular('Attribute Assignment') - ->setEntityLabelInPlural('Attribute Assignments') + ->setEntityLabelInSingular(new TranslatableMessage('crud.attribute_assignment.singular', [], 'admin')) + ->setEntityLabelInPlural(new TranslatableMessage('crud.attribute_assignment.plural', [], 'admin')) ->setDefaultSort(['articleType' => 'ASC']); } diff --git a/src/Infrastructure/Http/Controller/Admin/ArticleTypeCrudController.php b/src/Infrastructure/Http/Controller/Admin/ArticleTypeCrudController.php index 88d31fc..5af6b34 100644 --- a/src/Infrastructure/Http/Controller/Admin/ArticleTypeCrudController.php +++ b/src/Infrastructure/Http/Controller/Admin/ArticleTypeCrudController.php @@ -15,6 +15,7 @@ use EasyCorp\Bundle\EasyAdminBundle\Field\IdField; use EasyCorp\Bundle\EasyAdminBundle\Field\IntegerField; use EasyCorp\Bundle\EasyAdminBundle\Field\TextField; use Symfony\Bridge\Doctrine\Form\Type\EntityType; +use Symfony\Component\Translation\TranslatableMessage; /** @extends AbstractCrudController */ final class ArticleTypeCrudController extends AbstractCrudController @@ -26,7 +27,7 @@ final class ArticleTypeCrudController extends AbstractCrudController public function configureCrud(Crud $crud): Crud { - return $crud->setEntityLabelInSingular('Article Type')->setEntityLabelInPlural('Article Types'); + return $crud->setEntityLabelInSingular(new TranslatableMessage('crud.article_type.singular', [], 'admin'))->setEntityLabelInPlural(new TranslatableMessage('crud.article_type.plural', [], 'admin')); } public function configureAssets(Assets $assets): Assets diff --git a/src/Infrastructure/Http/Controller/Admin/AttributeDefinitionCrudController.php b/src/Infrastructure/Http/Controller/Admin/AttributeDefinitionCrudController.php index 1a49554..d7e385d 100644 --- a/src/Infrastructure/Http/Controller/Admin/AttributeDefinitionCrudController.php +++ b/src/Infrastructure/Http/Controller/Admin/AttributeDefinitionCrudController.php @@ -14,6 +14,7 @@ use EasyCorp\Bundle\EasyAdminBundle\Field\Field; use EasyCorp\Bundle\EasyAdminBundle\Field\IdField; use EasyCorp\Bundle\EasyAdminBundle\Field\TextField; use Symfony\Component\Form\Extension\Core\Type\EnumType; +use Symfony\Component\Translation\TranslatableMessage; /** @extends AbstractCrudController */ final class AttributeDefinitionCrudController extends AbstractCrudController @@ -26,8 +27,8 @@ final class AttributeDefinitionCrudController extends AbstractCrudController public function configureCrud(Crud $crud): Crud { return $crud - ->setEntityLabelInSingular('Attribute') - ->setEntityLabelInPlural('Attributes'); + ->setEntityLabelInSingular(new TranslatableMessage('crud.attribute.singular', [], 'admin')) + ->setEntityLabelInPlural(new TranslatableMessage('crud.attribute.plural', [], 'admin')); } public function createEntity(string $entityFqcn): AttributeDefinition @@ -38,25 +39,25 @@ final class AttributeDefinitionCrudController extends AbstractCrudController public function configureFields(string $pageName): iterable { yield IdField::new('id')->hideOnForm(); - yield TextField::new('name', 'Name'); + yield TextField::new('name', new TranslatableMessage('field.name', [], 'admin')); // choice.html.twig (used by ChoiceField) only renders formattedValue — safe for enums. // text.html.twig also renders field.value in the title attr, which chokes on enum objects. if (\in_array($pageName, [Crud::PAGE_NEW, Crud::PAGE_EDIT], true)) { - yield Field::new('type', 'Type') + yield Field::new('type', new TranslatableMessage('field.type', [], 'admin')) ->setFormType(EnumType::class) ->setFormTypeOptions(['class' => AttributeType::class]); } else { - yield ChoiceField::new('type', 'Type') + yield ChoiceField::new('type', new TranslatableMessage('field.type', [], 'admin')) ->setChoices([]) ->formatValue(static fn (mixed $v): string => $v instanceof AttributeType ? $v->value : (string) $v); } - yield TextField::new('unit', 'Unit')->setRequired(false)->hideOnIndex(); - yield Field::new('options', 'Options (one per line)') + yield TextField::new('unit', new TranslatableMessage('field.unit', [], 'admin'))->setRequired(false)->hideOnIndex(); + yield Field::new('options', new TranslatableMessage('field.options', [], 'admin')) ->setFormType(StringArrayType::class) ->setRequired(false) ->hideOnIndex() - ->setHelp('Only relevant for type select / multi_select.'); + ->setHelp(new TranslatableMessage('field.options_help', [], 'admin')); } } diff --git a/src/Infrastructure/Http/Controller/Admin/CustomerCrudController.php b/src/Infrastructure/Http/Controller/Admin/CustomerCrudController.php index c031644..07bf623 100644 --- a/src/Infrastructure/Http/Controller/Admin/CustomerCrudController.php +++ b/src/Infrastructure/Http/Controller/Admin/CustomerCrudController.php @@ -11,6 +11,7 @@ use EasyCorp\Bundle\EasyAdminBundle\Config\Crud; use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController; use EasyCorp\Bundle\EasyAdminBundle\Field\IdField; use EasyCorp\Bundle\EasyAdminBundle\Field\TextField; +use Symfony\Component\Translation\TranslatableMessage; /** @extends AbstractCrudController */ final class CustomerCrudController extends AbstractCrudController @@ -23,8 +24,8 @@ final class CustomerCrudController extends AbstractCrudController public function configureCrud(Crud $crud): Crud { return $crud - ->setEntityLabelInSingular('Kunde') - ->setEntityLabelInPlural('Kunden') + ->setEntityLabelInSingular(new TranslatableMessage('crud.customer.singular', [], 'admin')) + ->setEntityLabelInPlural(new TranslatableMessage('crud.customer.plural', [], 'admin')) ->setDefaultSort(['name' => 'ASC']) ->showEntityActionsInlined(); } diff --git a/src/Infrastructure/Http/Controller/Admin/DashboardController.php b/src/Infrastructure/Http/Controller/Admin/DashboardController.php index ea6a8e1..65fe967 100644 --- a/src/Infrastructure/Http/Controller/Admin/DashboardController.php +++ b/src/Infrastructure/Http/Controller/Admin/DashboardController.php @@ -13,11 +13,16 @@ use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractDashboardController; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Http\Attribute\IsGranted; +use Symfony\Contracts\Translation\TranslatorInterface; #[AdminDashboard(routePath: '/admin', routeName: 'easyadmin')] #[IsGranted('ROLE_USER')] final class DashboardController extends AbstractDashboardController { + public function __construct(private readonly TranslatorInterface $translator) + { + } + public function index(): Response { return $this->render('admin/dashboard.html.twig'); @@ -25,9 +30,14 @@ final class DashboardController extends AbstractDashboardController public function configureUserMenu(UserInterface $user): UserMenu { + $t = fn (string $key) => $this->translator->trans($key, [], 'admin'); + return parent::configureUserMenu($user) ->addMenuItems([ - MenuItem::linkToRoute('Change Password', 'fa fa-key', 'app_change_password'), + MenuItem::linkToRoute($t('menu.change_password'), 'fa fa-key', 'app_change_password'), + MenuItem::section(), + MenuItem::linkToRoute($t('menu.language_en'), 'fa fa-globe', 'admin_locale_switch', ['locale' => 'en']), + MenuItem::linkToRoute($t('menu.language_de'), 'fa fa-globe', 'admin_locale_switch', ['locale' => 'de']), ]); } @@ -45,20 +55,22 @@ final class DashboardController extends AbstractDashboardController public function configureMenuItems(): iterable { - yield MenuItem::linkToDashboard('Dashboard', 'fa fa-home'); - yield MenuItem::linkToRoute('Ingest Article', 'fa fa-camera', 'admin_manual_ingest'); - yield MenuItem::linkTo(ArticleCrudController::class, 'Articles', 'fa fa-box'); - yield MenuItem::linkTo(ArticleTypeCrudController::class, 'Article Types', 'fa fa-tags'); - yield MenuItem::linkTo(AttributeDefinitionCrudController::class, 'Attributes', 'fa fa-list-check'); - yield MenuItem::section('Pipeline'); - yield MenuItem::linkTo(AIPipelineJobCrudController::class, 'Active Jobs', 'fa fa-robot'); - yield MenuItem::linkTo(PipelineArchiveCrudController::class, 'Archive', 'fa fa-box-archive'); - yield MenuItem::linkTo(PromptTemplateCrudController::class, 'AI Prompts', 'fa fa-message'); - yield MenuItem::linkTo(UserCrudController::class, 'Users', 'fa fa-users'); - yield MenuItem::linkTo(LogEntryCrudController::class, 'Logs', 'fa fa-list'); - yield MenuItem::section('Sales'); - yield MenuItem::linkTo(OrderCrudController::class, 'Orders', 'fa fa-shopping-cart'); - yield MenuItem::linkTo(CustomerCrudController::class, 'Customers', 'fa fa-users'); - yield MenuItem::linkTo(InvoiceCrudController::class, 'Invoices', 'fa fa-file-invoice'); + $t = fn (string $key) => $this->translator->trans($key, [], 'admin'); + + yield MenuItem::linkToDashboard($t('menu.dashboard'), 'fa fa-home'); + yield MenuItem::linkToRoute($t('menu.ingest_article'), 'fa fa-camera', 'admin_manual_ingest'); + yield MenuItem::linkTo(ArticleCrudController::class, $t('menu.articles'), 'fa fa-box'); + yield MenuItem::linkTo(ArticleTypeCrudController::class, $t('menu.article_types'), 'fa fa-tags'); + yield MenuItem::linkTo(AttributeDefinitionCrudController::class, $t('menu.attributes'), 'fa fa-list-check'); + yield MenuItem::section($t('menu.section_pipeline')); + yield MenuItem::linkTo(AIPipelineJobCrudController::class, $t('menu.active_jobs'), 'fa fa-robot'); + yield MenuItem::linkTo(PipelineArchiveCrudController::class, $t('menu.archive'), 'fa fa-box-archive'); + yield MenuItem::linkTo(PromptTemplateCrudController::class, $t('menu.ai_prompts'), 'fa fa-message'); + yield MenuItem::linkTo(UserCrudController::class, $t('menu.users'), 'fa fa-users'); + yield MenuItem::linkTo(LogEntryCrudController::class, $t('menu.logs'), 'fa fa-list'); + yield MenuItem::section($t('menu.section_sales')); + yield MenuItem::linkTo(OrderCrudController::class, $t('menu.orders'), 'fa fa-shopping-cart'); + yield MenuItem::linkTo(CustomerCrudController::class, $t('menu.customers'), 'fa fa-users'); + yield MenuItem::linkTo(InvoiceCrudController::class, $t('menu.invoices'), 'fa fa-file-invoice'); } } diff --git a/src/Infrastructure/Http/Controller/Admin/InvoiceCrudController.php b/src/Infrastructure/Http/Controller/Admin/InvoiceCrudController.php index e5de021..e8db437 100644 --- a/src/Infrastructure/Http/Controller/Admin/InvoiceCrudController.php +++ b/src/Infrastructure/Http/Controller/Admin/InvoiceCrudController.php @@ -13,6 +13,7 @@ use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField; use EasyCorp\Bundle\EasyAdminBundle\Field\DateTimeField; use EasyCorp\Bundle\EasyAdminBundle\Field\IdField; use EasyCorp\Bundle\EasyAdminBundle\Field\TextField; +use Symfony\Component\Translation\TranslatableMessage; /** @extends AbstractCrudController */ final class InvoiceCrudController extends AbstractCrudController @@ -25,8 +26,8 @@ final class InvoiceCrudController extends AbstractCrudController public function configureCrud(Crud $crud): Crud { return $crud - ->setEntityLabelInSingular('Rechnung') - ->setEntityLabelInPlural('Rechnungen') + ->setEntityLabelInSingular(new TranslatableMessage('crud.invoice.singular', [], 'admin')) + ->setEntityLabelInPlural(new TranslatableMessage('crud.invoice.plural', [], 'admin')) ->setDefaultSort(['createdAt' => 'DESC']) ->showEntityActionsInlined(); } diff --git a/src/Infrastructure/Http/Controller/Admin/LocaleSwitchController.php b/src/Infrastructure/Http/Controller/Admin/LocaleSwitchController.php new file mode 100644 index 0000000..1fd9466 --- /dev/null +++ b/src/Infrastructure/Http/Controller/Admin/LocaleSwitchController.php @@ -0,0 +1,31 @@ + 'en|de'])] + public function switch(string $locale, Request $request): Response + { + if (!\in_array($locale, self::SUPPORTED, true)) { + $locale = 'en'; + } + + $request->getSession()->set('_locale', $locale); + + $referer = $request->headers->get('referer'); + + return $this->redirect($referer ?: $this->generateUrl('easyadmin')); + } +} diff --git a/src/Infrastructure/Http/Controller/Admin/LogEntryCrudController.php b/src/Infrastructure/Http/Controller/Admin/LogEntryCrudController.php index b33af38..8f1ed17 100644 --- a/src/Infrastructure/Http/Controller/Admin/LogEntryCrudController.php +++ b/src/Infrastructure/Http/Controller/Admin/LogEntryCrudController.php @@ -12,6 +12,7 @@ use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController; use EasyCorp\Bundle\EasyAdminBundle\Field\DateTimeField; use EasyCorp\Bundle\EasyAdminBundle\Field\IntegerField; use EasyCorp\Bundle\EasyAdminBundle\Field\TextField; +use Symfony\Component\Translation\TranslatableMessage; /** @extends AbstractCrudController */ final class LogEntryCrudController extends AbstractCrudController @@ -24,8 +25,8 @@ final class LogEntryCrudController extends AbstractCrudController public function configureCrud(Crud $crud): Crud { return $crud - ->setEntityLabelInSingular('Log Entry') - ->setEntityLabelInPlural('Log Entries') + ->setEntityLabelInSingular(new TranslatableMessage('crud.log_entry.singular', [], 'admin')) + ->setEntityLabelInPlural(new TranslatableMessage('crud.log_entry.plural', [], 'admin')) ->setDefaultSort(['loggedAt' => 'DESC']); } diff --git a/src/Infrastructure/Http/Controller/Admin/OrderCrudController.php b/src/Infrastructure/Http/Controller/Admin/OrderCrudController.php index 524c100..9acbaec 100644 --- a/src/Infrastructure/Http/Controller/Admin/OrderCrudController.php +++ b/src/Infrastructure/Http/Controller/Admin/OrderCrudController.php @@ -18,6 +18,7 @@ use EasyCorp\Bundle\EasyAdminBundle\Field\IdField; use EasyCorp\Bundle\EasyAdminBundle\Field\MoneyField; use EasyCorp\Bundle\EasyAdminBundle\Field\TextField; use EasyCorp\Bundle\EasyAdminBundle\Filter\ChoiceFilter; +use Symfony\Component\Translation\TranslatableMessage; /** @extends AbstractCrudController */ final class OrderCrudController extends AbstractCrudController @@ -30,8 +31,8 @@ final class OrderCrudController extends AbstractCrudController public function configureCrud(Crud $crud): Crud { return $crud - ->setEntityLabelInSingular('Bestellung') - ->setEntityLabelInPlural('Bestellungen') + ->setEntityLabelInSingular(new TranslatableMessage('crud.order.singular', [], 'admin')) + ->setEntityLabelInPlural(new TranslatableMessage('crud.order.plural', [], 'admin')) ->setDefaultSort(['saleDate' => 'DESC']) ->showEntityActionsInlined(); } diff --git a/src/Infrastructure/Http/Controller/Admin/PipelineArchiveCrudController.php b/src/Infrastructure/Http/Controller/Admin/PipelineArchiveCrudController.php index f75f6c6..26bdfac 100644 --- a/src/Infrastructure/Http/Controller/Admin/PipelineArchiveCrudController.php +++ b/src/Infrastructure/Http/Controller/Admin/PipelineArchiveCrudController.php @@ -21,6 +21,7 @@ use EasyCorp\Bundle\EasyAdminBundle\Field\IntegerField; use EasyCorp\Bundle\EasyAdminBundle\Field\TextareaField; use EasyCorp\Bundle\EasyAdminBundle\Field\TextField; use EasyCorp\Bundle\EasyAdminBundle\Orm\EntityRepository; +use Symfony\Component\Translation\TranslatableMessage; /** @extends AbstractCrudController */ final class PipelineArchiveCrudController extends AbstractCrudController @@ -33,8 +34,8 @@ final class PipelineArchiveCrudController extends AbstractCrudController public function configureCrud(Crud $crud): Crud { return $crud - ->setEntityLabelInSingular('Pipeline Job') - ->setEntityLabelInPlural('AI Pipeline — Archive') + ->setEntityLabelInSingular(new TranslatableMessage('crud.pipeline_job.singular', [], 'admin')) + ->setEntityLabelInPlural(new TranslatableMessage('crud.pipeline_job.plural_archive', [], 'admin')) ->setDefaultSort(['completedAt' => 'DESC']) ->showEntityActionsInlined(); } diff --git a/src/Infrastructure/Http/Controller/Admin/PipelineStreamController.php b/src/Infrastructure/Http/Controller/Admin/PipelineStreamController.php index c87be9f..3d640d5 100644 --- a/src/Infrastructure/Http/Controller/Admin/PipelineStreamController.php +++ b/src/Infrastructure/Http/Controller/Admin/PipelineStreamController.php @@ -12,21 +12,23 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\StreamedResponse; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Attribute\IsGranted; +use Symfony\Contracts\Translation\TranslatorInterface; #[IsGranted('ROLE_USER')] final class PipelineStreamController extends AbstractController { - private const array STEP_LABELS = [ - 'vision' => 'Foto analysiert', - 'specs_research' => 'Specs recherchiert', - 'json_coding' => 'Attribute kodiert', - 'draft_article' => 'Artikel erstellt', - 'ebay_text' => 'eBay-Texte generiert', - 'validation' => 'Validierung', + private const array STEP_KEYS = [ + 'vision' => 'pipeline.step.vision', + 'specs_research' => 'pipeline.step.specs_research', + 'json_coding' => 'pipeline.step.json_coding', + 'draft_article' => 'pipeline.step.draft_article', + 'ebay_text' => 'pipeline.step.ebay_text', + 'validation' => 'pipeline.step.validation', ]; public function __construct( private readonly AIPipelineJobRepositoryInterface $jobRepository, + private readonly TranslatorInterface $translator, ) { } @@ -102,17 +104,25 @@ final class PipelineStreamController extends AbstractController $inv = $job->getInventoryNumber() ?? substr($job->getId()->toRfc4122(), 0, 8); $label = "Job #{$inv}"; + $t = fn (string $key, array $p = []) => $this->translator->trans($key, $p, 'admin'); + $stepLabel = $step !== null + ? $t(self::STEP_KEYS[$step] ?? $step) + : ''; + $message = match (true) { - $status === AIPipelineJobStatus::Queued->value => "{$label} zur Pipeline hinzugefügt", + $status === AIPipelineJobStatus::Queued->value + => $t('pipeline.event.queued', ['%inv%' => $inv]), $status === AIPipelineJobStatus::Processing->value && $step === null - => "{$label} gestartet", + => $t('pipeline.event.processing_start', ['%inv%' => $inv]), $status === AIPipelineJobStatus::Processing->value - => "{$label}: ".self::STEP_LABELS[$step] ?? $step, - $status === AIPipelineJobStatus::Completed->value => "{$label} abgeschlossen ✓", - $status === AIPipelineJobStatus::Failed->value => "{$label} fehlgeschlagen: ".($job->getErrorMessage() ?? ''), + => $t('pipeline.event.processing_step', ['%inv%' => $inv, '%step%' => $stepLabel]), + $status === AIPipelineJobStatus::Completed->value + => $t('pipeline.event.completed', ['%inv%' => $inv]), + $status === AIPipelineJobStatus::Failed->value + => $t('pipeline.event.failed', ['%inv%' => $inv, '%reason%' => $job->getErrorMessage() ?? '']), $status === AIPipelineJobStatus::NeedsReview->value - => "{$label} benötigt manuelle Prüfung", - default => "{$label}: {$status}", + => $t('pipeline.event.needs_review', ['%inv%' => $inv]), + default => "{$label}: {$status}", }; return [ diff --git a/src/Infrastructure/Http/Controller/Admin/PromptTemplateCrudController.php b/src/Infrastructure/Http/Controller/Admin/PromptTemplateCrudController.php index ad082de..966f92e 100644 --- a/src/Infrastructure/Http/Controller/Admin/PromptTemplateCrudController.php +++ b/src/Infrastructure/Http/Controller/Admin/PromptTemplateCrudController.php @@ -6,6 +6,7 @@ namespace App\Infrastructure\Http\Controller\Admin; use App\Domain\AI\PromptTemplate; use App\Infrastructure\AI\PromptTemplateService; +use Symfony\Component\Translation\TranslatableMessage; use EasyCorp\Bundle\EasyAdminBundle\Config\Crud; use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController; use EasyCorp\Bundle\EasyAdminBundle\Field\DateTimeField; @@ -24,8 +25,8 @@ final class PromptTemplateCrudController extends AbstractCrudController public function configureCrud(Crud $crud): Crud { return $crud - ->setEntityLabelInSingular('Prompt Template') - ->setEntityLabelInPlural('Prompt Templates') + ->setEntityLabelInSingular(new TranslatableMessage('crud.prompt_template.singular', [], 'admin')) + ->setEntityLabelInPlural(new TranslatableMessage('crud.prompt_template.plural', [], 'admin')) ->setDefaultSort(['key' => 'ASC']); } @@ -37,18 +38,18 @@ final class PromptTemplateCrudController extends AbstractCrudController public function configureFields(string $pageName): iterable { yield IdField::new('id')->hideOnForm(); - yield TextField::new('key', 'Key') - ->setHelp('Slug identifying the prompt (e.g. specs_research). Must be unique.') + yield TextField::new('key', new TranslatableMessage('field.prompt_key', [], 'admin')) + ->setHelp(new TranslatableMessage('field.prompt_key_help', [], 'admin')) ->setColumns(4); - yield TextareaField::new('body', 'Prompt Body') + yield TextareaField::new('body', new TranslatableMessage('field.prompt_body', [], 'admin')) ->setHelp($this->buildVariableHelp()) ->setNumOfRows(18) ->hideOnIndex(); - yield TextField::new('body', 'Body Preview') + yield TextField::new('body', new TranslatableMessage('field.prompt_body_preview', [], 'admin')) ->formatValue(static fn (string $v): string => mb_substr($v, 0, 120).(mb_strlen($v) > 120 ? '…' : '')) ->hideOnForm() ->setSortable(false); - yield DateTimeField::new('updatedAt', 'Last Updated') + yield DateTimeField::new('updatedAt', new TranslatableMessage('field.last_updated', [], 'admin')) ->hideOnForm() ->setFormat('yyyy-MM-dd HH:mm'); } diff --git a/src/Infrastructure/Http/Controller/Admin/UserCrudController.php b/src/Infrastructure/Http/Controller/Admin/UserCrudController.php index b3644e3..3483c87 100644 --- a/src/Infrastructure/Http/Controller/Admin/UserCrudController.php +++ b/src/Infrastructure/Http/Controller/Admin/UserCrudController.php @@ -12,6 +12,7 @@ use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController; use EasyCorp\Bundle\EasyAdminBundle\Field\BooleanField; use EasyCorp\Bundle\EasyAdminBundle\Field\IdField; use EasyCorp\Bundle\EasyAdminBundle\Field\TextField; +use Symfony\Component\Translation\TranslatableMessage; /** @extends AbstractCrudController */ final class UserCrudController extends AbstractCrudController @@ -23,7 +24,7 @@ final class UserCrudController extends AbstractCrudController public function configureCrud(Crud $crud): Crud { - return $crud->setEntityLabelInSingular('User')->setEntityLabelInPlural('Users'); + return $crud->setEntityLabelInSingular(new TranslatableMessage('crud.user.singular', [], 'admin'))->setEntityLabelInPlural(new TranslatableMessage('crud.user.plural', [], 'admin')); } public function configureActions(Actions $actions): Actions diff --git a/translations/admin.de.yaml b/translations/admin.de.yaml new file mode 100644 index 0000000..9d2b3c5 --- /dev/null +++ b/translations/admin.de.yaml @@ -0,0 +1,119 @@ +menu: + dashboard: Dashboard + ingest_article: Artikel einlesen + articles: Artikel + article_types: Artikeltypen + attributes: Attribute + section_pipeline: Pipeline + active_jobs: Aktive Jobs + archive: Archiv + ai_prompts: KI-Prompts + users: Benutzer + logs: Logs + section_sales: Verkauf + orders: Bestellungen + customers: Kunden + invoices: Rechnungen + change_password: Passwort ändern + language_en: English + language_de: Deutsch + +crud: + article: + singular: Artikel + plural: Artikel + article_type: + singular: Artikeltyp + plural: Artikeltypen + attribute: + singular: Attribut + plural: Attribute + attribute_assignment: + singular: Attributzuweisung + plural: Attributzuweisungen + customer: + singular: Kunde + plural: Kunden + order: + singular: Bestellung + plural: Bestellungen + invoice: + singular: Rechnung + plural: Rechnungen + log_entry: + singular: Log-Eintrag + plural: Log-Einträge + user: + singular: Benutzer + plural: Benutzer + pipeline_job: + singular: Pipeline-Job + plural_active: KI-Pipeline — Aktiv + plural_archive: KI-Pipeline — Archiv + prompt_template: + singular: Prompt-Vorlage + plural: Prompt-Vorlagen + +field: + id: ID + name: Name + type: Typ + unit: Einheit + options: "Optionen (eine pro Zeile)" + options_help: "Nur relevant für Typ select / multi_select." + required_attributes: Pflichtattribute + optional_attributes: Optionale Attribute + inventory_number: Inventarnr. + status: Status + step: Schritt + attempts: Versuche + started: Gestartet + error: Fehler + ai_results: KI-Ergebnisse + price: Preis + condition: Zustand + manufacturer: Hersteller + model_number: Modellnr. + serial_number: Seriennr. + condition_notes: Zustandshinweise + description: Beschreibung + ebay_description: eBay-Beschreibung + attributes: Attribute + prompt_key: Schlüssel + prompt_key_help: "Eindeutiger Bezeichner für den Prompt (z. B. specs_research)." + prompt_body: Prompt-Text + prompt_body_preview: Vorschau + last_updated: Zuletzt geändert + +action: + retry: Wiederholen + retry_confirm: "Diesen Pipeline-Job ab dem aktuellen Schritt neu einreihen?" + rerun_ai: KI neu starten + rerun_ai_confirm: "Die gesamte KI-Pipeline für diesen Artikel neu starten? Attribute und eBay-Texte werden überschrieben." + mark_as_draft: Als Entwurf markieren + activate: Aktivieren + +flash: + pipeline_job_not_found: "Kein ursprünglicher Pipeline-Job gefunden — Foto kann nicht ermittelt werden." + photo_not_found: "Gespeichertes Foto nicht gefunden unter: %path%" + pipeline_requeued: "KI-Pipeline für %label% neu gestartet — Attribute und eBay-Texte werden aktualisiert." + article_marked_draft: Artikel als Entwurf markiert. + article_missing_attributes: "Aktivierung nicht möglich: fehlende Attribute: %attrs%" + article_activated: Artikel aktiviert und zur Kanalveröffentlichung eingereicht. + job_requeued: "Job %id% ab Schritt %step% neu eingereiht." + +pipeline: + step: + vision: Foto analysiert + specs_research: Specs recherchiert + json_coding: Attribute kodiert + draft_article: Artikel erstellt + ebay_text: eBay-Texte generiert + validation: Validierung + event: + queued: "Job #%inv% zur Pipeline hinzugefügt" + processing_start: "Job #%inv% gestartet" + processing_step: "Job #%inv%: %step%" + completed: "Job #%inv% abgeschlossen ✓" + failed: "Job #%inv% fehlgeschlagen: %reason%" + needs_review: "Job #%inv% benötigt manuelle Prüfung" diff --git a/translations/admin.en.yaml b/translations/admin.en.yaml new file mode 100644 index 0000000..08d69f0 --- /dev/null +++ b/translations/admin.en.yaml @@ -0,0 +1,95 @@ +menu.dashboard: Dashboard +menu.ingest_article: 'Ingest Article' +menu.articles: Articles +menu.article_types: 'Article Types' +menu.attributes: Attributes +menu.section_pipeline: Pipeline +menu.active_jobs: 'Active Jobs' +menu.archive: Archive +menu.ai_prompts: 'AI Prompts' +menu.users: Users +menu.logs: Logs +menu.section_sales: Sales +menu.orders: Orders +menu.customers: Customers +menu.invoices: Invoices +menu.change_password: 'Change Password' +menu.language_en: English +menu.language_de: Deutsch +crud.article.singular: Article +crud.article.plural: Articles +crud.article_type.singular: 'Article Type' +crud.article_type.plural: 'Article Types' +crud.attribute.singular: Attribute +crud.attribute.plural: Attributes +crud.attribute_assignment.singular: 'Attribute Assignment' +crud.attribute_assignment.plural: 'Attribute Assignments' +crud.customer.singular: Customer +crud.customer.plural: Customers +crud.order.singular: Order +crud.order.plural: Orders +crud.invoice.singular: Invoice +crud.invoice.plural: Invoices +crud.log_entry.singular: 'Log Entry' +crud.log_entry.plural: 'Log Entries' +crud.user.singular: User +crud.user.plural: Users +crud.pipeline_job.singular: 'Pipeline Job' +crud.pipeline_job.plural_active: 'AI Pipeline — Active' +crud.pipeline_job.plural_archive: 'AI Pipeline — Archive' +crud.prompt_template.singular: 'Prompt Template' +crud.prompt_template.plural: 'Prompt Templates' +field.id: ID +field.name: Name +field.type: Type +field.unit: Unit +field.options: 'Options (one per line)' +field.options_help: 'Only relevant for type select / multi_select.' +field.required_attributes: 'Required Attributes' +field.optional_attributes: 'Optional Attributes' +field.inventory_number: 'Inventory #' +field.status: Status +field.step: Step +field.attempts: Attempts +field.started: Started +field.error: Error +field.ai_results: 'AI Results' +field.price: Price +field.condition: Condition +field.manufacturer: Manufacturer +field.model_number: 'Model #' +field.serial_number: 'Serial #' +field.condition_notes: 'Condition Notes' +field.description: Description +field.ebay_description: 'eBay Description' +field.attributes: Attributes +field.prompt_key: Key +field.prompt_key_help: 'Slug identifying the prompt (e.g. specs_research). Must be unique.' +field.prompt_body: 'Prompt Body' +field.prompt_body_preview: 'Body Preview' +field.last_updated: 'Last Updated' +action.retry: Retry +action.retry_confirm: 'Re-queue this pipeline job from the current step?' +action.rerun_ai: 'Re-run AI' +action.rerun_ai_confirm: 'Re-run the full AI pipeline for this article? This will overwrite existing attributes and eBay texts.' +action.mark_as_draft: 'Mark as Draft' +action.activate: Activate +flash.pipeline_job_not_found: 'No original pipeline job found — cannot determine which photo to use.' +flash.photo_not_found: 'Stored photo not found at: %path%' +flash.pipeline_requeued: 'AI pipeline re-queued for %label% — attributes and eBay texts will be updated when complete.' +flash.article_marked_draft: 'Article marked as draft.' +flash.article_missing_attributes: 'Cannot activate: missing attributes: %attrs%' +flash.article_activated: 'Article activated and queued for channel publishing.' +flash.job_requeued: 'Job %id% re-queued from %step%.' +pipeline.step.vision: 'Photo analyzed' +pipeline.step.specs_research: 'Specs researched' +pipeline.step.json_coding: 'Attributes coded' +pipeline.step.draft_article: 'Article created' +pipeline.step.ebay_text: 'eBay texts generated' +pipeline.step.validation: Validation +pipeline.event.queued: 'Job #%inv% added to pipeline' +pipeline.event.processing_start: 'Job #%inv% started' +pipeline.event.processing_step: 'Job #%inv%: %step%' +pipeline.event.completed: 'Job #%inv% completed ✓' +pipeline.event.failed: 'Job #%inv% failed: %reason%' +pipeline.event.needs_review: 'Job #%inv% requires manual review'