search and export from invoice archive (#2408)
This commit is contained in:
@@ -12,8 +12,12 @@ namespace App\Controller;
|
||||
use App\Configuration\SystemConfiguration;
|
||||
use App\Entity\Invoice;
|
||||
use App\Entity\InvoiceTemplate;
|
||||
use App\Export\Spreadsheet\AnnotatedObjectExporter;
|
||||
use App\Export\Spreadsheet\Writer\BinaryFileResponseWriter;
|
||||
use App\Export\Spreadsheet\Writer\XlsxWriter;
|
||||
use App\Form\InvoiceDocumentUploadForm;
|
||||
use App\Form\InvoiceTemplateForm;
|
||||
use App\Form\Toolbar\InvoiceArchiveForm;
|
||||
use App\Form\Toolbar\InvoiceToolbarForm;
|
||||
use App\Form\Toolbar\InvoiceToolbarSimpleForm;
|
||||
use App\Invoice\ServiceInvoice;
|
||||
@@ -21,6 +25,7 @@ use App\Repository\InvoiceDocumentRepository;
|
||||
use App\Repository\InvoiceRepository;
|
||||
use App\Repository\InvoiceTemplateRepository;
|
||||
use App\Repository\Query\BaseQuery;
|
||||
use App\Repository\Query\InvoiceArchiveQuery;
|
||||
use App\Repository\Query\InvoiceQuery;
|
||||
use Exception;
|
||||
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
|
||||
@@ -236,20 +241,48 @@ final class InvoiceController extends AbstractController
|
||||
$invoice = $this->invoiceRepository->find($id);
|
||||
}
|
||||
|
||||
$query = new InvoiceQuery();
|
||||
$query->setOrderBy('date');
|
||||
$query = new InvoiceArchiveQuery();
|
||||
$query->setPage($page);
|
||||
$query->setCurrentUser($this->getUser());
|
||||
|
||||
$form = $this->getArchiveToolbarForm($query);
|
||||
$form->setData($query);
|
||||
$form->submit($request->query->all(), false);
|
||||
|
||||
if (!$form->isValid()) {
|
||||
$query->resetByFormError($form->getErrors());
|
||||
}
|
||||
|
||||
$invoices = $this->invoiceRepository->getPagerfantaForQuery($query);
|
||||
|
||||
return $this->render('invoice/listing.html.twig', [
|
||||
'entries' => $invoices,
|
||||
'query' => $query,
|
||||
'toolbarForm' => $form->createView(),
|
||||
'download' => $invoice,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/export", name="invoice_export", methods={"GET"})
|
||||
*/
|
||||
public function exportAction(Request $request, AnnotatedObjectExporter $exporter)
|
||||
{
|
||||
$query = new InvoiceArchiveQuery();
|
||||
$query->setCurrentUser($this->getUser());
|
||||
|
||||
$form = $this->getArchiveToolbarForm($query);
|
||||
$form->setData($query);
|
||||
$form->submit($request->query->all(), false);
|
||||
|
||||
$entries = $this->invoiceRepository->getInvoicesForQuery($query);
|
||||
|
||||
$spreadsheet = $exporter->export(Invoice::class, $entries);
|
||||
$writer = new BinaryFileResponseWriter(new XlsxWriter(), 'kimai-invoices');
|
||||
|
||||
return $writer->getFileResponse($spreadsheet);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/template/{page}", requirements={"page": "[1-9]\d*"}, defaults={"page": 1}, name="admin_invoice_template", methods={"GET", "POST"})
|
||||
* @Security("is_granted('manage_invoice_template')")
|
||||
@@ -415,6 +448,18 @@ final class InvoiceController extends AbstractController
|
||||
]);
|
||||
}
|
||||
|
||||
private function getArchiveToolbarForm(InvoiceArchiveQuery $query): FormInterface
|
||||
{
|
||||
return $this->createForm(InvoiceArchiveForm::class, $query, [
|
||||
'action' => $this->generateUrl('admin_invoice_list', []),
|
||||
'method' => 'GET',
|
||||
'timezone' => $this->getDateTimeFactory()->getTimezone()->getName(),
|
||||
'attr' => [
|
||||
'id' => 'invoice-archive-form'
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
private function createEditForm(InvoiceTemplate $template): FormInterface
|
||||
{
|
||||
if ($template->getId() === null) {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Export\Annotation as Exporter;
|
||||
use App\Invoice\InvoiceModel;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
||||
@@ -21,10 +22,14 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
* @ORM\UniqueConstraint(columns={"invoice_filename"})
|
||||
* }
|
||||
* )
|
||||
* @ORM\Entity(repositoryClass="App\Repository\InvoiceRepository")
|
||||
* @UniqueEntity("invoiceNumber")
|
||||
* @UniqueEntity("invoiceFilename")
|
||||
*
|
||||
* @ORM\Entity(repositoryClass="App\Repository\InvoiceRepository")
|
||||
* @Exporter\Order({"id", "createdAt", "invoiceNumber", "status", "customer", "total", "tax", "currency", "vat", "dueDays", "dueDate", "user", "invoiceFilename"})
|
||||
* @Exporter\Expose("customer", label="label.customer", exp="object.getCustomer() === null ? null : object.getCustomer().getName()")
|
||||
* @Exporter\Expose("dueDate", label="invoice.due_days", type="datetime", exp="object.getDueDate() === null ? null : object.getDueDate()")
|
||||
* @Exporter\Expose("user", label="label.username", type="string", exp="object.getUser() === null ? null : object.getUser().getDisplayName()")
|
||||
*/
|
||||
class Invoice
|
||||
{
|
||||
@@ -35,6 +40,8 @@ class Invoice
|
||||
/**
|
||||
* @var int|null
|
||||
*
|
||||
* @Exporter\Expose(label="label.id", type="integer")
|
||||
*
|
||||
* @ORM\Column(name="id", type="integer")
|
||||
* @ORM\Id
|
||||
* @ORM\GeneratedValue(strategy="IDENTITY")
|
||||
@@ -44,6 +51,8 @@ class Invoice
|
||||
/**
|
||||
* @var string
|
||||
*
|
||||
* @Exporter\Expose(label="invoice.number", type="string")
|
||||
*
|
||||
* @ORM\Column(name="invoice_number", type="string", length=50, nullable=false)
|
||||
* @Assert\NotNull()
|
||||
*/
|
||||
@@ -70,6 +79,8 @@ class Invoice
|
||||
/**
|
||||
* @var \DateTime
|
||||
*
|
||||
* @Exporter\Expose(label="label.date", type="datetime")
|
||||
*
|
||||
* @ORM\Column(name="created_at", type="datetime", nullable=false)
|
||||
* @Assert\NotNull()
|
||||
*/
|
||||
@@ -85,6 +96,8 @@ class Invoice
|
||||
/**
|
||||
* @var float
|
||||
*
|
||||
* @Exporter\Expose(label="label.total_rate", type="float")
|
||||
*
|
||||
* @ORM\Column(name="total", type="float", nullable=false)
|
||||
* @Assert\NotNull()
|
||||
*/
|
||||
@@ -93,6 +106,8 @@ class Invoice
|
||||
/**
|
||||
* @var float
|
||||
*
|
||||
* @Exporter\Expose(label="invoice.tax", type="float")
|
||||
*
|
||||
* @ORM\Column(name="tax", type="float", nullable=false)
|
||||
* @Assert\NotNull()
|
||||
*/
|
||||
@@ -101,6 +116,8 @@ class Invoice
|
||||
/**
|
||||
* @var string
|
||||
*
|
||||
* @Exporter\Expose(label="label.currency", type="string")
|
||||
*
|
||||
* @ORM\Column(name="currency", type="string", length=3, nullable=false)
|
||||
* @Assert\NotNull()
|
||||
* @Assert\Length(max=3)
|
||||
@@ -110,6 +127,8 @@ class Invoice
|
||||
/**
|
||||
* @var int
|
||||
*
|
||||
* @Exporter\Expose(label="label.due_days", type="integer")
|
||||
*
|
||||
* @ORM\Column(name="due_days", type="integer", length=3, nullable=false)
|
||||
* @Assert\NotNull()
|
||||
* @Assert\Range(min = 0, max = 999)
|
||||
@@ -119,6 +138,8 @@ class Invoice
|
||||
/**
|
||||
* @var float
|
||||
*
|
||||
* @Exporter\Expose(label="label.tax_rate", type="float")
|
||||
*
|
||||
* @ORM\Column(name="vat", type="float", nullable=false)
|
||||
* @Assert\NotNull()
|
||||
* @Assert\Range(min = 0.0, max = 99.99)
|
||||
@@ -128,6 +149,8 @@ class Invoice
|
||||
/**
|
||||
* @var string
|
||||
*
|
||||
* @Exporter\Expose(label="label.status", type="string")
|
||||
*
|
||||
* @ORM\Column(name="status", type="string", length=20, nullable=false)
|
||||
* @Assert\NotNull()
|
||||
*/
|
||||
@@ -136,6 +159,8 @@ class Invoice
|
||||
/**
|
||||
* @var string
|
||||
*
|
||||
* @Exporter\Expose(label="file", type="string")
|
||||
*
|
||||
* @ORM\Column(name="invoice_filename", type="string", length=150, nullable=false)
|
||||
* @Assert\NotNull()
|
||||
* @Assert\Length(min=1, max=150, allowEmptyString=false)
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
namespace App\EventSubscriber\Actions;
|
||||
|
||||
use App\Constants;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
@@ -33,4 +34,9 @@ abstract class AbstractActionsSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
return $this->urlGenerator->generate($route, $parameters);
|
||||
}
|
||||
|
||||
protected function documentationLink(string $url): string
|
||||
{
|
||||
return Constants::HOMEPAGE . '/documentation/' . $url;
|
||||
}
|
||||
}
|
||||
|
||||
37
src/EventSubscriber/Actions/InvoiceArchiveSubscriber.php
Normal file
37
src/EventSubscriber/Actions/InvoiceArchiveSubscriber.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\EventSubscriber\Actions;
|
||||
|
||||
use App\Event\PageActionsEvent;
|
||||
|
||||
class InvoiceArchiveSubscriber extends AbstractActionsSubscriber
|
||||
{
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
'actions.invoice_details' => ['onActions', 1000],
|
||||
];
|
||||
}
|
||||
|
||||
public function onActions(PageActionsEvent $event)
|
||||
{
|
||||
$actions = $event->getActions();
|
||||
|
||||
if ($this->isGranted('view_invoice')) {
|
||||
$actions['back'] = ['url' => $this->path('invoice'), 'translation_domain' => 'actions'];
|
||||
}
|
||||
|
||||
$actions['visibility'] = '#modal_invoices';
|
||||
$actions['download'] = ['url' => $this->path('invoice_export'), 'class' => 'toolbar-action'];
|
||||
$actions['help'] = ['url' => $this->documentationLink('invoices.html'), 'target' => '_blank'];
|
||||
|
||||
$event->setActions($actions);
|
||||
}
|
||||
}
|
||||
@@ -88,8 +88,6 @@ class UserSubscriber extends AbstractActionsSubscriber
|
||||
$actions['trash'] = ['url' => $this->path('admin_user_delete', ['id' => $user->getId()]), 'class' => 'modal-ajax-form'];
|
||||
}
|
||||
|
||||
$payload['actions'] = array_merge($payload['actions'], $actions);
|
||||
|
||||
$event->setPayload($payload);
|
||||
$event->setActions($actions);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ class InvoiceTemplateForm extends AbstractType
|
||||
'label' => 'label.due_days',
|
||||
])
|
||||
->add('vat', NumberType::class, [
|
||||
'label' => 'label.vat',
|
||||
'label' => 'label.tax_rate',
|
||||
'scale' => 2,
|
||||
])
|
||||
->add('renderer', InvoiceRendererType::class)
|
||||
|
||||
44
src/Form/Toolbar/InvoiceArchiveForm.php
Normal file
44
src/Form/Toolbar/InvoiceArchiveForm.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Form\Toolbar;
|
||||
|
||||
use App\Form\Type\InvoiceStatusType;
|
||||
use App\Repository\Query\InvoiceArchiveQuery;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
/**
|
||||
* Defines the form used for filtering timesheet entries for invoices.
|
||||
*/
|
||||
class InvoiceArchiveForm extends AbstractToolbarForm
|
||||
{
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function buildForm(FormBuilderInterface $builder, array $options)
|
||||
{
|
||||
$this->addSearchTermInputField($builder);
|
||||
$this->addDateRange($builder, ['timezone' => $options['timezone']]);
|
||||
$this->addCustomerMultiChoice($builder, ['required' => false, 'start_date_param' => null, 'end_date_param' => null, 'ignore_date' => true, 'placeholder' => ''], true);
|
||||
$builder->add('status', InvoiceStatusType::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function configureOptions(OptionsResolver $resolver)
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => InvoiceArchiveQuery::class,
|
||||
'csrf_protection' => false,
|
||||
'timezone' => date_default_timezone_get(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
45
src/Form/Type/InvoiceStatusType.php
Normal file
45
src/Form/Type/InvoiceStatusType.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Form\Type;
|
||||
|
||||
use App\Entity\Invoice;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
/**
|
||||
* Custom form field type to select an invoice template.
|
||||
*/
|
||||
class InvoiceStatusType extends AbstractType
|
||||
{
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function configureOptions(OptionsResolver $resolver)
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'label' => 'label.status',
|
||||
'multiple' => true,
|
||||
'choices' => [
|
||||
'status.' . Invoice::STATUS_NEW => Invoice::STATUS_NEW,
|
||||
'status.' . Invoice::STATUS_PENDING => Invoice::STATUS_PENDING,
|
||||
'status.' . Invoice::STATUS_PAID => Invoice::STATUS_PAID,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getParent()
|
||||
{
|
||||
return ChoiceType::class;
|
||||
}
|
||||
}
|
||||
@@ -151,7 +151,7 @@ final class ConfigurableNumberGenerator implements NumberGeneratorInterface
|
||||
|
||||
// for customer
|
||||
case 'cc':
|
||||
$partialResult = $this->repository->getCounterForAllTime($invoiceDate, $this->model->getCustomer()) + $increaseBy;
|
||||
$partialResult = $this->repository->getCounterForAllTime($this->model->getCustomer()) + $increaseBy;
|
||||
break;
|
||||
|
||||
case 'ccy':
|
||||
@@ -168,7 +168,7 @@ final class ConfigurableNumberGenerator implements NumberGeneratorInterface
|
||||
|
||||
// across all invoices
|
||||
case 'c':
|
||||
$partialResult = $this->repository->getCounterForAllTime($invoiceDate) + $increaseBy;
|
||||
$partialResult = $this->repository->getCounterForAllTime() + $increaseBy;
|
||||
break;
|
||||
|
||||
case 'cy':
|
||||
|
||||
@@ -16,7 +16,7 @@ use App\Entity\User;
|
||||
use App\Repository\Loader\InvoiceLoader;
|
||||
use App\Repository\Paginator\LoaderPaginator;
|
||||
use App\Repository\Paginator\PaginatorInterface;
|
||||
use App\Repository\Query\InvoiceQuery;
|
||||
use App\Repository\Query\InvoiceArchiveQuery;
|
||||
use Doctrine\ORM\EntityRepository;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Pagerfanta\Pagerfanta;
|
||||
@@ -91,7 +91,7 @@ class InvoiceRepository extends EntityRepository
|
||||
return $this->getCounterFor($start, $end, $customer);
|
||||
}
|
||||
|
||||
public function getCounterForAllTime(\DateTime $date, ?Customer $customer = null): int
|
||||
public function getCounterForAllTime(?Customer $customer = null): int
|
||||
{
|
||||
if (null !== $customer) {
|
||||
return $this->count(['customer' => $customer]);
|
||||
@@ -137,7 +137,7 @@ class InvoiceRepository extends EntityRepository
|
||||
$qb->setParameter('teams', $ids);
|
||||
}
|
||||
|
||||
private function getQueryBuilderForQuery(InvoiceQuery $query): QueryBuilder
|
||||
private function getQueryBuilderForQuery(InvoiceArchiveQuery $query): QueryBuilder
|
||||
{
|
||||
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||
|
||||
@@ -146,21 +146,79 @@ class InvoiceRepository extends EntityRepository
|
||||
->from(Invoice::class, 'i')
|
||||
;
|
||||
|
||||
if ($query->getBegin() !== null) {
|
||||
$qb->andWhere($qb->expr()->gte('i.createdAt', ':begin'));
|
||||
$qb->setParameter('begin', $query->getBegin());
|
||||
}
|
||||
|
||||
if ($query->getEnd() !== null) {
|
||||
$qb->andWhere($qb->expr()->lte('i.createdAt', ':end'));
|
||||
$qb->setParameter('end', $query->getEnd());
|
||||
}
|
||||
|
||||
if ($query->hasCustomers()) {
|
||||
$qb->andWhere($qb->expr()->in('i.customer', ':customer'));
|
||||
$qb->setParameter('customer', $query->getCustomers());
|
||||
}
|
||||
|
||||
if ($query->hasStatus()) {
|
||||
$qb->andWhere($qb->expr()->in('i.status', ':status'));
|
||||
$qb->setParameter('status', $query->getStatus());
|
||||
}
|
||||
|
||||
$orderBy = $query->getOrderBy();
|
||||
switch ($orderBy) {
|
||||
case 'date':
|
||||
$orderBy = 'i.createdAt';
|
||||
break;
|
||||
case 'customer':
|
||||
$orderBy = 'i.customer';
|
||||
break;
|
||||
case 'total':
|
||||
$orderBy = 'i.total';
|
||||
break;
|
||||
}
|
||||
|
||||
$qb->addOrderBy($orderBy, $query->getOrder());
|
||||
|
||||
$this->addPermissionCriteria($qb, $query->getCurrentUser());
|
||||
|
||||
if ($query->hasSearchTerm()) {
|
||||
$qb->leftJoin('i.customer', 'customer');
|
||||
$searchAnd = $qb->expr()->andX();
|
||||
$searchTerm = $query->getSearchTerm();
|
||||
|
||||
foreach ($searchTerm->getSearchFields() as $metaName => $metaValue) {
|
||||
$qb->leftJoin('customer.meta', 'meta');
|
||||
$searchAnd->add(
|
||||
$qb->expr()->andX(
|
||||
$qb->expr()->eq('meta.name', ':metaName'),
|
||||
$qb->expr()->like('meta.value', ':metaValue')
|
||||
)
|
||||
);
|
||||
$qb->setParameter('metaName', $metaName);
|
||||
$qb->setParameter('metaValue', '%' . $metaValue . '%');
|
||||
}
|
||||
|
||||
if ($searchTerm->hasSearchTerm()) {
|
||||
$searchAnd->add(
|
||||
$qb->expr()->orX(
|
||||
$qb->expr()->like('customer.name', ':searchTerm'),
|
||||
$qb->expr()->like('customer.company', ':searchTerm')
|
||||
)
|
||||
);
|
||||
$qb->setParameter('searchTerm', '%' . $searchTerm->getSearchTerm() . '%');
|
||||
}
|
||||
|
||||
if ($searchAnd->count() > 0) {
|
||||
$qb->andWhere($searchAnd);
|
||||
}
|
||||
}
|
||||
|
||||
return $qb;
|
||||
}
|
||||
|
||||
public function countInvoicesForQuery(InvoiceQuery $query): int
|
||||
public function countInvoicesForQuery(InvoiceArchiveQuery $query): int
|
||||
{
|
||||
$qb = $this->getQueryBuilderForQuery($query);
|
||||
$qb
|
||||
@@ -173,7 +231,20 @@ class InvoiceRepository extends EntityRepository
|
||||
return (int) $qb->getQuery()->getSingleScalarResult();
|
||||
}
|
||||
|
||||
protected function getPaginatorForQuery(InvoiceQuery $query): PaginatorInterface
|
||||
/**
|
||||
* @param InvoiceArchiveQuery $query
|
||||
* @return Invoice[]
|
||||
*/
|
||||
public function getInvoicesForQuery(InvoiceArchiveQuery $query): iterable
|
||||
{
|
||||
// this is using the paginator internally, as it will load all joined entities into the working unit
|
||||
// do not "optimize" to use the query directly, as it would results in hundreds of additional lazy queries
|
||||
$paginator = $this->getPaginatorForQuery($query);
|
||||
|
||||
return $paginator->getAll();
|
||||
}
|
||||
|
||||
protected function getPaginatorForQuery(InvoiceArchiveQuery $query): PaginatorInterface
|
||||
{
|
||||
$counter = $this->countInvoicesForQuery($query);
|
||||
$qb = $this->getQueryBuilderForQuery($query);
|
||||
@@ -181,7 +252,7 @@ class InvoiceRepository extends EntityRepository
|
||||
return new LoaderPaginator(new InvoiceLoader($qb->getEntityManager()), $qb, $counter);
|
||||
}
|
||||
|
||||
public function getPagerfantaForQuery(InvoiceQuery $query): Pagerfanta
|
||||
public function getPagerfantaForQuery(InvoiceArchiveQuery $query): Pagerfanta
|
||||
{
|
||||
$paginator = new Pagerfanta($this->getPaginatorForQuery($query));
|
||||
$paginator->setMaxPerPage($query->getPageSize());
|
||||
|
||||
58
src/Repository/Query/DateRangeTrait.php
Normal file
58
src/Repository/Query/DateRangeTrait.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Repository\Query;
|
||||
|
||||
use App\Form\Model\DateRange;
|
||||
|
||||
trait DateRangeTrait
|
||||
{
|
||||
/**
|
||||
* @var DateRange
|
||||
*/
|
||||
protected $dateRange;
|
||||
|
||||
public function getBegin(): ?\DateTime
|
||||
{
|
||||
if (null === $this->dateRange) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->dateRange->getBegin();
|
||||
}
|
||||
|
||||
public function setBegin(\DateTime $begin): void
|
||||
{
|
||||
$this->dateRange->setBegin($begin);
|
||||
}
|
||||
|
||||
public function getEnd(): ?\DateTime
|
||||
{
|
||||
if (null === $this->dateRange) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->dateRange->getEnd();
|
||||
}
|
||||
|
||||
public function setEnd(\DateTime $end): void
|
||||
{
|
||||
$this->dateRange->setEnd($end);
|
||||
}
|
||||
|
||||
public function getDateRange(): ?DateRange
|
||||
{
|
||||
return $this->dateRange;
|
||||
}
|
||||
|
||||
public function setDateRange(DateRange $dateRange): void
|
||||
{
|
||||
$this->dateRange = $dateRange;
|
||||
}
|
||||
}
|
||||
101
src/Repository/Query/InvoiceArchiveQuery.php
Normal file
101
src/Repository/Query/InvoiceArchiveQuery.php
Normal file
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Repository\Query;
|
||||
|
||||
use App\Entity\Customer;
|
||||
use App\Entity\Invoice;
|
||||
use App\Form\Model\DateRange;
|
||||
|
||||
/**
|
||||
* Query for created invoices.
|
||||
*/
|
||||
class InvoiceArchiveQuery extends BaseQuery
|
||||
{
|
||||
use DateRangeTrait;
|
||||
|
||||
public const INVOICE_ARCHIVE_ORDER_ALLOWED = [
|
||||
'date', 'customer', 'total'
|
||||
];
|
||||
|
||||
/**
|
||||
* Filter for invoice status (by default all)
|
||||
* @var string[]
|
||||
*/
|
||||
private $status = [];
|
||||
/**
|
||||
* @var Customer[]
|
||||
*/
|
||||
private $customers = [];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->setDefaults([
|
||||
'orderBy' => 'date',
|
||||
'order' => self::ORDER_DESC,
|
||||
'dateRange' => new DateRange(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function addCustomer(Customer $customer): void
|
||||
{
|
||||
$this->customers[] = $customer;
|
||||
}
|
||||
|
||||
public function setCustomers(array $customers): void
|
||||
{
|
||||
foreach ($customers as $customer) {
|
||||
$this->addCustomer($customer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Customer[]
|
||||
*/
|
||||
public function getCustomers(): array
|
||||
{
|
||||
return $this->customers;
|
||||
}
|
||||
|
||||
public function hasCustomers(): bool
|
||||
{
|
||||
return !empty($this->customers);
|
||||
}
|
||||
|
||||
public function hasStatus(): bool
|
||||
{
|
||||
return !empty($this->status);
|
||||
}
|
||||
|
||||
public function getStatus(): array
|
||||
{
|
||||
return $this->status;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $status
|
||||
*/
|
||||
public function setStatus(array $status): void
|
||||
{
|
||||
foreach ($status as $s) {
|
||||
$this->addStatus($s);
|
||||
}
|
||||
}
|
||||
|
||||
public function addStatus(string $status): void
|
||||
{
|
||||
if (!\in_array($status, [Invoice::STATUS_NEW, Invoice::STATUS_PENDING, Invoice::STATUS_PAID])) {
|
||||
throw new \InvalidArgumentException('Unknown invoice status given.');
|
||||
}
|
||||
|
||||
if (!\in_array($status, $this->status)) {
|
||||
$this->status[] = $status;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,9 @@ namespace App\Repository\Query;
|
||||
|
||||
use App\Entity\InvoiceTemplate;
|
||||
|
||||
/**
|
||||
* Find items (eg timesheets) for creating a new invoice.
|
||||
*/
|
||||
class InvoiceQuery extends TimesheetQuery
|
||||
{
|
||||
/**
|
||||
|
||||
@@ -20,6 +20,7 @@ use App\Form\Model\DateRange;
|
||||
class TimesheetQuery extends ActivityQuery implements BillableInterface
|
||||
{
|
||||
use BillableTrait;
|
||||
use DateRangeTrait;
|
||||
|
||||
public const STATE_ALL = 1;
|
||||
public const STATE_RUNNING = 2;
|
||||
@@ -49,10 +50,6 @@ class TimesheetQuery extends ActivityQuery implements BillableInterface
|
||||
* @var \DateTime|null
|
||||
*/
|
||||
private $modifiedAfter;
|
||||
/**
|
||||
* @var DateRange
|
||||
*/
|
||||
protected $dateRange;
|
||||
/**
|
||||
* @var iterable
|
||||
*/
|
||||
@@ -230,42 +227,6 @@ class TimesheetQuery extends ActivityQuery implements BillableInterface
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getBegin(): ?\DateTime
|
||||
{
|
||||
return $this->dateRange->getBegin();
|
||||
}
|
||||
|
||||
public function setBegin(\DateTime $begin): TimesheetQuery
|
||||
{
|
||||
$this->dateRange->setBegin($begin);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getEnd(): ?\DateTime
|
||||
{
|
||||
return $this->dateRange->getEnd();
|
||||
}
|
||||
|
||||
public function setEnd(\DateTime $end): TimesheetQuery
|
||||
{
|
||||
$this->dateRange->setEnd($end);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDateRange(): DateRange
|
||||
{
|
||||
return $this->dateRange;
|
||||
}
|
||||
|
||||
public function setDateRange(DateRange $dateRange): TimesheetQuery
|
||||
{
|
||||
$this->dateRange = $dateRange;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTags(bool $allowUnknown = false): iterable
|
||||
{
|
||||
if (empty($this->tags)) {
|
||||
|
||||
@@ -77,19 +77,8 @@
|
||||
|
||||
{% macro invoice_listing(view) %}
|
||||
{% import "macros/widgets.html.twig" as widgets %}
|
||||
|
||||
{% set actions = {} %}
|
||||
|
||||
{% if is_granted('view_invoice') %}
|
||||
{% set actions = actions|merge({'back': path('invoice')}) %}
|
||||
{% endif %}
|
||||
|
||||
{% set actions = actions|merge({'visibility': '#modal_invoices'}) %}
|
||||
|
||||
{% set actions = actions|merge({'help': {'url': 'invoices.html'|docu_link, 'target': '_blank'}}) %}
|
||||
|
||||
{% set event = trigger('actions.invoice_details', {'actions': actions, 'view': view}) %}
|
||||
{{ widgets.page_actions(actions) }}
|
||||
{% set event = actions(app.user, 'invoice_details', {'view': view}) %}
|
||||
{{ widgets.page_actions(event.actions) }}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro invoice_upload(view) %}
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
|
||||
{% block page_title %}{{ 'invoice.title'|trans }}{% endblock %}
|
||||
{% block page_actions %}{{ actions.invoice_listing('index') }}{% endblock %}
|
||||
{% block page_search %}{{ toolbar.dropDownSearch(toolbarForm) }}{% endblock %}
|
||||
|
||||
{% block main_before %}
|
||||
{{ tables.data_table_column_modal(tableName, columns) }}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
'title': {'class': 'hidden-xs text-nowrap', 'orderBy': false},
|
||||
'company': {'class': 'hidden-xs hidden-sm hidden', 'orderBy': false},
|
||||
'vat_id': {'class': 'hidden-xs hidden-sm text-nowrap', 'orderBy': false},
|
||||
'vat': {'class': 'hidden-xs hidden-sm hidden-md text-nowrap', 'orderBy': false},
|
||||
'tax_rate': {'class': 'hidden-xs hidden-sm hidden-md text-nowrap', 'orderBy': false},
|
||||
'due_days': {'class': 'hidden-xs hidden-sm hidden-md text-nowrap', 'orderBy': false},
|
||||
'address': {'class': 'hidden', 'orderBy': false},
|
||||
'contact': {'class': 'hidden', 'orderBy': false},
|
||||
@@ -39,7 +39,7 @@
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'title') }}">{{ entry.title }}</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'company') }}">{{ entry.company }}</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'vat_id') }}">{{ entry.vatId }}</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'vat') }}">{{ entry.vat }}</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'tax_rate') }}">{{ entry.vat }}</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'due_days') }}">{{ entry.dueDays }}</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'address') }}">{{ entry.address|nl2br }}</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'contact') }}">{{ entry.contact|nl2br }}</td>
|
||||
|
||||
@@ -13,6 +13,7 @@ use App\Entity\Activity;
|
||||
use App\Entity\Customer;
|
||||
use App\Entity\Project;
|
||||
use App\Entity\Team;
|
||||
use App\Form\Model\DateRange;
|
||||
use App\Repository\Query\ActivityQuery;
|
||||
use App\Repository\Query\BaseQuery;
|
||||
use App\Repository\Query\ProjectQuery;
|
||||
@@ -63,12 +64,12 @@ class BaseQueryTest extends TestCase
|
||||
self::assertNull($sut->getSearchTerm());
|
||||
}
|
||||
|
||||
protected function assertBaseQuery(BaseQuery $sut, $orderBy = 'id')
|
||||
protected function assertBaseQuery(BaseQuery $sut, $orderBy = 'id', $order = BaseQuery::ORDER_ASC)
|
||||
{
|
||||
$this->assertPage($sut);
|
||||
$this->assertPageSize($sut);
|
||||
$this->assertOrderBy($sut, $orderBy);
|
||||
$this->assertOrder($sut);
|
||||
$this->assertOrder($sut, $order);
|
||||
$this->assertTeams($sut);
|
||||
}
|
||||
|
||||
@@ -275,4 +276,29 @@ class BaseQueryTest extends TestCase
|
||||
$this->assertEquals(99, $sut->getProject());
|
||||
$this->assertEquals([99], $sut->getProjects());
|
||||
}
|
||||
|
||||
protected function assertDateRangeTrait($sut)
|
||||
{
|
||||
self::assertNull($sut->getBegin());
|
||||
self::assertNull($sut->getEnd());
|
||||
|
||||
$dateRange = new DateRange();
|
||||
$sut->setDateRange($dateRange);
|
||||
|
||||
self::assertSame($dateRange, $sut->getDateRange());
|
||||
self::assertNull($sut->getBegin());
|
||||
self::assertNull($sut->getEnd());
|
||||
|
||||
$begin = new \DateTime('2013-11-23 13:45:07');
|
||||
$end = new \DateTime('2014-01-01 23:45:11');
|
||||
$dateRange->setBegin($begin);
|
||||
$dateRange->setEnd($end);
|
||||
|
||||
self::assertSame($begin, $sut->getDateRange()->getBegin());
|
||||
/* @phpstan-ignore-next-line */
|
||||
self::assertSame($begin, $sut->getBegin());
|
||||
self::assertSame($end, $sut->getDateRange()->getEnd());
|
||||
/* @phpstan-ignore-next-line */
|
||||
self::assertSame($end, $sut->getEnd());
|
||||
}
|
||||
}
|
||||
|
||||
45
tests/Repository/Query/InvoiceArchiveQueryTest.php
Normal file
45
tests/Repository/Query/InvoiceArchiveQueryTest.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Tests\Repository\Query;
|
||||
|
||||
use App\Entity\Customer;
|
||||
use App\Entity\Invoice;
|
||||
use App\Repository\Query\BaseQuery;
|
||||
use App\Repository\Query\InvoiceArchiveQuery;
|
||||
|
||||
/**
|
||||
* @covers \App\Repository\Query\InvoiceArchiveQuery
|
||||
*/
|
||||
class InvoiceArchiveQueryTest extends BaseQueryTest
|
||||
{
|
||||
public function testQuery()
|
||||
{
|
||||
$sut = new InvoiceArchiveQuery();
|
||||
self::assertFalse($sut->hasStatus());
|
||||
$this->assertBaseQuery($sut, 'date', BaseQuery::ORDER_DESC);
|
||||
$this->assertDateRangeTrait($sut);
|
||||
|
||||
$this->assertIsArray($sut->getCustomers());
|
||||
$this->assertEmpty($sut->getCustomers());
|
||||
self::assertFalse($sut->hasCustomers());
|
||||
|
||||
$sut->addCustomer(new Customer());
|
||||
$sut->setCustomers([new Customer()]);
|
||||
self::assertCount(2, $sut->getCustomers());
|
||||
self::assertTrue($sut->hasCustomers());
|
||||
|
||||
$sut->addStatus(Invoice::STATUS_PAID);
|
||||
$sut->setStatus([Invoice::STATUS_PENDING]);
|
||||
self::assertTrue($sut->hasStatus());
|
||||
self::assertEquals([Invoice::STATUS_PAID, Invoice::STATUS_PENDING], $sut->getStatus());
|
||||
|
||||
$this->assertResetByFormError(new InvoiceArchiveQuery(), 'date', BaseQuery::ORDER_DESC);
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ class TimesheetQueryTest extends BaseQueryTest
|
||||
$this->assertPageSize($sut);
|
||||
$this->assertOrderBy($sut, 'begin');
|
||||
$this->assertOrder($sut, TimesheetQuery::ORDER_DESC);
|
||||
$this->assertDateRangeTrait($sut);
|
||||
|
||||
$this->assertUser($sut);
|
||||
$this->assertUsers($sut);
|
||||
|
||||
@@ -72,6 +72,10 @@
|
||||
<source>attachments</source>
|
||||
<target>Dateien</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="file">
|
||||
<source>file</source>
|
||||
<target>Datei</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="rates.empty">
|
||||
<source>rates.empty</source>
|
||||
<target>Es wurden noch keine Gebühren hinterlegt.</target>
|
||||
@@ -629,6 +633,10 @@
|
||||
<source>label.vat_id</source>
|
||||
<target>Umsatzsteuer-ID</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="label.tax_rate">
|
||||
<source>label.tax_rate</source>
|
||||
<target>Steuersatz</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="label.contact">
|
||||
<source>label.contact</source>
|
||||
<target>Kontakt</target>
|
||||
|
||||
@@ -72,6 +72,10 @@
|
||||
<source>attachments</source>
|
||||
<target>Files</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="file">
|
||||
<source>file</source>
|
||||
<target>File</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="rates.empty">
|
||||
<source>rates.empty</source>
|
||||
<target>No fees have been configured yet.</target>
|
||||
@@ -641,6 +645,10 @@
|
||||
<source>label.vat_id</source>
|
||||
<target>VAT-ID</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="label.tax_rate">
|
||||
<source>label.tax_rate</source>
|
||||
<target>Tax rate</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="label.contact">
|
||||
<source>label.contact</source>
|
||||
<target>Contact</target>
|
||||
|
||||
Reference in New Issue
Block a user