wip: implement invoice generator for teamers

This commit is contained in:
Björn Fromme committed 2026-03-03 09:18:24 +01:00
1 parent 6c608063ad
commit bbdd887dc2
25 files changed
+1584 -21

No files matched your search

+1
View File
@@ -19,6 +19,7 @@
"doctrine/doctrine-bundle": "^2.10",
"doctrine/doctrine-migrations-bundle": "^3.2",
"doctrine/orm": "^2.15",
"dompdf/dompdf": "^2.0",
"flagception/flagception-bundle": "^6.0",
"knplabs/knp-menu-bundle": "^3.2",
"knplabs/knp-paginator-bundle": "^6.2",
+3
View File
@@ -10,3 +10,6 @@ flagception:
datev_email:
default: false
env: FEATURE_DATEV_EMAIL
invoice_editor:
default: false
env: FEATURE_INVOICE_EDITOR
+4
View File
@@ -194,3 +194,7 @@ services:
$defaults:
from: '%default_email_from%'
to: '%default_email_to%'
App\Service\Invoice\InvoicePdfGeneratorInterface: '@App\Service\Invoice\InvoicePdfGenerator'
App\Service\Invoice\InvoiceUploadWriterInterface: '@App\Service\Invoice\InvoiceUploadWriter'
App\Service\Invoice\InvoiceSubmissionServiceInterface: '@App\Service\Invoice\InvoiceSubmissionService'
+38
View File
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260221101500 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add digital teamer invoice draft entities';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE disposition_invoice (id INT AUTO_INCREMENT NOT NULL, disposition_id INT NOT NULL, submitted_by_id INT DEFAULT NULL, generated_upload_id INT DEFAULT NULL, uuid VARCHAR(36) NOT NULL, status VARCHAR(32) NOT NULL, currency VARCHAR(3) NOT NULL, total_cents INT NOT NULL, submitted_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', rejected_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', rejection_comment LONGTEXT DEFAULT NULL, locked_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', created_by VARCHAR(255) DEFAULT NULL, updated_by VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', updated_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', UNIQUE INDEX UNIQ_58F5D73FD17F50A6 (uuid), UNIQUE INDEX UNIQ_58F5D73FE743E1FB (disposition_id), UNIQUE INDEX UNIQ_58F5D73FF08B5E1A (generated_upload_id), INDEX IDX_58F5D73F8DB32B4A (submitted_by_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('CREATE TABLE disposition_invoice_item (id INT AUTO_INCREMENT NOT NULL, invoice_id INT NOT NULL, fee_id INT DEFAULT NULL, position INT NOT NULL, source_type VARCHAR(16) NOT NULL, label_snapshot VARCHAR(255) NOT NULL, unit_price_cents_snapshot INT NOT NULL, quantity INT NOT NULL, line_total_cents INT NOT NULL, created_by VARCHAR(255) DEFAULT NULL, updated_by VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', updated_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', INDEX IDX_77182CD42989F1FD (invoice_id), INDEX IDX_77182CD4AB45AECA (fee_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('ALTER TABLE disposition_invoice ADD CONSTRAINT FK_58F5D73FE743E1FB FOREIGN KEY (disposition_id) REFERENCES disposition (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE disposition_invoice ADD CONSTRAINT FK_58F5D73F8DB32B4A FOREIGN KEY (submitted_by_id) REFERENCES user (id)');
$this->addSql('ALTER TABLE disposition_invoice ADD CONSTRAINT FK_58F5D73FF08B5E1A FOREIGN KEY (generated_upload_id) REFERENCES upload (id) ON DELETE SET NULL');
$this->addSql('ALTER TABLE disposition_invoice_item ADD CONSTRAINT FK_77182CD42989F1FD FOREIGN KEY (invoice_id) REFERENCES disposition_invoice (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE disposition_invoice_item ADD CONSTRAINT FK_77182CD4AB45AECA FOREIGN KEY (fee_id) REFERENCES fee (id)');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE disposition_invoice_item DROP FOREIGN KEY FK_77182CD42989F1FD');
$this->addSql('ALTER TABLE disposition_invoice_item DROP FOREIGN KEY FK_77182CD4AB45AECA');
$this->addSql('ALTER TABLE disposition_invoice DROP FOREIGN KEY FK_58F5D73FE743E1FB');
$this->addSql('ALTER TABLE disposition_invoice DROP FOREIGN KEY FK_58F5D73F8DB32B4A');
$this->addSql('ALTER TABLE disposition_invoice DROP FOREIGN KEY FK_58F5D73FF08B5E1A');
$this->addSql('DROP TABLE disposition_invoice_item');
$this->addSql('DROP TABLE disposition_invoice');
}
}
@@ -73,6 +73,7 @@ class DetailController extends AbstractController
'disposition' => $disposition,
'form' => $form->createView(),
'returnUrl' => $this->getReturnUrl($request, 'app_teamer_index'),
'invoice_editor_enabled' => $this->featureManager->isActive('invoice_editor'),
]);
}
@@ -0,0 +1,217 @@
<?php
declare(strict_types=1);
namespace App\Controller\Teamer\Disposition;
use App\Entity\Disposition;
use App\Entity\DispositionInvoice;
use App\Entity\Fee;
use App\Entity\User;
use App\Form\Invoice\TeamerInvoiceEditorType;
use App\Htmx\HxRedirectResponse;
use App\Model\Invoice\TeamerInvoiceEditorDto;
use App\Model\Invoice\TeamerInvoiceItemDto;
use App\Service\Invoice\InvoiceSubmissionServiceInterface;
use Flagception\Manager\FeatureManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Target;
use Symfony\Component\Form\FormError;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Workflow\WorkflowInterface;
class InvoiceEditorController extends AbstractController
{
public function __construct(
private readonly InvoiceSubmissionServiceInterface $submissionService,
private readonly FeatureManagerInterface $featureManager,
#[Target('disposition')]
private readonly WorkflowInterface $workflow,
) {
}
#[Route('/teamer/disposition/invoice-editor/{uuid}', name: 'app_teamer_disposition_invoice_editor', methods: ['GET'])]
#[IsGranted('ROLE_TEAMER')]
#[IsGranted('VIEW', subject: 'disposition')]
public function index(Disposition $disposition): Response
{
$this->assertFeatureEnabled();
$this->assertInvoiceEditable($disposition);
$dto = $this->submissionService->createEditorDto($disposition);
$form = $this->createEditorForm($disposition, $dto, ['Default']);
return $this->renderEditorForm($disposition, $form, $dto);
}
#[Route('/teamer/disposition/invoice/{uuid}/refresh', name: 'app_teamer_disposition_invoice_refresh', methods: ['POST'])]
#[IsGranted('ROLE_TEAMER')]
#[IsGranted('VIEW', subject: 'disposition')]
public function refresh(Disposition $disposition, Request $request): Response
{
$this->assertFeatureEnabled();
$this->assertInvoiceEditable($disposition);
$dto = $this->submissionService->createEditorDto($disposition);
$form = $this->createEditorForm($disposition, $dto, false);
$form->handleRequest($request);
$normalized = $this->submissionService->refreshDraft($disposition, $dto);
$normalizedDto = $this->createDtoFromInvoice($normalized);
$normalizedForm = $this->createEditorForm($disposition, $normalizedDto, false);
return $this->renderEditorForm($disposition, $normalizedForm, $normalizedDto);
}
#[Route('/teamer/disposition/invoice/{uuid}/save', name: 'app_teamer_disposition_invoice_save', methods: ['POST'])]
#[IsGranted('ROLE_TEAMER')]
#[IsGranted('VIEW', subject: 'disposition')]
public function save(Disposition $disposition, Request $request): Response
{
$this->assertFeatureEnabled();
if (false === $this->isGranted('INVOICE', $disposition)) {
throw $this->createAccessDeniedException();
}
$this->assertInvoiceEditable($disposition);
$dto = $this->submissionService->createEditorDto($disposition);
$form = $this->createEditorForm($disposition, $dto, ['Default']);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
/** @var User $user */
$user = $this->getUser();
$invoice = $this->submissionService->saveDraft($disposition, $dto, $user);
$updatedDto = $this->createDtoFromInvoice($invoice);
$updatedForm = $this->createEditorForm($disposition, $updatedDto, ['Default']);
$this->addFlash('success', 'Dein Entwurf wurde gespeichert');
return $this->renderEditorForm($disposition, $updatedForm, $updatedDto);
}
return $this->renderEditorForm($disposition, $form, $dto);
}
#[Route('/teamer/disposition/invoice/{uuid}/submit', name: 'app_teamer_disposition_invoice_submit', methods: ['POST'])]
#[IsGranted('ROLE_TEAMER')]
#[IsGranted('VIEW', subject: 'disposition')]
public function submit(Disposition $disposition, Request $request): Response
{
$this->assertFeatureEnabled();
if (false === $this->isGranted('INVOICE', $disposition)) {
throw $this->createAccessDeniedException();
}
$this->assertInvoiceEditable($disposition);
$dto = $this->submissionService->createEditorDto($disposition);
$form = $this->createEditorForm($disposition, $dto, ['Default', 'submit']);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
/** @var User $user */
$user = $this->getUser();
try {
$this->submissionService->submitFinal($disposition, $dto, $user);
} catch (\RuntimeException) {
$form->addError(new FormError('Die Honorarnote konnte nicht eingereicht werden. Bitte versuche es erneut.'));
return $this->renderEditorForm($disposition, $form, $dto);
}
$this->addFlash('success', 'Deine Honorarnote wurde eingereicht');
return new HxRedirectResponse($this->generateUrl('app_teamer_disposition_detail', [
'uuid' => $disposition->getUuid(),
]));
}
return $this->renderEditorForm($disposition, $form, $dto);
}
/**
* @param array<string>|false $validationGroups
*/
private function createEditorForm(Disposition $disposition, TeamerInvoiceEditorDto $dto, array|false $validationGroups): FormInterface
{
return $this->createForm(TeamerInvoiceEditorType::class, $dto, [
'validation_groups' => $validationGroups,
'fee_choices' => $this->getFeeChoices($disposition),
]);
}
private function renderEditorForm(
Disposition $disposition,
FormInterface $form,
TeamerInvoiceEditorDto $dto,
): Response {
return $this->render('teamer/disposition/_invoice_editor_form.html.twig', [
'disposition' => $disposition,
'form' => $form->createView(),
'dto' => $dto,
'allowSubmit' => true,
]);
}
/**
* @return array<string, int>
*/
private function getFeeChoices(Disposition $disposition): array
{
$choices = [];
foreach ($disposition->getAssignment()->getFees() as $fee) {
if (!$fee instanceof Fee) {
continue;
}
$choices[sprintf('%s (%s)', $fee->getName(), number_format($fee->getValue() / 100, 2, ',', '.').' EUR')] = $fee->getId();
}
return $choices;
}
private function createDtoFromInvoice(DispositionInvoice $invoice): TeamerInvoiceEditorDto
{
$dto = new TeamerInvoiceEditorDto();
foreach ($invoice->getItems() as $invoiceItem) {
$item = new TeamerInvoiceItemDto();
$item
->setFeeId($invoiceItem->getFee()?->getId())
->setLabel($invoiceItem->getLabelSnapshot())
->setUnitPriceCents($invoiceItem->getUnitPriceCentsSnapshot())
->setQuantity($invoiceItem->getQuantity())
;
$dto->addItem($item);
}
if (0 === count($dto->getItems())) {
$dto->addItem(new TeamerInvoiceItemDto());
}
return $dto;
}
private function assertFeatureEnabled(): void
{
if (false === $this->featureManager->isActive('invoice_editor')) {
throw $this->createNotFoundException();
}
}
private function assertInvoiceEditable(Disposition $disposition): void
{
if (false === $this->workflow->can($disposition, 'upload_invoice')) {
throw $this->createAccessDeniedException();
}
}
}
+19
View File
@@ -72,6 +72,9 @@ class Disposition implements BlameableEntityInterface, TimestampableEntityInterf
#[ORM\Column(type: Types::BOOLEAN)]
private bool $syncedWithBusProNet = false;
#[ORM\OneToOne(mappedBy: 'disposition', targetEntity: DispositionInvoice::class, cascade: ['persist', 'remove'])]
private ?DispositionInvoice $invoiceDraft = null;
public function __construct(Application $application)
{
$this->uuid = Uuid::v4();
@@ -292,4 +295,20 @@ class Disposition implements BlameableEntityInterface, TimestampableEntityInterf
return $this;
}
public function getInvoiceDraft(): ?DispositionInvoice
{
return $this->invoiceDraft;
}
public function setInvoiceDraft(?DispositionInvoice $invoiceDraft): static
{
$this->invoiceDraft = $invoiceDraft;
if (null !== $invoiceDraft && $invoiceDraft->getDisposition() !== $this) {
$invoiceDraft->setDisposition($this);
}
return $this;
}
}
+255
View File
@@ -0,0 +1,255 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Entity\Traits\BlameableEntity;
use App\Entity\Traits\TimestampableEntity;
use App\Repository\DispositionInvoiceRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity(repositoryClass: DispositionInvoiceRepository::class)]
class DispositionInvoice implements BlameableEntityInterface, TimestampableEntityInterface
{
use BlameableEntity;
use TimestampableEntity;
public const STATUS_DRAFT = 'draft';
public const STATUS_SUBMITTED = 'submitted';
public const STATUS_REJECTED = 'rejected';
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\OneToOne(inversedBy: 'invoiceDraft')]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private ?Disposition $disposition = null;
#[ORM\Column(length: 32)]
private string $status = self::STATUS_DRAFT;
#[ORM\Column(length: 3)]
private string $currency = 'EUR';
#[ORM\Column]
private int $totalCents = 0;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $submittedAt = null;
#[ORM\ManyToOne]
private ?User $submittedBy = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $rejectedAt = null;
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $rejectionComment = null;
#[ORM\OneToOne]
#[ORM\JoinColumn(onDelete: 'SET NULL')]
private ?Upload $generatedUpload = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $lockedAt = null;
/**
* @var Collection<int, DispositionInvoiceItem>
*/
#[ORM\OneToMany(mappedBy: 'invoice', targetEntity: DispositionInvoiceItem::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OrderBy(['position' => 'ASC'])]
private Collection $items;
public function __construct()
{
$this->uuid = (string) Uuid::v4();
$this->items = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getUuid(): string
{
return $this->uuid;
}
public function getDisposition(): ?Disposition
{
return $this->disposition;
}
public function setDisposition(?Disposition $disposition): static
{
$this->disposition = $disposition;
if (null !== $disposition && $disposition->getInvoiceDraft() !== $this) {
$disposition->setInvoiceDraft($this);
}
return $this;
}
public function getStatus(): string
{
return $this->status;
}
public function setStatus(string $status): static
{
$this->status = $status;
return $this;
}
public function getCurrency(): string
{
return $this->currency;
}
public function setCurrency(string $currency): static
{
$this->currency = $currency;
return $this;
}
public function getTotalCents(): int
{
return $this->totalCents;
}
public function setTotalCents(int $totalCents): static
{
$this->totalCents = $totalCents;
return $this;
}
public function getSubmittedAt(): ?\DateTimeImmutable
{
return $this->submittedAt;
}
public function setSubmittedAt(?\DateTimeImmutable $submittedAt): static
{
$this->submittedAt = $submittedAt;
return $this;
}
public function getSubmittedBy(): ?User
{
return $this->submittedBy;
}
public function setSubmittedBy(?User $submittedBy): static
{
$this->submittedBy = $submittedBy;
return $this;
}
public function getRejectedAt(): ?\DateTimeImmutable
{
return $this->rejectedAt;
}
public function setRejectedAt(?\DateTimeImmutable $rejectedAt): static
{
$this->rejectedAt = $rejectedAt;
return $this;
}
public function getRejectionComment(): ?string
{
return $this->rejectionComment;
}
public function setRejectionComment(?string $rejectionComment): static
{
$this->rejectionComment = $rejectionComment;
return $this;
}
public function getGeneratedUpload(): ?Upload
{
return $this->generatedUpload;
}
public function setGeneratedUpload(?Upload $generatedUpload): static
{
$this->generatedUpload = $generatedUpload;
return $this;
}
public function getLockedAt(): ?\DateTimeImmutable
{
return $this->lockedAt;
}
public function setLockedAt(?\DateTimeImmutable $lockedAt): static
{
$this->lockedAt = $lockedAt;
return $this;
}
public function isLocked(): bool
{
return null !== $this->lockedAt;
}
/**
* @return Collection<int, DispositionInvoiceItem>
*/
public function getItems(): Collection
{
return $this->items;
}
public function addItem(DispositionInvoiceItem $item): static
{
if (!$this->items->contains($item)) {
$this->items->add($item);
$item->setInvoice($this);
}
return $this;
}
public function removeItem(DispositionInvoiceItem $item): static
{
if ($this->items->removeElement($item)) {
if ($item->getInvoice() === $this) {
$item->setInvoice(null);
}
}
return $this;
}
public function clearItems(): static
{
foreach ($this->items as $item) {
$this->removeItem($item);
}
return $this;
}
}
+151
View File
@@ -0,0 +1,151 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Entity\Traits\BlameableEntity;
use App\Entity\Traits\TimestampableEntity;
use App\Repository\DispositionInvoiceItemRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: DispositionInvoiceItemRepository::class)]
class DispositionInvoiceItem implements BlameableEntityInterface, TimestampableEntityInterface
{
use BlameableEntity;
use TimestampableEntity;
public const SOURCE_ASSIGNMENT_FEE = 'assignment_fee';
public const SOURCE_CUSTOM = 'custom';
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(inversedBy: 'items')]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private ?DispositionInvoice $invoice = null;
#[ORM\Column]
private int $position = 0;
#[ORM\ManyToOne]
private ?Fee $fee = null;
#[ORM\Column(length: 16)]
private string $sourceType = self::SOURCE_CUSTOM;
#[ORM\Column(length: 255)]
private string $labelSnapshot;
#[ORM\Column]
private int $unitPriceCentsSnapshot = 0;
#[ORM\Column]
private int $quantity = 1;
#[ORM\Column]
private int $lineTotalCents = 0;
public function getId(): ?int
{
return $this->id;
}
public function getInvoice(): ?DispositionInvoice
{
return $this->invoice;
}
public function setInvoice(?DispositionInvoice $invoice): static
{
$this->invoice = $invoice;
return $this;
}
public function getPosition(): int
{
return $this->position;
}
public function setPosition(int $position): static
{
$this->position = $position;
return $this;
}
public function getFee(): ?Fee
{
return $this->fee;
}
public function setFee(?Fee $fee): static
{
$this->fee = $fee;
return $this;
}
public function getSourceType(): string
{
return $this->sourceType;
}
public function setSourceType(string $sourceType): static
{
$this->sourceType = $sourceType;
return $this;
}
public function getLabelSnapshot(): string
{
return $this->labelSnapshot;
}
public function setLabelSnapshot(string $labelSnapshot): static
{
$this->labelSnapshot = $labelSnapshot;
return $this;
}
public function getUnitPriceCentsSnapshot(): int
{
return $this->unitPriceCentsSnapshot;
}
public function setUnitPriceCentsSnapshot(int $unitPriceCentsSnapshot): static
{
$this->unitPriceCentsSnapshot = $unitPriceCentsSnapshot;
return $this;
}
public function getQuantity(): int
{
return $this->quantity;
}
public function setQuantity(int $quantity): static
{
$this->quantity = $quantity;
return $this;
}
public function getLineTotalCents(): int
{
return $this->lineTotalCents;
}
public function setLineTotalCents(int $lineTotalCents): static
{
$this->lineTotalCents = $lineTotalCents;
return $this;
}
}
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\EventListener;
use App\Entity\DispositionInvoice;
use App\Entity\Upload;
use App\Event\DocumentRejectedEvent;
use App\Repository\DispositionInvoiceRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
#[AsEventListener(event: DocumentRejectedEvent::NAME, method: 'onDocumentRejected')]
class InvoiceDraftRejectedListener
{
public function __construct(
private readonly DispositionInvoiceRepository $invoiceRepository,
private readonly EntityManagerInterface $entityManager,
) {
}
public function onDocumentRejected(DocumentRejectedEvent $event): void
{
$document = $event->getDocument();
if (Upload::TYPE_INVOICE !== $document->getType()) {
return;
}
$disposition = $document->getDisposition();
if (null === $disposition) {
return;
}
$invoice = $this->invoiceRepository->findOneByDisposition($disposition);
if (null === $invoice) {
return;
}
$invoice
->setStatus(DispositionInvoice::STATUS_REJECTED)
->setRejectedAt(new \DateTimeImmutable())
->setRejectionComment($event->getComment())
->setLockedAt(null)
;
$this->entityManager->flush();
}
}
@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\Form\Invoice;
use App\Model\Invoice\TeamerInvoiceEditorDto;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Count;
use Symfony\Component\Validator\Constraints\IsTrue;
class TeamerInvoiceEditorType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('items', CollectionType::class, [
'label' => false,
'entry_type' => TeamerInvoiceItemType::class,
'entry_options' => [
'fee_choices' => $options['fee_choices'],
],
'allow_add' => true,
'allow_delete' => true,
'by_reference' => false,
'error_bubbling' => false,
'constraints' => [
new Count([
'min' => 1,
'minMessage' => 'Bitte füge mindestens eine Position hinzu',
]),
],
])
->add('confirmFinal', CheckboxType::class, [
'label' => 'Ich bestätige die Richtigkeit der Angaben',
'required' => false,
'constraints' => [
new IsTrue([
'message' => 'Bitte bestätige die Angaben',
'groups' => ['submit'],
]),
],
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver
->setDefaults([
'data_class' => TeamerInvoiceEditorDto::class,
'fee_choices' => [],
'validation_groups' => ['Default'],
'anti_xss' => false,
])
->setAllowedTypes('fee_choices', 'array')
;
}
}
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\Form\Invoice;
use App\Model\Invoice\TeamerInvoiceItemDto;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\Extension\Core\Type\MoneyType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\GreaterThanOrEqual;
use Symfony\Component\Validator\Constraints\NotBlank;
class TeamerInvoiceItemType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('feeId', ChoiceType::class, [
'label' => 'Leistung',
'placeholder' => 'Eigene Position',
'required' => false,
'choices' => $options['fee_choices'],
])
->add('label', TextType::class, [
'label' => 'Bezeichnung',
'constraints' => [
new NotBlank([
'message' => 'Bitte gib eine Bezeichnung an',
]),
],
])
->add('unitPriceCents', MoneyType::class, [
'label' => 'Betrag',
'divisor' => 100,
'currency' => 'EUR',
'constraints' => [
new GreaterThanOrEqual([
'value' => 0,
'message' => 'Der Betrag muss mindestens 0 sein',
]),
],
])
->add('quantity', IntegerType::class, [
'label' => 'Anzahl',
'constraints' => [
new GreaterThanOrEqual([
'value' => 1,
'message' => 'Die Anzahl muss mindestens 1 sein',
]),
],
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver
->setDefaults([
'data_class' => TeamerInvoiceItemDto::class,
'fee_choices' => [],
])
->setAllowedTypes('fee_choices', 'array')
;
}
}
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Model\Invoice;
class TeamerInvoiceEditorDto
{
/**
* @var list<TeamerInvoiceItemDto>
*/
private array $items = [];
private bool $confirmFinal = false;
/**
* @return list<TeamerInvoiceItemDto>
*/
public function getItems(): array
{
return $this->items;
}
/**
* @param list<TeamerInvoiceItemDto> $items
*/
public function setItems(array $items): static
{
$this->items = $items;
return $this;
}
public function addItem(TeamerInvoiceItemDto $item): static
{
$this->items[] = $item;
return $this;
}
public function isConfirmFinal(): bool
{
return $this->confirmFinal;
}
public function setConfirmFinal(bool $confirmFinal): static
{
$this->confirmFinal = $confirmFinal;
return $this;
}
public function getTotalCents(): int
{
$total = 0;
foreach ($this->items as $item) {
$total += $item->getLineTotalCents();
}
return $total;
}
}
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Model\Invoice;
class TeamerInvoiceItemDto
{
private ?int $feeId = null;
private string $label = '';
private int $unitPriceCents = 0;
private int $quantity = 1;
public function getFeeId(): ?int
{
return $this->feeId;
}
public function setFeeId(?int $feeId): static
{
$this->feeId = $feeId;
return $this;
}
public function getLabel(): string
{
return $this->label;
}
public function setLabel(?string $label): static
{
$this->label = (string) $label;
return $this;
}
public function getUnitPriceCents(): int
{
return $this->unitPriceCents;
}
public function setUnitPriceCents(int|float|string|null $unitPriceCents): static
{
$this->unitPriceCents = (int) $unitPriceCents;
return $this;
}
public function getQuantity(): int
{
return $this->quantity;
}
public function setQuantity(int|float|string|null $quantity): static
{
$this->quantity = (int) $quantity;
return $this;
}
public function getLineTotalCents(): int
{
return $this->unitPriceCents * $this->quantity;
}
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\DispositionInvoiceItem;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<DispositionInvoiceItem>
*/
class DispositionInvoiceItemRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, DispositionInvoiceItem::class);
}
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Disposition;
use App\Entity\DispositionInvoice;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<DispositionInvoice>
*/
class DispositionInvoiceRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, DispositionInvoice::class);
}
public function findOneByDisposition(Disposition $disposition): ?DispositionInvoice
{
return $this->findOneBy(['disposition' => $disposition]);
}
}
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Service\Invoice;
use App\Entity\DispositionInvoice;
use Dompdf\Dompdf;
use Dompdf\Options;
use Twig\Environment;
class InvoicePdfGenerator implements InvoicePdfGeneratorInterface
{
public function __construct(private readonly Environment $twig)
{
}
public function generatePdfBinary(DispositionInvoice $invoice): string
{
$options = new Options();
$options->setIsRemoteEnabled(false);
$options->setIsHtml5ParserEnabled(true);
$dompdf = new Dompdf($options);
$html = $this->twig->render('pdf/invoice_generated.html.twig', [
'invoice' => $invoice,
'disposition' => $invoice->getDisposition(),
'assignment' => $invoice->getDisposition()?->getAssignment(),
'teamer' => $invoice->getDisposition()?->getTeamer(),
]);
$dompdf->loadHtml($html, 'UTF-8');
$dompdf->setPaper('A4', 'portrait');
$dompdf->render();
return $dompdf->output();
}
}
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Service\Invoice;
use App\Entity\DispositionInvoice;
interface InvoicePdfGeneratorInterface
{
public function generatePdfBinary(DispositionInvoice $invoice): string;
}
@@ -0,0 +1,203 @@
<?php
declare(strict_types=1);
namespace App\Service\Invoice;
use App\Entity\Disposition;
use App\Entity\DispositionInvoice;
use App\Entity\DispositionInvoiceItem;
use App\Entity\Fee;
use App\Entity\Upload;
use App\Entity\User;
use App\Event\DocumentUploadedEvent;
use App\Model\Invoice\TeamerInvoiceEditorDto;
use App\Model\Invoice\TeamerInvoiceItemDto;
use App\Repository\DispositionInvoiceRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Target;
use Symfony\Component\Workflow\WorkflowInterface;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
class InvoiceSubmissionService implements InvoiceSubmissionServiceInterface
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly DispositionInvoiceRepository $invoiceRepository,
private readonly InvoicePdfGeneratorInterface $pdfGenerator,
private readonly InvoiceUploadWriterInterface $uploadWriter,
private readonly EventDispatcherInterface $eventDispatcher,
#[Target('disposition')]
private readonly WorkflowInterface $workflow,
) {
}
public function createEditorDto(Disposition $disposition): TeamerInvoiceEditorDto
{
$dto = new TeamerInvoiceEditorDto();
$invoice = $this->invoiceRepository->findOneByDisposition($disposition);
if (null !== $invoice && 0 < $invoice->getItems()->count()) {
foreach ($invoice->getItems() as $itemEntity) {
$item = new TeamerInvoiceItemDto();
$item
->setFeeId($itemEntity->getFee()?->getId())
->setLabel($itemEntity->getLabelSnapshot())
->setUnitPriceCents($itemEntity->getUnitPriceCentsSnapshot())
->setQuantity($itemEntity->getQuantity())
;
$dto->addItem($item);
}
return $dto;
}
$firstFee = $disposition->getAssignment()?->getFees()->first();
if ($firstFee instanceof Fee) {
$item = new TeamerInvoiceItemDto();
$item
->setFeeId($firstFee->getId())
->setLabel($firstFee->getName())
->setUnitPriceCents($firstFee->getValue())
->setQuantity(1)
;
$dto->addItem($item);
return $dto;
}
$dto->addItem(new TeamerInvoiceItemDto());
return $dto;
}
public function refreshDraft(Disposition $disposition, TeamerInvoiceEditorDto $dto): DispositionInvoice
{
$invoice = $this->getOrCreateInvoice($disposition);
$this->applyItemsToInvoice($disposition, $invoice, $dto);
return $invoice;
}
public function saveDraft(Disposition $disposition, TeamerInvoiceEditorDto $dto, User $actor): DispositionInvoice
{
$invoice = $this->getOrCreateInvoice($disposition);
$this->applyItemsToInvoice($disposition, $invoice, $dto);
$invoice
->setStatus(DispositionInvoice::STATUS_DRAFT)
->setRejectedAt(null)
->setRejectionComment(null)
->setLockedAt(null)
->setSubmittedAt(null)
->setSubmittedBy(null)
;
$this->entityManager->persist($invoice);
$this->entityManager->flush();
return $invoice;
}
public function submitFinal(Disposition $disposition, TeamerInvoiceEditorDto $dto, User $actor): DispositionInvoice
{
$invoice = $this->getOrCreateInvoice($disposition);
$this->applyItemsToInvoice($disposition, $invoice, $dto);
$now = new \DateTimeImmutable();
$invoice
->setStatus(DispositionInvoice::STATUS_SUBMITTED)
->setSubmittedAt($now)
->setSubmittedBy($actor)
->setLockedAt($now)
->setRejectedAt(null)
->setRejectionComment(null)
;
$this->entityManager->persist($invoice);
$pdfBinary = $this->pdfGenerator->generatePdfBinary($invoice);
$upload = $this->uploadWriter->writeFinalInvoiceUpload($disposition, $invoice, $pdfBinary, $actor);
if (false === $this->workflow->can($disposition, 'upload_invoice')) {
throw new \RuntimeException('Invoice upload transition is currently blocked');
}
$this->workflow->apply($disposition, 'upload_invoice');
$this->entityManager->flush();
$this->eventDispatcher->dispatch(new DocumentUploadedEvent($upload), DocumentUploadedEvent::NAME);
return $invoice;
}
private function getOrCreateInvoice(Disposition $disposition): DispositionInvoice
{
$invoice = $this->invoiceRepository->findOneByDisposition($disposition);
if (null !== $invoice) {
return $invoice;
}
$invoice = new DispositionInvoice();
$invoice->setDisposition($disposition);
$disposition->setInvoiceDraft($invoice);
return $invoice;
}
private function applyItemsToInvoice(Disposition $disposition, DispositionInvoice $invoice, TeamerInvoiceEditorDto $dto): void
{
$assignmentFees = [];
foreach ($disposition->getAssignment()->getFees() as $fee) {
$assignmentFees[$fee->getId()] = $fee;
}
$invoice->clearItems();
$position = 1;
$total = 0;
foreach ($dto->getItems() as $itemDto) {
$quantity = max(1, $itemDto->getQuantity());
$unitPrice = max(0, $itemDto->getUnitPriceCents());
$label = trim($itemDto->getLabel());
$fee = null;
$sourceType = DispositionInvoiceItem::SOURCE_CUSTOM;
if (null !== $itemDto->getFeeId() && isset($assignmentFees[$itemDto->getFeeId()])) {
/** @var Fee $fee */
$fee = $assignmentFees[$itemDto->getFeeId()];
$label = $fee->getName();
$unitPrice = $fee->getValue();
$sourceType = DispositionInvoiceItem::SOURCE_ASSIGNMENT_FEE;
}
if ('' === $label) {
continue;
}
$lineTotal = $unitPrice * $quantity;
$item = new DispositionInvoiceItem();
$item
->setInvoice($invoice)
->setPosition($position)
->setFee($fee)
->setSourceType($sourceType)
->setLabelSnapshot($label)
->setUnitPriceCentsSnapshot($unitPrice)
->setQuantity($quantity)
->setLineTotalCents($lineTotal)
;
$invoice->addItem($item);
$total += $lineTotal;
++$position;
}
$invoice->setTotalCents($total);
}
}
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Service\Invoice;
use App\Entity\Disposition;
use App\Entity\DispositionInvoice;
use App\Entity\User;
use App\Model\Invoice\TeamerInvoiceEditorDto;
interface InvoiceSubmissionServiceInterface
{
public function createEditorDto(Disposition $disposition): TeamerInvoiceEditorDto;
public function refreshDraft(Disposition $disposition, TeamerInvoiceEditorDto $dto): DispositionInvoice;
public function saveDraft(Disposition $disposition, TeamerInvoiceEditorDto $dto, User $actor): DispositionInvoice;
public function submitFinal(Disposition $disposition, TeamerInvoiceEditorDto $dto, User $actor): DispositionInvoice;
}
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Service\Invoice;
use App\Entity\Disposition;
use App\Entity\DispositionInvoice;
use App\Entity\Upload;
use App\Entity\User;
use App\Service\Upload\UploadHandler;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Filesystem\Filesystem;
class InvoiceUploadWriter implements InvoiceUploadWriterInterface
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly UploadHandler $uploadHandler,
) {
}
public function writeFinalInvoiceUpload(
Disposition $disposition,
DispositionInvoice $invoice,
string $pdfBinary,
User $owner,
): Upload {
if (null !== $existingUpload = $disposition->getDocumentByType(Upload::TYPE_INVOICE)) {
$disposition->removeDocument($existingUpload);
$this->entityManager->remove($existingUpload);
}
$upload = new Upload();
$upload
->setType(Upload::TYPE_INVOICE)
->setStatus(Upload::STATUS_NEW)
->setMimeType('application/pdf')
->setOwner($owner)
->setOriginalFilename('honorarnote_'.$disposition->getUuid().'.pdf')
->setFilename($this->createFilename())
->setSize(strlen($pdfBinary))
;
$disposition->addDocument($upload);
$invoice->setGeneratedUpload($upload);
$path = $this->uploadHandler->getUploadFilepath($upload);
$directory = \dirname($path);
$filesystem = new Filesystem();
if (false === $filesystem->exists($directory)) {
$filesystem->mkdir($directory);
}
$bytesWritten = file_put_contents($path, $pdfBinary);
if (false === $bytesWritten) {
throw new \RuntimeException(sprintf('Failed to write generated invoice PDF to "%s".', $path));
}
return $upload;
}
private function createFilename(): string
{
return bin2hex(random_bytes(16)).'.pdf';
}
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Service\Invoice;
use App\Entity\Disposition;
use App\Entity\DispositionInvoice;
use App\Entity\Upload;
use App\Entity\User;
interface InvoiceUploadWriterInterface
{
public function writeFinalInvoiceUpload(
Disposition $disposition,
DispositionInvoice $invoice,
string $pdfBinary,
User $owner,
): Upload;
}
+50
View File
@@ -0,0 +1,50 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<style>
body { font-family: DejaVu Sans, sans-serif; font-size: 12px; color: #111827; }
h1 { font-size: 20px; margin: 0 0 10px; }
.muted { color: #6b7280; }
.section { margin-top: 16px; }
table { width: 100%; border-collapse: collapse; margin-top: 8px; }
th, td { border: 1px solid #d1d5db; padding: 6px; text-align: left; }
th { background: #f3f4f6; }
.right { text-align: right; }
.total-row td { font-weight: bold; }
</style>
</head>
<body>
<h1>Honorarnote</h1>
<div class="muted">Einsatz: {{ assignment.destination }}</div>
<div class="muted">Teamer: {{ teamer }}</div>
<div class="muted">Datum: {{ "now"|date("d.m.Y") }}</div>
<div class="section">
<table>
<thead>
<tr>
<th>Bezeichnung</th>
<th class="right">Betrag</th>
<th class="right">Anzahl</th>
<th class="right">Zeilensumme</th>
</tr>
</thead>
<tbody>
{% for item in invoice.items %}
<tr>
<td>{{ item.labelSnapshot }}</td>
<td class="right">{{ item.unitPriceCentsSnapshot|format_money }}</td>
<td class="right">{{ item.quantity }}</td>
<td class="right">{{ item.lineTotalCents|format_money }}</td>
</tr>
{% endfor %}
<tr class="total-row">
<td colspan="3" class="right">Gesamt</td>
<td class="right">{{ invoice.totalCents|format_money }}</td>
</tr>
</tbody>
</table>
</div>
</body>
</html>
@@ -0,0 +1,94 @@
{% macro collectionRow(form, disposition, refreshPath) %}
{% set inputAttr = {
'hx-post': refreshPath,
'hx-target': '#invoice-editor-form',
'hx-swap': 'outerHTML',
'hx-sync': 'closest form:replace',
'hx-trigger': 'focusout, change',
} %}
<div {{ stimulus_target('form-collection', 'field') }} class="grid lg:grid-cols-12 gap-3 items-end border border-gray-200 rounded p-3">
<div class="lg:col-span-4">
{{ form_row(form.feeId, { attr: inputAttr }) }}
</div>
<div class="lg:col-span-3">
{{ form_row(form.label, { attr: inputAttr }) }}
</div>
<div class="lg:col-span-2">
{{ form_row(form.unitPriceCents, { attr: inputAttr }) }}
</div>
<div class="lg:col-span-2">
{{ form_row(form.quantity, { attr: inputAttr }) }}
</div>
<div class="lg:col-span-1 flex justify-end pb-2">
<button type="button" {{ stimulus_action('form-collection', 'removeItem') }}>
{{ icon('delete', 'w-5 h-5 pointer-events-none text-red-500') }}
</button>
</div>
</div>
{% endmacro %}
{% set savePath = path('app_teamer_disposition_invoice_save', { uuid: disposition.uuid }) %}
{% set submitPath = path('app_teamer_disposition_invoice_submit', { uuid: disposition.uuid }) %}
{% set refreshPath = path('app_teamer_disposition_invoice_refresh', { uuid: disposition.uuid }) %}
<div id="invoice-editor-form">
{{ form_start(form, {
attr: {
'hx-post': savePath,
'hx-target': '#invoice-editor-form',
'hx-swap': 'outerHTML'
}
}) }}
<div class="pb-4">
<twig:MessageBox message="Bitte erfasse deine Honorarnote digital. Du kannst Zwischenschritte als Entwurf speichern." type="info"/>
</div>
{% if disposition.invoiceDraft and disposition.invoiceDraft.status == constant('App\\Entity\\DispositionInvoice::STATUS_REJECTED') %}
<div class="pb-4">
<twig:MessageBox message="Deine Honorarnote wurde abgelehnt. Bitte überarbeite sie." type="error"/>
{% if disposition.invoiceDraft.rejectionComment %}
<div class="pt-2 text-sm">
<strong>Begründung:</strong> {{ disposition.invoiceDraft.rejectionComment|nl2br }}
</div>
{% endif %}
</div>
{% endif %}
<div {{ stimulus_controller('form-collection', { 'prototype': _self.collectionRow(form.items.vars.prototype, disposition, refreshPath)|json_encode }) }}>
<div {{ stimulus_target('form-collection', 'fields') }} class="flex flex-col space-y-3 pb-4">
{% do form.items.setRendered %}
{%- for item in form.items -%}
{{- _self.collectionRow(item, disposition, refreshPath) -}}
{%- endfor -%}
</div>
<div class="flex justify-end pb-4">
<button type="button" class="btn btn--secondary" {{ stimulus_action('form-collection', 'addItem') }}>
Position hinzufügen
</button>
</div>
{{ form_errors(form.items) }}
</div>
<div class="pb-4 text-right font-bold">
Gesamt: {{ dto.totalCents|format_money }}
</div>
<div class="pb-4">
{{ form_row(form.confirmFinal) }}
</div>
<div class="flex items-center space-x-2">
<button type="submit" class="btn" formaction="{{ savePath }}">
Entwurf speichern
</button>
<button type="submit" class="btn" formaction="{{ submitPath }}">
Final einreichen
</button>
</div>
{{ form_rest(form) }}
{{ form_end(form) }}
</div>
+29 -21
View File
@@ -95,28 +95,36 @@
{# New invoice #}
{% if workflow_can(disposition, 'upload_invoice') %}
{% set formAttr = stimulus_controller('form-upload-guard')|stimulus_action('form-upload-guard', 'guard', 'submit') %}
{{ form_start(form, { 'attr': formAttr.toArray() }) }}
<div class="flex flex-col space-y-4">
<a href="{{ path('app_teamer_disposition_invoicepdf', { 'uuid': disposition.uuid }) }}" target="_blank" class="inline-block">
<img src="{{ asset('build/images/invoice_thumb.jpg') }}" alt="Honorarnote" class="block w-32 h-auto shadow-md">
</a>
<div>
Bitte lade sie ausgefüllt und unterschrieben hier wieder hoch:
{% if invoice_editor_enabled %}
<div
hx-get="{{ path('app_teamer_disposition_invoice_editor', { 'uuid': disposition.uuid }) }}"
hx-trigger="load"
hx-target="this"
hx-swap="innerHTML"
>
{% include '_partials/_spinner.html.twig' with { 'class': 'w-8 h-8' } %}
</div>
{% include '_partials/_upload_collection_form.html.twig' with {
'endpoint_upload': path('_uploader_upload_invoice'),
'accepted_files': 'image/jpg,image/jpeg,application/pdf',
} %}
{% if form.confirmInvoiceData is defined %}
{{ form_row(form.confirmInvoiceData) }}
{% endif %}
<button type="submit" class="btn" {{ stimulus_target('form-upload-guard', 'submit') }}>
Aktualisieren
</button>
</div>
{{ form_rest(form) }}
{{ form_end(form) }}
{% else %}
{% set formAttr = stimulus_controller('form-upload-guard')|stimulus_action('form-upload-guard', 'guard', 'submit') %}
{{ form_start(form, { 'attr': formAttr.toArray() }) }}
<div class="flex flex-col space-y-4">
<a href="{{ path('app_teamer_disposition_invoicepdf', { 'uuid': disposition.uuid }) }}" target="_blank" class="inline-block">
<img src="{{ asset('build/images/invoice_thumb.jpg') }}" alt="Honorarnote" class="block w-32 h-auto shadow-md">
</a>
<div>
Bitte lade sie ausgefüllt und unterschrieben hier wieder hoch:
</div>
{% include '_partials/_upload_collection_form.html.twig' with {
'endpoint_upload': path('_uploader_upload_invoice'),
'accepted_files': 'image/jpg,image/jpeg,application/pdf',
} %}
<button type="submit" class="btn" {{ stimulus_target('form-upload-guard', 'submit') }}>
Aktualisieren
</button>
</div>
{{ form_rest(form) }}
{{ form_end(form) }}
{% endif %}
{% else %}
{% for blocker in workflow_transition_blockers(disposition, 'upload_invoice') %}
<twig:MessageBox message="{{ blocker.message }}"/>