diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 1af4c7a10..e958533bd 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -2,7 +2,9 @@ name: main on: push: - branches: [ master, beta ] + branches: + - master + - 'beta/**' pull_request: jobs: @@ -318,7 +320,7 @@ jobs: - run: true deploy-beta: - if: github.ref == 'refs/heads/beta' + if: ${{ startsWith(github.ref_name, 'beta') }} needs: [checks-passed, workdir] uses: ./.github/workflows/deploy-template.yml with: diff --git a/app/AccountancyModule/Components/DataGrid.php b/app/AccountancyModule/Components/DataGrid.php index df81f54e6..dcbfc994f 100644 --- a/app/AccountancyModule/Components/DataGrid.php +++ b/app/AccountancyModule/Components/DataGrid.php @@ -6,6 +6,7 @@ use Ublaboo\DataGrid\Column\Action; use Ublaboo\DataGrid\Filter\FilterSelect; +use Ublaboo\DataGrid\Localization\SimpleTranslator; use function array_map; use function array_reverse; @@ -29,6 +30,38 @@ public function __construct() //disable autocomplete - issue #1443 $this['filter']->getElementPrototype()->setAttribute('autocomplete', 'off'); }; + + $translator = new SimpleTranslator([ + 'ublaboo_datagrid.no_item_found_reset' => 'Nenalezeny žádné záznamy. Můžete zrušit filtr', + 'ublaboo_datagrid.no_item_found' => 'Nenalezeny žádné záznamy.', + 'ublaboo_datagrid.here' => 'zde', + 'ublaboo_datagrid.items' => 'Položky', + 'ublaboo_datagrid.all' => 'vše', + 'ublaboo_datagrid.from' => 'od', + 'ublaboo_datagrid.reset_filter' => 'Zrušit filtr', + 'ublaboo_datagrid.group_actions' => 'Hromadné akce', + 'ublaboo_datagrid.show' => 'Zobrazit', + 'ublaboo_datagrid.add' => 'Přidat', + 'ublaboo_datagrid.edit' => 'Upravit', + 'ublaboo_datagrid.show_all_columns' => 'Zobrazit všechny sloupce', + 'ublaboo_datagrid.show_default_columns' => 'Zobrazit výchozí sloupce', + 'ublaboo_datagrid.hide_column' => 'Skrýt sloupec', + 'ublaboo_datagrid.action' => 'Akce', + 'ublaboo_datagrid.previous' => 'Předchozí', + 'ublaboo_datagrid.next' => 'Další', + 'ublaboo_datagrid.choose' => 'Vybrat', + 'ublaboo_datagrid.choose_input_required' => 'Text hromadné akce nesmí být prázdný', + 'ublaboo_datagrid.execute' => 'Provést', + 'ublaboo_datagrid.save' => 'Uložit', + 'ublaboo_datagrid.cancel' => 'Zrušit', + 'ublaboo_datagrid.multiselect_choose' => 'Vybrat', + 'ublaboo_datagrid.multiselect_selected' => '{0} vybráno', + 'ublaboo_datagrid.filter_submit_button' => 'Filtrovat', + 'ublaboo_datagrid.show_filter' => 'Zobrazit filtr', + 'ublaboo_datagrid.per_page_submit' => 'Změnit', + ]); + + $this->setTranslator($translator); } /** diff --git a/app/AccountancyModule/PaymentModule/components/EmailButton.php b/app/AccountancyModule/PaymentModule/components/EmailButton.php new file mode 100644 index 000000000..981c60e5a --- /dev/null +++ b/app/AccountancyModule/PaymentModule/components/EmailButton.php @@ -0,0 +1,201 @@ +queryBus->handle(new GroupEmailQuery($this->group->id, EmailType::get(EmailType::PAYMENT_REMINDER))); + $paymentsForSendEmail = $this->paymentsAvailableForGroupInfoSending($this->payments); + + $this->template->setParameters([ + 'canSend' => $this->canSend(), + 'isReminderSendActive' => $email !== null && $email->isEnabled(), + 'isGroupSendActive' => $this->group->getState() === 'open' && ! empty($paymentsForSendEmail), + ]); + $this->template->setFile(__DIR__ . '/templates/EmailButton.latte'); + $this->template->render(); + } + + public function canSend(): bool + { + return ! ($this->group->getOauthId() === null) && ! ($this->group->getBankAccountId() === null); + } + + public function renderLight(): void + { + $this->template->setParameters(['style' => 'light']); + $this->render(); + } + + /** + * rozešle všechny neposlané e-maily + */ + public function handleSendGroup(): void + { + $this->sendPaymentInfoEmails($this->paymentsAvailableForGroupInfoSending($this->payments)); + } + + public function handleSendGroupReminder(): void + { + $this->sendPaymentReminderEmails($this->paymentsAvailableForGroupReminderSending($this->payments)); + } + + public function handleSendTest(): void + { + if (! $this->isEditable) { + $this->presenter->flashMessage('Neplatný požadavek na odeslání testovacího e-mailu!', 'danger'); + $this->presenter->redirect('this'); + } + + try { + $email = $this->mailing->sendTestMail($this->group->id); + $this->presenter->flashMessage('Testovací e-mail byl odeslán na ' . $email . '.'); + } catch (OAuthNotSet) { + $this->presenter->flashMessage(self::NO_MAILER_MESSAGE, 'warning'); + } catch (InvalidOAuth $e) { + $this->oauthError($e); + } catch (InvalidBankAccount) { + $this->presenter->flashMessage(self::NO_BANK_ACCOUNT_MESSAGE, 'warning'); + } catch (EmailNotSet) { + $this->flashMessage('Nemáte nastavený e-mail ve skautisu, na který by se odeslal testovací e-mail!', 'danger'); + } + + $this->redirect('this'); + } + + /** @param Payment[] $payments */ + private function sendPaymentInfoEmails(array $payments): void + { + $sentCount = 0; + + try { + foreach ($payments as $payment) { + $this->commandBus->handle(new SendPaymentInfo($payment->getId())); + $sentCount++; + } + } catch (OAuthNotSet) { + $this->presenter->flashMessage(self::NO_MAILER_MESSAGE, 'warning'); + $this->presenter->redirect('this'); + } catch (InvalidBankAccount) { + $this->presenter->flashMessage(self::NO_BANK_ACCOUNT_MESSAGE, 'warning'); + $this->presenter->redirect('this'); + } catch (InvalidOAuth $e) { + $this->oauthError($e); + $this->presenter->redirect('this'); + } + + if ($sentCount > 0) { + $this->presenter->flashMessage( + $sentCount === 1 + ? 'Informační e-mail byl odeslán' + : 'Informační e-maily (' . $sentCount . ') byly odeslány', + 'success', + ); + } + + $this->presenter->redirect('this'); + } + + /** @param Payment[] $payments */ + private function sendPaymentReminderEmails(array $payments): void + { + $sentCount = 0; + + try { + foreach ($payments as $payment) { + $this->commandBus->handle(new SendPaymentReminder($payment->getId())); + $sentCount++; + } + } catch (OAuthNotSet) { + $this->presenter->flashMessage(self::NO_MAILER_MESSAGE, 'warning'); + $this->presenter->redirect('this'); + } catch (InvalidBankAccount) { + $this->presenter->flashMessage(self::NO_BANK_ACCOUNT_MESSAGE, 'warning'); + $this->presenter->redirect('this'); + } catch (InvalidOAuth $e) { + $this->oauthError($e); + $this->presenter->redirect('this'); + } catch (EmailTemplateNotSet) { + $this->presenter->flashMessage(self::NO_TEMPLATE_ASSIGNED, 'warning'); + $this->presenter->redirect('this'); + } + + if ($sentCount > 0) { + $this->presenter->flashMessage( + $sentCount === 1 + ? 'E-mail s upomínkou byl odeslán' + : 'E-maily s upomínkou byly odeslány (celkem: ' . $sentCount . ')', + 'success', + ); + } + + $this->presenter->redirect('this'); + } + + /** + * @param Payment[] $payments + * + * @return Payment[] + */ + private function paymentsAvailableForGroupInfoSending(array $payments): array + { + return array_filter( + $payments, + function (Payment $p) { + return ! $p->isClosed() && ! empty($p->getEmailRecipients()) && $p->getSentEmails() === []; + }, + ); + } + + /** + * @param Payment[] $payments + * + * @return Payment[] + */ + private function paymentsAvailableForGroupReminderSending(array $payments): array + { + return array_filter( + $payments, + function (Payment $p) { + return $p->isOverdue(); + }, + ); + } + + private function oauthError(InvalidOAuth $e): void + { + $this->presenter->flashMessage($e->getExplainedMessage(), 'danger'); + } +} diff --git a/app/AccountancyModule/PaymentModule/components/GroupForm.php b/app/AccountancyModule/PaymentModule/components/GroupForm.php index d07843c2d..9aa4d070a 100644 --- a/app/AccountancyModule/PaymentModule/components/GroupForm.php +++ b/app/AccountancyModule/PaymentModule/components/GroupForm.php @@ -165,6 +165,7 @@ private function formSucceeded(BaseForm $form): void $emails = [ EmailType::PAYMENT_INFO => $this->buildEmailTemplate($v, EmailType::PAYMENT_INFO), EmailType::PAYMENT_COMPLETED => $this->buildEmailTemplate($v, EmailType::PAYMENT_COMPLETED), + EmailType::PAYMENT_REMINDER => $this->buildEmailTemplate($v, EmailType::PAYMENT_REMINDER), ]; $emails = array_filter($emails); @@ -178,6 +179,7 @@ private function formSucceeded(BaseForm $form): void $emails, $oAuthId, $v->bankAccount, + $v->emails[EmailType::PAYMENT_REMINDER]?->remindersEnabled ?? false, ); $this->flashMessage('Skupina byla upravena'); @@ -190,6 +192,7 @@ private function formSucceeded(BaseForm $form): void $emails, $oAuthId, $v->bankAccount, + $v->emails[EmailType::PAYMENT_REMINDER]?->remindersEnabled ?? false, ); $this->flashMessage('Skupina byla založena'); @@ -218,6 +221,11 @@ private function buildDefaultsFromGroup(): array foreach (EmailType::getAvailableEnums() as $emailType) { $emails[$emailType->toString()] = $this->getEmailDefaults($this->groupId, $emailType); + if ($emailType->toString() !== EmailType::PAYMENT_REMINDER) { + continue; + } + + $emails[$emailType->toString()]['remindersEnabled'] = $group->isRemindersEnabled(); } return [ @@ -240,6 +248,8 @@ private function addEmailsToForm(BaseForm $form): void $emails = [ EmailType::PAYMENT_INFO => 'E-mail s platebními údaji', EmailType::PAYMENT_COMPLETED => 'E-mail při dokončení platby', + // EmailType::PAYMENT_CANCELED => 'E-mail při zrušení platby', + EmailType::PAYMENT_REMINDER => 'E-mail upomínka platby', ]; foreach ($emails as $type => $caption) { @@ -247,8 +257,9 @@ private function addEmailsToForm(BaseForm $form): void $container = $emailsContainer->addContainer($type); $container->setCurrentGroup($group); - $subjectId = $type . '_subject'; - $bodyId = $type . '_body'; + $subjectId = $type . '_subject'; + $bodyId = $type . '_body'; + $remindersId = $type . '_reminders'; // Only payment info email is always saved if ($type !== EmailType::PAYMENT_INFO) { @@ -256,12 +267,18 @@ private function addEmailsToForm(BaseForm $form): void ->setOption('class', 'form-check') ->addCondition($form::FILLED) ->toggle($subjectId) - ->toggle($bodyId); + ->toggle($bodyId) + ->toggle($remindersId); + } + + if ($type === EmailType::PAYMENT_REMINDER) { + $container->addCheckbox('remindersEnabled', 'Automaticky odeslat email po splatnosti') + ->setOption('id', $remindersId); } $container->addText('subject', 'Předmět e-mailu') ->setOption('id', $subjectId); - $container->addTextArea('body', 'Text mailu') + $container->addTextArea('body', 'Text mailu', 10, 20) ->setOption('id', $bodyId) ->setHtmlAttribute('class', 'form-control') ->setDefaultValue($this->getDefaultEmailBody($type)); diff --git a/app/AccountancyModule/PaymentModule/components/PaymentList.php b/app/AccountancyModule/PaymentModule/components/PaymentList.php index 1af65f5d1..fe67dcded 100644 --- a/app/AccountancyModule/PaymentModule/components/PaymentList.php +++ b/app/AccountancyModule/PaymentModule/components/PaymentList.php @@ -8,10 +8,23 @@ use App\AccountancyModule\Components\DataGrid; use App\AccountancyModule\Factories\GridFactory; use App\AccountancyModule\Grids\DtoListDataSource; +use Model\Common\Services\CommandBus; use Model\Common\Services\QueryBus; use Model\DTO\Payment\Payment; +use Model\Google\Exception\OAuthNotSet; +use Model\Google\InvalidOAuth; +use Model\Payment\Commands\Mailing\SendPaymentInfo; +use Model\Payment\Commands\Mailing\SendPaymentReminder; +use Model\Payment\EmailTemplateNotSet; +use Model\Payment\EmailType; +use Model\Payment\InvalidBankAccount; use Model\Payment\Payment\State; +use Model\Payment\PaymentClosed; +use Model\Payment\PaymentHasNoEmails; +use Model\Payment\PaymentNotFound; +use Model\Payment\ReadModel\Queries\GroupEmailQuery; use Model\Payment\ReadModel\Queries\PaymentListQuery; +use Model\PaymentService; use function array_flip; use function array_reverse; @@ -26,7 +39,7 @@ final class PaymentList extends BaseControl State::CANCELED, ]; - public function __construct(private int $groupId, private bool $isEditable, private QueryBus $queryBus, private GridFactory $gridFactory) + public function __construct(private PaymentService $model, private CommandBus $commandBus, private int $groupId, private bool $isEditable, private QueryBus $queryBus, private GridFactory $gridFactory) { } @@ -38,13 +51,25 @@ public function render(): void protected function createComponentGrid(): DataGrid { - $grid = $this->gridFactory->createSimpleGrid( + $email = $this->queryBus->handle(new GroupEmailQuery($this->groupId, EmailType::get(EmailType::PAYMENT_REMINDER))); + $grid = $this->gridFactory->createSimpleGrid( __DIR__ . '/templates/PaymentList.grid.latte', - ['isEditable' => $this->isEditable], + [ + 'isEditable' => $this->isEditable, + 'isReminderSendActive' => $email !== null && $email->isEnabled(), + ], ); $grid->setRememberState(false, true); $grid->setColumnsHideable(); + $grid->addGroupButtonAction('Odeslat email')->onClick[] = [$this, 'sendMail']; + if ($email !== null && $email->isEnabled()) { + $grid->addGroupButtonAction('Odeslat upomínku')->onClick[] = [$this, 'sendReminder']; + } + + $grid->addGroupButtonAction('Zaplaceno')->onClick[] = [$this, 'setPay']; + $grid->addGroupButtonAction('Zrušit')->onClick[] = [$this, 'setCancel']; + $grid->addColumnText('name', 'Název/účel') ->setSortable() ->setSortableCallback(function (DtoListDataSource $dataSource, array $sort): DtoListDataSource { @@ -74,6 +99,9 @@ protected function createComponentGrid(): DataGrid $grid->addColumnDateTime('dueDate', 'Splatnost') ->setSortable(); + $grid->addColumnDateTime('closedAt', 'Zaplaceno') + ->setSortable(); + $grid->addColumnDateTime('Note', 'Poznámka') ->setSortable() ->setDefaultHide(); @@ -99,4 +127,151 @@ protected function createComponentGrid(): DataGrid return $grid; } + + public function handleSend(int $pid): void + { + $payment = $this->model->findPayment($pid); + + if ($payment === null) { + $this->presenter->flashMessage('Zadaná platba neexistuje', 'danger'); + $this->presenter->redirect('this'); + } + + if (empty($payment->getEmailRecipients())) { + $this->presenter->flashMessage('Platba nemá vyplněný e-mail', 'danger'); + $this->presenter->redirect('this'); + } + + $this->sendMail([$pid]); + } + + public function handleSendReminder(int $pid): void + { + $payment = $this->model->findPayment($pid); + + if ($payment === null) { + $this->presenter->flashMessage('Zadaná platba neexistuje', 'danger'); + $this->presenter->redirect('this'); + } + + if (empty($payment->getEmailRecipients())) { + $this->presenter->flashMessage('Platba nemá vyplněný e-mail', 'danger'); + $this->presenter->redirect('this'); + } + + $this->sendReminder([$pid]); + } + + public function handleComplete(int $pid): void + { + $this->setPay([$pid]); + } + + public function handleCancel(int $pid): void + { + $this->setCancel([$pid]); + } + + /** @param array $ids */ + public function sendMail(array $ids): void + { + $count = 0; + foreach ($ids as $id) { + try { + $this->commandBus->handle(new SendPaymentInfo($id)); + $count++; + } catch (OAuthNotSet) { + $this->flashMessage(EmailButton::NO_MAILER_MESSAGE, 'warning'); + } catch (InvalidBankAccount) { + $this->flashMessage(EmailButton::NO_BANK_ACCOUNT_MESSAGE, 'warning'); + } catch (InvalidOAuth $e) { + $this->flashMessage($e->getExplainedMessage(), 'danger'); + } catch (PaymentClosed $e) { + $this->flashMessage($e->getMessage(), 'warning'); + } catch (PaymentHasNoEmails $e) { + $this->flashMessage($e->getMessage(), 'warning'); + } + } + + if ($count === 1) { + $this->presenter->flashMessage($count . ' informační e-mail odeslán', 'info'); + } else { + $this->presenter->flashMessage($count . ' Informačních e-mailů odesláno', 'info'); + } + + $this->presenter->redirect('this'); + } + + /** @param array $ids */ + public function sendReminder(array $ids): void + { + $count = 0; + try { + foreach ($ids as $id) { + try { + $this->commandBus->handle(new SendPaymentReminder($id)); + $count++; + } catch (OAuthNotSet) { + $this->flashMessage(EmailButton::NO_MAILER_MESSAGE, 'warning'); + } catch (InvalidBankAccount) { + $this->flashMessage(EmailButton::NO_BANK_ACCOUNT_MESSAGE, 'warning'); + } catch (InvalidOAuth $e) { + $this->flashMessage($e->getExplainedMessage(), 'danger'); + } catch (PaymentClosed $e) { + $this->flashMessage($e->getMessage(), 'warning'); + } catch (PaymentHasNoEmails $e) { + $this->flashMessage($e->getMessage(), 'warning'); + } + } + + if ($count === 1) { + $this->presenter->flashMessage($count . ' upomínkový e-mailů odeslán', 'info'); + } else { + $this->presenter->flashMessage($count . ' upomínkových e-mailů odesláno', 'info'); + } + } catch (EmailTemplateNotSet) { + $this->flashMessage('Platební skupina nemá povolené upomínky', 'warning'); + } + + $this->presenter->redirect('this'); + } + + /** @param array $ids */ + public function setPay(array $ids): void + { + if (! $this->isEditable) { + $this->flashMessage('Nejste oprávněni k uzavření platby!', 'danger'); + $this->redirect('this'); + } + + foreach ($ids as $id) { + try { + $this->model->completePayment($id); + $this->flashMessage('Platba byla zaplacena.'); + } catch (PaymentClosed $e) { + $this->flashMessage($e->getMessage(), 'warning'); + } catch (InvalidOAuth $exc) { + $this->flashMessage($exc->getExplainedMessage(), 'danger'); + } + } + + $this->presenter->redirect('this'); + } + + /** @param array $ids */ + public function setCancel(array $ids): void + { + foreach ($ids as $id) { + try { + $this->model->cancelPayment($id); + $this->flashMessage('Platba byla uzavřena'); + } catch (PaymentNotFound) { + $this->flashMessage('Platba nenalezena!', 'danger'); + } catch (PaymentClosed $e) { + $this->flashMessage($e->getMessage(), 'warning'); + } + } + + $this->presenter->redirect('this'); + } } diff --git a/app/AccountancyModule/PaymentModule/components/templates/EmailButton.latte b/app/AccountancyModule/PaymentModule/components/templates/EmailButton.latte new file mode 100644 index 000000000..90f1d3fac --- /dev/null +++ b/app/AccountancyModule/PaymentModule/components/templates/EmailButton.latte @@ -0,0 +1,40 @@ +
+ + +
\ No newline at end of file diff --git a/app/AccountancyModule/PaymentModule/components/templates/GroupForm.latte b/app/AccountancyModule/PaymentModule/components/templates/GroupForm.latte index 2c39b8ab1..1a88a5ca2 100644 --- a/app/AccountancyModule/PaymentModule/components/templates/GroupForm.latte +++ b/app/AccountancyModule/PaymentModule/components/templates/GroupForm.latte @@ -1,8 +1,18 @@
+
- {control form}
+ + + +
+
+
+
+ {control form} +
+

E-mail odesílatele
E-mail, ze kterého se budou zasílat informace o platbách.

diff --git a/app/AccountancyModule/PaymentModule/components/templates/PaymentList.grid.latte b/app/AccountancyModule/PaymentModule/components/templates/PaymentList.grid.latte index 05b5c5740..fab860191 100644 --- a/app/AccountancyModule/PaymentModule/components/templates/PaymentList.grid.latte +++ b/app/AccountancyModule/PaymentModule/components/templates/PaymentList.grid.latte @@ -20,11 +20,15 @@
{var $emailType = $email->getType()->toString()} - {if $emailType === 'payment_info'} + {switch $emailType} + {case \Model\Payment\EmailType::PAYMENT_INFO} Platební údaje - {elseif $emailType === 'payment_completed'} + {case \Model\Payment\EmailType::PAYMENT_COMPLETED} Potvrzení o dokončení platby - {/if} + {case \Model\Payment\EmailType::PAYMENT_REMINDER} + Upomínka platby + {/switch} +
odeslal {$email->getTime()|date:'d.m.Y'} uživatel {$email->getSenderName()} @@ -49,14 +53,19 @@ - - + {/if} + {elseif $item->isClosed()} - {$item->getClosedAt()|date:'j.n.Y'} @@ -84,7 +93,7 @@
{else} {/if} - diff --git a/app/AccountancyModule/PaymentModule/factories/IEmailButtonFactory.php b/app/AccountancyModule/PaymentModule/factories/IEmailButtonFactory.php new file mode 100644 index 000000000..6b09a40c2 --- /dev/null +++ b/app/AccountancyModule/PaymentModule/factories/IEmailButtonFactory.php @@ -0,0 +1,15 @@ +assertCanEditGroup(); $group = $this->model->getGroup($id); if ($group === null || ! $this->hasAccessToGroup($group)) { @@ -114,18 +109,15 @@ public function actionDefault(int $id): void $nextVS = null; } - $payments = $this->getPaymentsForGroup($id); - - $paymentsForSendEmail = $this->paymentsAvailableForGroupInfoSending($payments); + $this->payments = $this->getPaymentsForGroup($id); $this->template->setParameters([ 'group' => $group, 'nextVS' => $nextVS, - 'payments' => $payments, + 'payments' => $this->payments, 'summarize' => $this->model->getGroupSummaries([$id])[$id], 'now' => new DateTimeImmutable(), - 'isGroupSendActive' => $group->getState() === 'open' && ! empty($paymentsForSendEmail), - 'notSentPaymentsCount' => $this->countNotSentPayments($payments), + 'notSentPaymentsCount' => $this->countNotSentPayments($this->payments), ]); } @@ -154,21 +146,6 @@ public function actionMassAdd(int $id, int|null $unitId = null, bool $directMemb ]); } - public function handleCancel(int $pid): void - { - $this->assertCanEditGroup(); - - try { - $this->model->cancelPayment($pid); - } catch (PaymentNotFound) { - $this->flashMessage('Platba nenalezena!', 'danger'); - } catch (PaymentClosed) { - $this->flashMessage('Tato platba už je uzavřená', 'danger'); - } - - $this->redirect('this'); - } - private function assertCanEditGroup(): void { $group = $this->model->getGroup($this->id); @@ -181,85 +158,6 @@ private function assertCanEditGroup(): void $this->template->setParameters(['message' => 'Nemáte oprávnění pracovat s touto skupinou.']); } - public function handleSend(int $pid): void - { - $this->assertCanEditGroup(); - - $payment = $this->model->findPayment($pid); - - if ($payment === null) { - $this->flashMessage('Zadaná platba neexistuje', 'danger'); - $this->redirect('this'); - } - - if (empty($payment->getEmailRecipients())) { - $this->flashMessage('Platba nemá vyplněný e-mail', 'danger'); - $this->redirect('this'); - } - - try { - $this->sendEmailsForPayments([$payment]); - } catch (PaymentClosed) { - $this->flashMessage('Nelze odeslat uzavřenou platbu'); - } - } - - /** - * rozešle všechny neposlané e-maily - * - * @param int $gid groupId - */ - public function handleSendGroup(int $gid): void - { - $this->assertCanEditGroup(); - - $payments = $this->getPaymentsForGroup($gid); - - $this->sendEmailsForPayments($this->paymentsAvailableForGroupInfoSending($payments)); - } - - public function handleSendTest(int $gid): void - { - if (! $this->isEditable) { - $this->flashMessage('Neplatný požadavek na odeslání testovacího e-mailu!', 'danger'); - $this->redirect('this'); - } - - try { - $email = $this->mailing->sendTestMail($gid); - $this->flashMessage('Testovací e-mail byl odeslán na ' . $email . '.'); - } catch (OAuthNotSet) { - $this->flashMessage(self::NO_MAILER_MESSAGE, 'warning'); - } catch (InvalidOAuth $e) { - $this->oauthError($e); - } catch (InvalidBankAccount) { - $this->flashMessage(self::NO_BANK_ACCOUNT_MESSAGE, 'warning'); - } catch (EmailNotSet) { - $this->flashMessage('Nemáte nastavený e-mail ve skautisu, na který by se odeslal testovací e-mail!', 'danger'); - } - - $this->redirect('this'); - } - - public function handleComplete(int $pid): void - { - if (! $this->isEditable) { - $this->flashMessage('Nejste oprávněni k uzavření platby!', 'danger'); - $this->redirect('this'); - } - - try { - $this->model->completePayment($pid); - $this->flashMessage('Platba byla zaplacena.'); - } catch (PaymentClosed) { - $this->flashMessage('Tato platba už je uzavřená', 'danger'); - } catch (InvalidOAuth $exc) { - $this->flashMessage($exc->getExplainedMessage(), 'danger'); - } - - $this->redirect('this'); - } - public function handleGenerateVs(): void { $this->assertCanEditGroup(); @@ -386,6 +284,13 @@ protected function createComponentPairButton(): PairButton return $this->pairButtonFactory->create(); } + protected function createComponentEmailButton(): EmailButton + { + $group = $this->model->getGroup($this->id); + + return $this->emailButtonFactory->create($this->isEditable, $this->payments, $group); + } + protected function createComponentMassAddForm(): MassAddForm { return $this->massAddFormFactory->create($this->id); @@ -408,65 +313,12 @@ protected function createComponentProgress(): GroupProgress return new GroupProgress($this->model->getGroupSummaries([$this->id])[$this->id]); } - private function oauthError(InvalidOAuth $e): void - { - $this->flashMessage($e->getExplainedMessage(), 'danger'); - } - - /** @param Payment[] $payments */ - private function sendEmailsForPayments(array $payments): void - { - $sentCount = 0; - - try { - foreach ($payments as $payment) { - $this->commandBus->handle(new SendPaymentInfo($payment->getId())); - $sentCount++; - } - } catch (OAuthNotSet) { - $this->flashMessage(self::NO_MAILER_MESSAGE, 'warning'); - $this->redirect('this'); - } catch (InvalidBankAccount) { - $this->flashMessage(self::NO_BANK_ACCOUNT_MESSAGE, 'warning'); - $this->redirect('this'); - } catch (InvalidOAuth $e) { - $this->oauthError($e); - $this->redirect('this'); - } - - if ($sentCount > 0) { - $this->flashMessage( - $sentCount === 1 - ? 'Informační e-mail byl odeslán' - : 'Informační e-maily (' . $sentCount . ') byly odeslány', - 'success', - ); - } - - $this->redirect('this'); - } - /** @return Payment[] */ private function getPaymentsForGroup(int $groupId): array { return $this->queryBus->handle(new PaymentListQuery($groupId)); } - /** - * @param Payment[] $payments - * - * @return Payment[] - */ - private function paymentsAvailableForGroupInfoSending(array $payments): array - { - return array_filter( - $payments, - function (Payment $p) { - return ! $p->isClosed() && ! empty($p->getEmailRecipients()) && $p->getSentEmails() === []; - }, - ); - } - /** @param Payment[] $payments */ private function countNotSentPayments(array $payments): int { diff --git a/app/AccountancyModule/PaymentModule/templates/Payment/default.latte b/app/AccountancyModule/PaymentModule/templates/Payment/default.latte index f857cc5c7..6f3512333 100644 --- a/app/AccountancyModule/PaymentModule/templates/Payment/default.latte +++ b/app/AccountancyModule/PaymentModule/templates/Payment/default.latte @@ -50,23 +50,7 @@ Časopisy {/if} -
- - -
+ {control emailButton:light} {control pairButton:light} diff --git a/app/AccountancyModule/PaymentModule/templates/defaultEmails/payment_reminder.html b/app/AccountancyModule/PaymentModule/templates/defaultEmails/payment_reminder.html new file mode 100644 index 000000000..4bec5ff79 --- /dev/null +++ b/app/AccountancyModule/PaymentModule/templates/defaultEmails/payment_reminder.html @@ -0,0 +1,19 @@ +Dobrý den, +evidujeme, že níže uvedená platba je po splatnosti (splatnost: %maturity%). Prosíme o její úhradu. + +Informace k platbě:
+Účel platby: %name%
+Číslo účtu: %account%
+Částka: %amount% Kč
+Datum splatnosti: %maturity%
+VS: %vs%
+KS: %ks% + +Pro zrychlení platby jsme připravili QR kód, který lze použít při placení v mobilních aplikacích bank. Použití QR kódu šetří váš čas a snižuje pravděpodobnost překlepu. +%qrcode% + +Pokud již byla platba uhrazena, děkujeme a tento e-mail prosím považujte za bezpředmětný. + +Děkujeme za rychlou úhradu. + +
Tento e-mail byl vygenerován automaticky systémem Skautského hospodaření online (h.skauting.cz) a odeslán uživatelem %user%. \ No newline at end of file diff --git a/app/AccountancyModule/TravelModule/Components/CommandForm.php b/app/AccountancyModule/TravelModule/Components/CommandForm.php index 775770729..2ba731740 100644 --- a/app/AccountancyModule/TravelModule/Components/CommandForm.php +++ b/app/AccountancyModule/TravelModule/Components/CommandForm.php @@ -233,7 +233,7 @@ private function createCommand(ArrayHash $values): void $values['unit'], ); - $this->flashMessage('Cestovní příkaz byl založen.'); + $this->presenter->flashMessage('Cestovní příkaz byl založen.'); } private function updateCommand(ArrayHash $values): void @@ -253,7 +253,7 @@ private function updateCommand(ArrayHash $values): void $values['unit'], ); - $this->flashMessage('Cestovní příkaz byl upraven.'); + $this->presenter->flashMessage('Cestovní příkaz byl upraven.'); } /** diff --git a/app/AccountancyModule/TravelModule/Components/VehicleGrid.php b/app/AccountancyModule/TravelModule/Components/VehicleGrid.php index c68ce8cd6..ba12ec28d 100644 --- a/app/AccountancyModule/TravelModule/Components/VehicleGrid.php +++ b/app/AccountancyModule/TravelModule/Components/VehicleGrid.php @@ -6,11 +6,12 @@ use App\AccountancyModule\Factories\BaseGridControl; use App\AccountancyModule\Factories\GridFactory; -use Doctrine\Common\Collections\ArrayCollection; +use Model\Travel\VehicleLinkedRecord; +use Model\Travel\VehicleNotFound; use Model\TravelService; use Model\UnitService; +use Ublaboo\DataGrid\Column\Action\Confirmation\StringConfirmation; use Ublaboo\DataGrid\DataGrid; -use Ublaboo\DataGrid\DataSource\DoctrineCollectionDataSource; class VehicleGrid extends BaseGridControl { @@ -27,28 +28,62 @@ protected function createComponentGrid(): DataGrid ['units' => $units], ); - $grid->addColumnLink('type', 'Typ', 'Vehicle:detail') + $grid->addColumnText('type', 'Typ') ->setSortable(); - $grid->addColumnText('registration', 'SPZ'); + $grid->addColumnLink('registration', 'SPZ', 'Vehicle:detail') + ->setFilterText(); $grid->addColumnText('consumption', 'Ø spotřeba (l/100 km)'); $grid->addColumnText('subunit', 'Oddíl') ->setFilterSelect($units, 'subunitId')->setPrompt('Všechny'); - $grid->addColumnDateTime('createdAt', 'Vytvořeno') + $grid->addColumnDateTime('metadata.createdAt', 'Vytvořeno') ->setSortable(); - $grid->addColumnDateTime('authorName', 'Vytvořil') - ->setSortable() - ->setFilterText(); + $grid->addColumnDateTime('metadata.authorName', 'Vytvořil') + ->setSortable(); - $grid->addFilterText('search', '', ['type', 'authorName']) + $grid->addFilterText('search', '', ['type', 'v.metadata.authorName', 'registration']) ->setPlaceholder('Typ vozdila, uživatel...'); - $vehicles = $this->travel->getAllVehicles($this->unitId); - $grid->setDataSource(new DoctrineCollectionDataSource(new ArrayCollection($vehicles), 'id')); + $grid->setDataSource($this->travel->getVehiclesByFilter()); + + $grid->addAction('edit', '', 'Vehicle:detail', ['id' => 'id']) + ->setIcon('far fa-edit') + ->setTitle('Detail vozidla') + ->setClass('btn btn-sm btn-secondary'); + + $grid->addAction('delete', '', 'remove!', ['id' => 'id']) + ->setIcon('far fa-trash-can') + ->setTitle('Smazat vozidlo') + ->setClass('btn btn-sm btn-danger') + ->setConfirmation( + new StringConfirmation('Opravdu chceš smazat řádek %s?', 'registration'), // Second parameter is optional + ); return $grid; } + + public function handleRemove(int $id): void + { + // Check whether vehicle exists and belongs to unit + /*$vehicle = $this->getVehicle($vehicleId); + if (! $this->isVehicleEditable($vehicle)) { + $this->setView('accessDenied'); + + return; + }*/ + + try { + $this->travel->removeVehicle($id); + $this->flashMessage('Vozidlo bylo odebráno.'); + } catch (VehicleLinkedRecord) { + $this->flashMessage('Nelze smazat vozidlo s cestovními příkazy.', 'warning'); + } catch (VehicleNotFound) { + $this->flashMessage('Vozidlo nebylo nalezeno', 'warning'); + } + + $this->redirect('VehicleList:default'); + } } diff --git a/app/AccountancyModule/TravelModule/presenters/VehiclePresenter.php b/app/AccountancyModule/TravelModule/presenters/VehiclePresenter.php index b4ef92e39..74a152f18 100644 --- a/app/AccountancyModule/TravelModule/presenters/VehiclePresenter.php +++ b/app/AccountancyModule/TravelModule/presenters/VehiclePresenter.php @@ -12,6 +12,8 @@ use Model\DTO\Travel\Vehicle as VehicleDTO; use Model\Travel\Commands\Vehicle\CreateVehicle; use Model\Travel\ReadModel\Queries\Vehicle\RoadworthyScansQuery; +use Model\Travel\VehicleLinkedRecord; +use Model\Travel\VehicleNotFound; use Model\TravelService; use Model\Unit\ReadModel\Queries\UnitQuery; use Model\Unit\Unit; @@ -113,10 +115,13 @@ public function handleRemove(int $vehicleId): void return; } - if ($this->travelService->removeVehicle($vehicleId)) { + try { + $this->travelService->removeVehicle($vehicleId); $this->flashMessage('Vozidlo bylo odebráno.'); - } else { + } catch (VehicleLinkedRecord) { $this->flashMessage('Nelze smazat vozidlo s cestovními příkazy.', 'warning'); + } catch (VehicleNotFound) { + $this->flashMessage('Vozidlo nebylo nalezeno', 'warning'); } $this->redirect('VehicleList:default'); diff --git a/app/AccountancyModule/TravelModule/templates/Contract/default.latte b/app/AccountancyModule/TravelModule/templates/Contract/default.latte index 1c62d96f4..694fc463c 100644 --- a/app/AccountancyModule/TravelModule/templates/Contract/default.latte +++ b/app/AccountancyModule/TravelModule/templates/Contract/default.latte @@ -12,21 +12,21 @@
- + - +
  Vykonavatel/Řidič Zástupce jednotky Platná od: Platná do:Akce 
- Vytisknout - Detail - {$c->passenger->name} {$c->unitRepresentative} {$c->since?->toNative()|date:"j. n. Y"} {$c->until?->toNative()|date:"j. n. Y"} + Vytisknout + Detail +
diff --git a/app/AccountancyModule/TravelModule/templates/Contract/detail.latte b/app/AccountancyModule/TravelModule/templates/Contract/detail.latte index b92517115..8e5a660e0 100644 --- a/app/AccountancyModule/TravelModule/templates/Contract/detail.latte +++ b/app/AccountancyModule/TravelModule/templates/Contract/detail.latte @@ -3,12 +3,12 @@
diff --git a/app/AccountancyModule/TravelModule/templates/Vehicle/detail.latte b/app/AccountancyModule/TravelModule/templates/Vehicle/detail.latte index d3222733b..32128d1fd 100644 --- a/app/AccountancyModule/TravelModule/templates/Vehicle/detail.latte +++ b/app/AccountancyModule/TravelModule/templates/Vehicle/detail.latte @@ -5,7 +5,7 @@
Toto vozidlo je archivované.
{else} - + Archivovat {/if} diff --git a/app/config/config.neon b/app/config/config.neon index 2839e0796..647309fa2 100644 --- a/app/config/config.neon +++ b/app/config/config.neon @@ -151,6 +151,9 @@ services: - Model\Services\PdfRenderer(%tempDir%) + group: + factory: Model\GroupService + - Model\PaymentService - Model\Payment\BankAccountService(fioCache: @fio.cache) @@ -242,7 +245,8 @@ services: scheduler: jobs: # stats must be registered as service and have method calculate - - { cron: '* * * * *', callback: [ @fio.client, get ] } + #- { cron: '* * * * *', callback: [ @fio.client, get ] } + - { cron: '* * * * *', callback: [ @group, reminder ] } decorator: diff --git a/app/model/DTO/Payment/Group.php b/app/model/DTO/Payment/Group.php index d42f260cc..06725a9fd 100644 --- a/app/model/DTO/Payment/Group.php +++ b/app/model/DTO/Payment/Group.php @@ -21,6 +21,7 @@ * @property-read int|NULL $constantSymbol * @property-read VariableSymbol|NULL $nextVariableSymbol * @property-read string $state + * @property-read bool $isRemindersEnabled * @property-read OAuthId|NULL $oAuthId * @property-read string $note */ @@ -43,6 +44,7 @@ public function __construct( private OAuthId|null $oAuthId = null, private string $note, private int|null $bankAccountId = null, + private bool $isRemindersEnabled = false, ) { } @@ -116,4 +118,9 @@ public function getBankAccountId(): int|null { return $this->bankAccountId; } + + public function isRemindersEnabled(): bool + { + return $this->isRemindersEnabled; + } } diff --git a/app/model/DTO/Payment/GroupFactory.php b/app/model/DTO/Payment/GroupFactory.php index 197b15ac9..afa56ef53 100644 --- a/app/model/DTO/Payment/GroupFactory.php +++ b/app/model/DTO/Payment/GroupFactory.php @@ -26,6 +26,7 @@ public static function create(GroupEntity $group): Group $group->getOauthId(), $group->getNote(), $group->getBankAccountId(), + $group->isRemindersEnabled(), ); } } diff --git a/app/model/DTO/Payment/Payment.php b/app/model/DTO/Payment/Payment.php index 2546b46c3..4247c09e5 100644 --- a/app/model/DTO/Payment/Payment.php +++ b/app/model/DTO/Payment/Payment.php @@ -113,6 +113,11 @@ public function isClosed(): bool return $this->closed; } + public function isOverdue(): bool + { + return ! $this->closed && $this->dueDate->isPast() && $this->state->equalsValue(State::PREPARING); + } + public function getState(): State { return $this->state; diff --git a/app/model/Infrastructure/Repositories/Payment/GroupRepository.php b/app/model/Infrastructure/Repositories/Payment/GroupRepository.php index 007a3ee56..7925096be 100644 --- a/app/model/Infrastructure/Repositories/Payment/GroupRepository.php +++ b/app/model/Infrastructure/Repositories/Payment/GroupRepository.php @@ -10,6 +10,7 @@ use Model\Common\Services\EventBus; use Model\Google\OAuthId; use Model\Payment\DomainEvents\GroupWasRemoved; +use Model\Payment\EmailType; use Model\Payment\Group; use Model\Payment\Group\Type; use Model\Payment\GroupNotFound; @@ -58,6 +59,23 @@ public function findByIds(array $ids): array return $groups; } + /** + * {@inheritDoc} + */ + public function findByReminder(): array + { + return $this->em->createQueryBuilder() + ->select('g') + ->from(Group::class, 'g', 'g.id') + ->leftJoin(Group\Email::class, 'e', Join::WITH, 'e.group = g') + ->where('g.isRemindersEnabled = 1') + ->andWhere('e.type = :reminder') + ->andWhere('e.enabled = 1') + ->setParameter('reminder', EmailType::PAYMENT_REMINDER) + ->getQuery() + ->getResult(); + } + /** * {@inheritDoc} */ diff --git a/app/model/Infrastructure/Repositories/Payment/PaymentRepository.php b/app/model/Infrastructure/Repositories/Payment/PaymentRepository.php index 9588c8981..ff2010178 100644 --- a/app/model/Infrastructure/Repositories/Payment/PaymentRepository.php +++ b/app/model/Infrastructure/Repositories/Payment/PaymentRepository.php @@ -5,7 +5,9 @@ namespace Model\Infrastructure\Repositories\Payment; use Assert\Assert; +use DateTimeImmutable; use Model\Infrastructure\Repositories\AggregateRepository; +use Model\Payment\EmailType; use Model\Payment\Payment; use Model\Payment\Payment\State; use Model\Payment\PaymentNotFound; @@ -104,6 +106,37 @@ public function findByMultipleGroups(array $groupIds): array ->getResult(); } + /** + * {@inheritDoc} + */ + public function findByReminder(array $groupIds): array + { + Assert::thatAll($groupIds)->integer(); + + if (empty($groupIds)) { + return []; + } + + return $this->getEntityManager()->createQueryBuilder() + ->select('p, e') + ->from(Payment::class, 'p') + ->leftJoin('p.sentEmails', 'e') + ->where('p.groupId IN (:groupIds)') + ->andWhere('p.state = :state') + ->andWhere('p.dueDate <= :dueDate') + ->andWhere( + 'NOT EXISTS ( + SELECT 1 FROM ' . Payment\SentEmail::class . ' se + WHERE se.payment = p AND se.type = :reminderType)', + ) + ->setParameter('groupIds', $groupIds) + ->setParameter('state', State::PREPARING) + ->setParameter('dueDate', (new DateTimeImmutable())->format('Y-m-d')) + ->setParameter('reminderType', EmailType::PAYMENT_REMINDER) + + ->getQuery()->getResult(); + } + public function save(Payment $payment): void { $this->saveAndDispatchEvents($payment); diff --git a/app/model/Infrastructure/Repositories/Travel/VehicleRepository.php b/app/model/Infrastructure/Repositories/Travel/VehicleRepository.php index ba93e5d48..7e7f5f2cf 100644 --- a/app/model/Infrastructure/Repositories/Travel/VehicleRepository.php +++ b/app/model/Infrastructure/Repositories/Travel/VehicleRepository.php @@ -5,6 +5,7 @@ namespace Model\Infrastructure\Repositories\Travel; use Doctrine\ORM\EntityManager; +use Doctrine\ORM\QueryBuilder; use Model\Travel\Repositories\IVehicleRepository; use Model\Travel\Vehicle; use Model\Travel\VehicleNotFound; @@ -64,6 +65,14 @@ public function findByUnit(int $unitId): array return array_values($vehicles); } + public function findByFilter(): QueryBuilder + { + return $this->em->createQueryBuilder() + ->select('v') + ->from(Vehicle::class, 'v', 'v.id') + ->where('v.archived = FALSE'); + } + public function save(Vehicle $vehicle): void { $this->em->persist($vehicle); diff --git a/app/model/Payment/Commands/Mailing/SendPaymentReminder.php b/app/model/Payment/Commands/Mailing/SendPaymentReminder.php new file mode 100644 index 000000000..55431e2b7 --- /dev/null +++ b/app/model/Payment/Commands/Mailing/SendPaymentReminder.php @@ -0,0 +1,25 @@ +paymentId; + } + + public function isCli(): bool + { + return $this->cli; + } +} diff --git a/app/model/Payment/EmailType.php b/app/model/Payment/EmailType.php index b7bcb7812..20fb33cab 100644 --- a/app/model/Payment/EmailType.php +++ b/app/model/Payment/EmailType.php @@ -16,6 +16,10 @@ final class EmailType extends Enum public const PAYMENT_COMPLETED = 'payment_completed'; + public const PAYMENT_CANCELED = 'payment_canceled'; + + public const PAYMENT_REMINDER = 'payment_reminder'; + public function toString(): string { return $this->getValue(); diff --git a/app/model/Payment/Exception/PaymentClosed.php b/app/model/Payment/Exception/PaymentClosed.php index 9a9b9bce9..c00893dc4 100644 --- a/app/model/Payment/Exception/PaymentClosed.php +++ b/app/model/Payment/Exception/PaymentClosed.php @@ -6,6 +6,12 @@ use Exception; +use function sprintf; + class PaymentClosed extends Exception { + public static function withName(string $name): self + { + return new self(sprintf('Platba "%s" je již uzavřena.', $name)); + } } diff --git a/app/model/Payment/Exception/PaymentHasNoEmails.php b/app/model/Payment/Exception/PaymentHasNoEmails.php index 43323d71b..b716ed801 100644 --- a/app/model/Payment/Exception/PaymentHasNoEmails.php +++ b/app/model/Payment/Exception/PaymentHasNoEmails.php @@ -12,6 +12,6 @@ class PaymentHasNoEmails extends Exception { public static function withName(string $name): self { - return new self(sprintf('Payment "%s" does not have any e-mail address filled', $name)); + return new self(sprintf('Platba "%s" nemá vyplněný žádný e-mail', $name)); } } diff --git a/app/model/Payment/Group.php b/app/model/Payment/Group.php index f7a67e6e1..8a5ce2a6e 100644 --- a/app/model/Payment/Group.php +++ b/app/model/Payment/Group.php @@ -68,6 +68,9 @@ class Group /** @ORM\Column(type="datetime_immutable", nullable=true) */ private DateTimeImmutable|null $createdAt = null; + /** @ORM\Column(type="boolean", options={"default"=0}) */ + private bool $isRemindersEnabled = false; + /** * @ORM\Embedded(class=Group\BankAccount::class, columnPrefix=false) * @@ -109,6 +112,7 @@ public function __construct( BankAccount|null $bankAccount, IBankAccountAccessChecker $bankAccountAccessChecker, IOAuthAccessChecker $oAuthAccessChecker, + bool $isRemindersEnabled = false, ) { Assertion::notEmpty($unitIds); $this->object = $object; @@ -133,6 +137,7 @@ public function __construct( $this->changeBankAccount($bankAccount, $bankAccountAccessChecker); $this->changeOAuth($oAuthId, $oAuthAccessChecker); + $this->isRemindersEnabled = $isRemindersEnabled; } public function update( @@ -142,13 +147,15 @@ public function update( BankAccount|null $bankAccount, IBankAccountAccessChecker $bankAccountAccessChecker, IOAuthAccessChecker $oAuthAccessChecker, + bool $isRemindersEnabled = false, ): void { $this->changeBankAccount($bankAccount, $bankAccountAccessChecker); $this->changeOAuth($oAuthId, $oAuthAccessChecker); - $this->name = $name; - $this->paymentDefaults = $paymentDefaults; - $this->oauthId = $oAuthId; + $this->name = $name; + $this->paymentDefaults = $paymentDefaults; + $this->oauthId = $oAuthId; + $this->isRemindersEnabled = $isRemindersEnabled; } public function open(string $note): void @@ -250,6 +257,11 @@ public function getState(): string return $this->state; } + public function isRemindersEnabled(): bool + { + return $this->isRemindersEnabled; + } + public function getCreatedAt(): DateTimeImmutable|null { return $this->createdAt; diff --git a/app/model/Payment/GroupService.php b/app/model/Payment/GroupService.php new file mode 100644 index 000000000..f104a8d08 --- /dev/null +++ b/app/model/Payment/GroupService.php @@ -0,0 +1,58 @@ +groups->findByReminder(); + $reminderPayments = $this->payments->findByReminder(array_map(function ($group) { + return (int) $group->getId(); + }, $reminderGroups)); + + foreach ($reminderPayments as $payment) { + try { + $this->commandBus->handle(new SendPaymentReminder($payment->getId(), true)); + $count++; + } catch (OAuthNotSet) { + $this->logger->error('OAuth not set'); + } catch (InvalidBankAccount) { + $this->logger->error(EmailButton::NO_BANK_ACCOUNT_MESSAGE); + } catch (InvalidOAuth) { + $this->logger->error('Invalid OAuth'); + } catch (EmailTemplateNotSet) { + $this->logger->error(EmailButton::NO_TEMPLATE_ASSIGNED); + } catch (PaymentHasNoEmails) { + $this->logger->error('Payment has no emails'); + } + } + + $this->logger->info('Sent reminders for ' . $count . ' payments'); + } +} diff --git a/app/model/Payment/Handlers/Mailing/SendPaymentInfoHandler.php b/app/model/Payment/Handlers/Mailing/SendPaymentInfoHandler.php index a732aedef..51b89e59d 100644 --- a/app/model/Payment/Handlers/Mailing/SendPaymentInfoHandler.php +++ b/app/model/Payment/Handlers/Mailing/SendPaymentInfoHandler.php @@ -23,7 +23,7 @@ public function __invoke(SendPaymentInfo $command): void $payment = $this->payments->find($command->getPaymentId()); if ($payment->isClosed()) { - throw new PaymentClosed(); + throw PaymentClosed::withName($payment->getName()); } $this->mailingService->sendEmail($payment->getId(), EmailType::get(EmailType::PAYMENT_INFO)); diff --git a/app/model/Payment/Handlers/Mailing/SendPaymentReminderHandler.php b/app/model/Payment/Handlers/Mailing/SendPaymentReminderHandler.php new file mode 100644 index 000000000..9d28561b2 --- /dev/null +++ b/app/model/Payment/Handlers/Mailing/SendPaymentReminderHandler.php @@ -0,0 +1,31 @@ +payments->find($command->getPaymentId()); + + if ($payment->isClosed()) { + throw PaymentClosed::withName($payment->getName()); + } + + $this->mailingService->sendEmail($payment->getId(), EmailType::get(EmailType::PAYMENT_REMINDER), $command->isCli()); + } +} diff --git a/app/model/Payment/MailingService.php b/app/model/Payment/MailingService.php index 8398c8557..a267a41ee 100644 --- a/app/model/Payment/MailingService.php +++ b/app/model/Payment/MailingService.php @@ -44,7 +44,7 @@ public function __construct( * @throws EmailTemplateNotSet * @throws OAuthNotSet */ - public function sendEmail(int $paymentId, EmailType $emailType): void + public function sendEmail(int $paymentId, EmailType $emailType, bool $cli = false): void { $payment = $this->payments->find($paymentId); $group = $this->groups->find($payment->getGroupId()); @@ -57,9 +57,14 @@ public function sendEmail(int $paymentId, EmailType $emailType): void ); } - $this->sendForPayment($payment, $group, $template); + if ($cli) { + $userName = 'AUTOMAT'; + } else { + $userName = $this->users->getCurrentUser()->getName(); + } - $payment->recordSentEmail($emailType, new DateTimeImmutable(), $this->users->getCurrentUser()->getName()); + $this->sendForPayment($payment, $group, $template, $userName); + $payment->recordSentEmail($emailType, new DateTimeImmutable(), $userName); $this->payments->save($payment); } @@ -93,7 +98,7 @@ public function sendTestMail(int $groupId): string 'obsah poznámky', ); - $this->send($group, $payment, $group->getEmailTemplate(EmailType::get(EmailType::PAYMENT_INFO))); + $this->send($group, $payment, $group->getEmailTemplate(EmailType::get(EmailType::PAYMENT_INFO)), $user->getName()); return $user->getEmail(); } @@ -106,9 +111,9 @@ public function sendTestMail(int $groupId): string * @throws UserNotFound * @throws OAuthNotSet */ - private function sendForPayment(Payment $paymentRow, Group $group, EmailTemplate $template): void + private function sendForPayment(Payment $paymentRow, Group $group, EmailTemplate $template, string $userName): void { - $this->send($group, $this->createPayment($paymentRow), $template); + $this->send($group, $this->createPayment($paymentRow), $template, $userName); } /** @@ -118,7 +123,7 @@ private function sendForPayment(Payment $paymentRow, Group $group, EmailTemplate * @throws OAuthNotSet * @throws PaymentHasNoEmails */ - private function send(Group $group, MailPayment $payment, EmailTemplate $emailTemplate): void + private function send(Group $group, MailPayment $payment, EmailTemplate $emailTemplate, string $userName): void { if ($group->getOauthId() === null) { throw new OAuthNotSet(); @@ -128,14 +133,12 @@ private function send(Group $group, MailPayment $payment, EmailTemplate $emailTe throw PaymentHasNoEmails::withName($payment->getName()); } - $user = $this->users->getCurrentUser(); - $bankAccount = $group->getBankAccountId() !== null ? $this->bankAccounts->find($group->getBankAccountId()) : null; $bankAccountNumber = $bankAccount !== null ? (string) $bankAccount->getNumber() : null; - $emailTemplate = $emailTemplate->evaluate($group, $payment, $bankAccountNumber, $user->getName()); + $emailTemplate = $emailTemplate->evaluate($group, $payment, $bankAccountNumber, $userName); $template = $this->templateFactory->create( TemplateFactory::PAYMENT_DETAILS, diff --git a/app/model/Payment/PaymentService.php b/app/model/Payment/PaymentService.php index 6422ac5e1..bc818eda6 100644 --- a/app/model/Payment/PaymentService.php +++ b/app/model/Payment/PaymentService.php @@ -129,6 +129,7 @@ public function createGroup( array $emails, OAuthId|null $oAuthId, int|null $bankAccountId, + bool $remindersEnabled = false, ): int { $now = new DateTimeImmutable(); $bankAccount = $bankAccountId !== null ? $this->bankAccounts->find($bankAccountId) : null; @@ -144,6 +145,7 @@ public function createGroup( $bankAccount, $this->bankAccountAccessChecker, $this->oAuthAccessChecker, + $remindersEnabled, ); $this->groups->save($group); @@ -159,11 +161,12 @@ public function updateGroup( array $emails, OAuthId|null $oAuthId, int|null $bankAccountId, + bool $remindersEnabled = false, ): void { $group = $this->groups->find($id); $bankAccount = $bankAccountId !== null ? $this->bankAccounts->find($bankAccountId) : null; - $group->update($name, $paymentDefaults, $oAuthId, $bankAccount, $this->bankAccountAccessChecker, $this->oAuthAccessChecker); + $group->update($name, $paymentDefaults, $oAuthId, $bankAccount, $this->bankAccountAccessChecker, $this->oAuthAccessChecker, $remindersEnabled); foreach (EmailType::getAvailableValues() as $typeKey) { $type = EmailType::get($typeKey); diff --git a/app/model/Payment/Repositories/IGroupRepository.php b/app/model/Payment/Repositories/IGroupRepository.php index 2101adbd1..aaf6df955 100644 --- a/app/model/Payment/Repositories/IGroupRepository.php +++ b/app/model/Payment/Repositories/IGroupRepository.php @@ -22,6 +22,9 @@ public function find(int $id): Group; */ public function findByIds(array $ids): array; + /** @return Group[] */ + public function findByReminder(): array; + /** * @param int[] $unitIds * diff --git a/app/model/Payment/Repositories/IPaymentRepository.php b/app/model/Payment/Repositories/IPaymentRepository.php index 0cf592f6d..5449e0ada 100644 --- a/app/model/Payment/Repositories/IPaymentRepository.php +++ b/app/model/Payment/Repositories/IPaymentRepository.php @@ -17,6 +17,13 @@ public function find(int $id): Payment; /** @return Payment[] */ public function findByGroup(int $groupId): array; + /** + * @param int[] $groupIds + * + * @return Payment[] + */ + public function findByReminder(array $groupIds): array; + /** * @param int[] $groupIds * diff --git a/app/model/Travel/Exception/VehicleLinkedRecord.php b/app/model/Travel/Exception/VehicleLinkedRecord.php new file mode 100644 index 000000000..3343bf66f --- /dev/null +++ b/app/model/Travel/Exception/VehicleLinkedRecord.php @@ -0,0 +1,11 @@ +vehicles->findByFilter(); + } + + /** @throws VehicleLinkedRecord|VehicleNotFound */ + public function removeVehicle(int $vehicleId): void { if ($this->commands->countByVehicle($vehicleId) > 0) { - return false; //nelze mazat vozidlo s navazanými příkazy + throw new VehicleLinkedRecord('Cannot remove vehicle with linked commands'); } - try { - $vehicle = $this->vehicles->find($vehicleId); - - $this->vehicles->remove($vehicle); - - return true; - } catch (VehicleNotFound) { - return false; - } + $vehicle = $this->vehicles->find($vehicleId); + $this->vehicles->remove($vehicle); } /** diff --git a/app/templates/Default/about.latte b/app/templates/Default/about.latte index 5ea5ca30a..33ed9892b 100644 --- a/app/templates/Default/about.latte +++ b/app/templates/Default/about.latte @@ -7,25 +7,25 @@

Skautské hospodaření vzniklo na základě potřeby systematizovat vyúčtování víkendových výprav a tím minimalizovat počty chyb a nejasností s tím spojených. - Projekt vznikl v roce 2009 a od té doby je průběžne rozšiřován podle možností a potřeby. + Projekt vznikl v roce 2009 a od té doby je průběžně rozšiřován podle možností a potřeby.

- Na podzim roku 2016 se k rozvoji projektu připojil František Maša a díky tomu získal projekt více nových funkcí a profesionálnější programátorské zázemí. Který svoje aktivity omezil během 2022. + Na podzim roku 2016 se k rozvoji projektu připojil František Maša, díky kterému získal projekt více nových funkcí a profesionálnější programátorské zázemí. Ten svoje aktivity omezil během roku 2022.

- V průběhu 2023 se do vývoje zapojil Alex, který pomáhá s postupným rozvojem a Mlha, který se zaměřil na sekci vzdělávacích akcí. + V průběhu roku 2023 se do vývoje zapojil Alex, který pomáhá s postupným rozvojem, a Mlha, který se zaměřil na sekci vzdělávacích akcí.

K čemu slouží?

Hlavní myšlenkou je usnadnit práci hospodářům akcí, a proto svým rozsahem pokrývá pouze akce a nikoliv účetnictví celého střediska. Systém je napojen na SkautIS, díky čemuž se může přihlášený uživatel dostat k akcím a členům své jednotky a s nimi jednoduše dále pracovat. - Seznam účastníků lze vytořit jednoduše, právě díky napojení na SkautIS a pak stačí jen doplnit zaplacené účastnické příspěvky. + Seznam účastníků lze vytořit jednoduše, právě díky napojení na SkautIS, a pak stačí jen doplnit zaplacené účastnické příspěvky. Umožnuje vytvořit přehlednou pokladní knihu, která dokáže generovat druhotné pokladní doklady. Z celé akce lze na konci vygenerovat záverečnou zprávu.

Napsali o nás

@@ -34,7 +34,7 @@

Hledáním nových možností a starých chyb

- Projitím celého systému a sepsání nedostatků a nápadů na jeho vylepšení, třeba pomůžete ho posunout zase o kus dál. Ne všechny nápady a připomínky lze zapracovat ať z časových důvodů či z důvodu zvýšení složitosti systému. + Projitím celého systému a sepsáním nedostatků a nápadů na jeho vylepšení, třeba ho pomůžete posunout zase o kus dál. Ne všechny nápady a připomínky lze zapracovat, ať z časových důvodů či z důvodu zvýšení složitosti systému.

Pomocí při programování dalších částí

diff --git a/app/templates/Default/default.latte b/app/templates/Default/default.latte index 7ff5a78ed..cb1b2134b 100644 --- a/app/templates/Default/default.latte +++ b/app/templates/Default/default.latte @@ -35,7 +35,7 @@

Správa dokladů online

-

Jednoduše vložte skeny všech účtenek a faktur přímo k akci.

+

Jednoduše vložíte skeny všech účtenek a faktur přímo k akci.

@@ -56,7 +56,7 @@

Centralizace dat

-

všechna finanční data na jednom místě

+

Všechna finanční data na jednom místě

diff --git a/app/templates/Default/reinforcement.latte b/app/templates/Default/reinforcement.latte index 83318c44e..decf4eb90 100644 --- a/app/templates/Default/reinforcement.latte +++ b/app/templates/Default/reinforcement.latte @@ -1,4 +1,4 @@ -{block title}Posily pro skatského hospodaření{/block} +{block title}Posily pro skautské hospodaření{/block} {block #content} @@ -7,42 +7,43 @@

Posily

- Po 13 letech už mi dochází síly a hledám další posily. Celý kód je pod MIT licencí veřejný, tedy jak si projekt - rozjet najdeš na Githubu - a co je v plánu je v Issues. + Celý kód je pod MIT licencí veřejný, jak rozjet projekt + najdeš na Githubu + a co je v plánu, je v Issues.

Koho hledáme? Zapojit se lze různě. Můžeš programovat backend v Nette či pomoct s TypeScriptem na Frontendu.
- Rozsah práce? Práce je nekonečno, ale tady pomůže i realizace pár menších issues. + Rozsah práce? Práce je nekonečno, ale pomůže i realizace pár menších issues.

Nette vyvojář

- Celý projekt je v Nette a na migrační scripty používá Doctrine migrations. Nasazování je automatizované po merge requestu. + Celý projekt je v Nette a na migrační scripty používá Doctrine migrations. Nasazování je automatizované po merge requestu.
Kromě přidávání nových funkcí by určitě pomohlo aktualizovat knihovny, abychom drželi projekt bezpečný.

Frontend vývojář

- Na projektu jsou různé vychytávky psané v Typescriptu, tedy jejich správa a rozvoj. - Je možné přejít i na novější Bootstrap, ale nevidím v tom přidanou hodnotu nyní. + Na projektu jsou různé vychytávky psané v Typescriptu, tedy jejich správa a rozvoj.
+ Je možné přejít i na novější Bootstrap, ale nevidím v tom nyní přidanou hodnotu.

Uživatelská podpora (+ socialní sítě)

- Občas někdo potřebuje pomoct (1-2 za měsíc) a hodí se když to s ním vyřeší někdo jiný než programátor, - aby mohl svůj čas věnovat rozvoji kódu. Dlouhodobě nikdo nepíše návodu ani posty s vychytávkama a novinkama - na Facebook, což by rozšířilo o projektu povědomí. + Občas někdo potřebuje pomoct (1-2x za měsíc) a hodí se, když to s ním vyřeší někdo jiný než programátor, + aby mohl svůj čas věnovat rozvoji kódu.
+ Dlouhodobě nikdo nepíše návody ani posty s vychytávkami a novinkami + na Facebook, což by rozšířilo povědomí o projektu.

Donátor

Přispěním libovolné částky můžeš někomu zpříjemnit práci na projektu, kde vždy většina času bude dobrovolnická. - Seznam přispěvků najdeš zde + Seznam přispěvků najdeš zde.

Kontakt

- sinacek(zavinac)skaut(tecka)cz
+ hskauting(zavinac)skaut(tecka)cz
Facebook

diff --git a/composer.json b/composer.json index 369341698..03012b713 100644 --- a/composer.json +++ b/composer.json @@ -133,7 +133,7 @@ ], "coding-pretty": "vendor/bin/phpcbf app", "coding-standard": [ - "vendor/bin/phpcbf app", + "vendor/bin/phpcbf app tests", "vendor/bin/phpcs" ], "coding-standard-ci": "vendor/bin/phpcs", diff --git a/frontend/app.scss b/frontend/app.scss index 0a1ad7ac5..a274a776d 100644 --- a/frontend/app.scss +++ b/frontend/app.scss @@ -90,6 +90,11 @@ table.table a:hover { background-color: #000000; } +.visually-disabled { + opacity: 0.65; + cursor: not-allowed; +} + :root[data-bs-theme="dark"] { .bg-posily { --bs-bg-opacity: 0.2; diff --git a/frontend/app.ts b/frontend/app.ts index cc81b9cf8..afd4337c0 100644 --- a/frontend/app.ts +++ b/frontend/app.ts @@ -7,6 +7,8 @@ import './ts/checkAll'; import initializeAjax from './ts/ajax'; import './app.scss'; +import { HelpPanelModule } from './modules/HelpPanelModule'; + // Use czech language for dates moment.locale('cs'); @@ -15,5 +17,11 @@ document.addEventListener('DOMContentLoaded', () => { new DarkModeToggle('darkModeToggle'); new LogoutTimer('timer', 'timer-minutes'); dom.watch(); -}); + // Naše nová inicializace + if (document.getElementById('toggleHelpButton')) { + // Pokud tlačítko existuje, modul se inicializuje. + const helpPanel = new HelpPanelModule(); + helpPanel.initialize(); + } +}); \ No newline at end of file diff --git a/frontend/icons.ts b/frontend/icons.ts index 7813532e8..a3dca0796 100644 --- a/frontend/icons.ts +++ b/frontend/icons.ts @@ -63,6 +63,11 @@ library.add( fas.faThList, fas.faEllipsisV, fas.faUserGraduate, + fas.faPaperPlane, + fas.faPenRuler, + fas.faBell, + fas.faTrashCan, + fas.faBoxArchive, // Regular icons far.faCalendar, @@ -84,6 +89,9 @@ library.add( far.faUser, far.faQuestionCircle, far.faArrowAltCircleLeft, + far.faComment, + far.faPaperPlane, + far.faBell, // Brands fab.faGithub, diff --git a/frontend/modules/HelpPanelModule.ts b/frontend/modules/HelpPanelModule.ts new file mode 100644 index 000000000..410989eaa --- /dev/null +++ b/frontend/modules/HelpPanelModule.ts @@ -0,0 +1,54 @@ +export class HelpPanelModule { + // Privátní vlastnosti pro uložení referencí na DOM prvky. + // Budou nastaveny v konstruktoru. + private readonly toggleHelpButton: HTMLButtonElement | null; + private readonly leftPanel: HTMLDivElement | null; + private readonly rightPanel: HTMLDivElement | null; + + /** + * Konstruktor vyhledá všechny potřebné prvky na stránce a uloží si je. + */ + constructor() { + this.toggleHelpButton = document.getElementById('toggleHelpButton') as HTMLButtonElement; + this.leftPanel = document.getElementById('leftPanel') as HTMLDivElement; + this.rightPanel = document.getElementById('rightPanel') as HTMLDivElement; + } + + /** + * Inicializuje funkcionalitu modulu. + * Zkontroluje, zda byly všechny prvky nalezeny, a připojí event listener. + */ + public initialize(): void { + // Bezpečnostní kontrola, zda všechny prvky existují. + if (!this.toggleHelpButton || !this.leftPanel || !this.rightPanel) { + console.error('HelpPanelModule: Nepodařilo se najít všechny potřebné prvky (toggleHelpButton, leftPanel, rightPanel).'); + return; + } + + // Přidá posluchač události na kliknutí, který volá naši privátní metodu. + // Použití arrow funkce `() => ...` zajistí správný kontext `this`. + this.toggleHelpButton.addEventListener('click', () => this.toggleVisibility()); + } + + /** + * Privátní metoda, která se stará o samotnou logiku skrytí/zobrazení panelů. + */ + private toggleVisibility(): void { + // Tato kontrola je zde pro typovou jistotu TypeScriptu, aby věděl, + // že v této metodě nepracujeme s `null` hodnotami. + if (!this.rightPanel || !this.leftPanel || !this.toggleHelpButton) { + return; + } + + // Přepne viditelnost pravého panelu. + this.rightPanel.classList.toggle('d-none'); + + // Přepne šířku levého panelu. + this.leftPanel.classList.toggle('col-sm-6'); + this.leftPanel.classList.toggle('col-sm-12'); + + // Aktualizuje text na tlačítku. + const isHidden = this.rightPanel.classList.contains('d-none'); + this.toggleHelpButton.textContent = isHidden ? 'Zobrazit nápovědu' : 'Skrýt nápovědu'; + } +} \ No newline at end of file diff --git a/frontend/ts/DataGridExtension.ts b/frontend/ts/DataGridExtension.ts index ae8aee069..760a2c367 100644 --- a/frontend/ts/DataGridExtension.ts +++ b/frontend/ts/DataGridExtension.ts @@ -1,8 +1,18 @@ import najaInstance, {InteractionEvent} from 'naja'; export class DataGridExtension { + + private static groupHandlersBound = false; + public constructor(naja: typeof najaInstance) { naja.addEventListener('interaction', DataGridExtension.enableSortHistory); + if (!DataGridExtension.groupHandlersBound) { + DataGridExtension.groupHandlersBound = true; + document.addEventListener('change', DataGridExtension.onChange, true); + document.addEventListener('click', DataGridExtension.onShiftClick, true); // volitelné: shift-range + } + + } private static enableSortHistory(event: InteractionEvent): void { @@ -12,5 +22,77 @@ export class DataGridExtension { (event.options as any).history = true; } } + + // === Datagrid group actions === + private static onChange(e: Event): void { + const t = e.target as HTMLElement | null; + if (!t) return; + + // 1) řádkový checkbox → přepnout tlačítka/select + spočítat + const gridKey = t.getAttribute('data-check'); + if (gridKey) { + const checked = document.querySelectorAll(`input[data-check-all-${gridKey}]:checked`); + const select = document.querySelector(`.datagrid-${gridKey} select[name="group_action[group_action]"]`); + const buttons = document.querySelectorAll(`.datagrid-${gridKey} .row-group-actions *[type="submit"]`); + const counter = document.querySelector(`.datagrid-${gridKey} .datagrid-selected-rows-count`); + + const any = checked.length > 0; + buttons.forEach(b => { b.disabled = !any; }); + if (select) { + select.disabled = !any; + if (!any) select.value = ''; + } + if (counter) { + const total = document.querySelectorAll(`input[data-check-all-${gridKey}]`).length; + counter.innerHTML = any ? `${checked.length}/${total}` : ''; + } + + if (select) select.dispatchEvent(new Event('change', { bubbles: true })); + } + + // 2) master checkbox → (od)škrtnout všechny + vyvolat jejich change + const masterKey = t.getAttribute('data-check-all'); + if (masterKey && (t as HTMLInputElement).type === 'checkbox') { + const checked = (t as HTMLInputElement).checked; + const inputs = document.querySelectorAll(`input[type=checkbox][data-check-all-${masterKey}]`); + inputs.forEach(input => { + input.checked = checked; + input.dispatchEvent(new Event('change', { bubbles: true })); + }); + } + } + + // Volitelné: SHIFT výběr rozsahu mezi dvěma kliky + private static lastCheckboxCell: HTMLElement | null = null; + private static onShiftClick(e: MouseEvent): void { + const path = (e.composedPath?.() as HTMLElement[]) ?? []; + const cell = path.find((el: any) => el?.classList?.contains('col-checkbox')) as HTMLElement | undefined; + if (!cell) return; + + if (DataGridExtension.lastCheckboxCell && e.shiftKey) { + const currentRow = cell.closest('tr'); + const lastRow = DataGridExtension.lastCheckboxCell.closest('tr'); + const tbody = lastRow?.closest('tbody'); + if (!currentRow || !lastRow || !tbody) { + DataGridExtension.lastCheckboxCell = cell; + return; + } + + const rows = Array.from(tbody.querySelectorAll('tr')); + const i1 = rows.indexOf(lastRow); + const i2 = rows.indexOf(currentRow); + if (i1 >= 0 && i2 >= 0) { + const [from, to] = i1 < i2 ? [i1, i2] : [i2 + 1, i1]; + rows.slice(from, to).forEach(r => { + const cb = r.querySelector('.col-checkbox input[type=checkbox]'); + if (cb && !cb.checked) { + cb.checked = true; + cb.dispatchEvent(new Event('change', { bubbles: true })); + } + }); + } + } + DataGridExtension.lastCheckboxCell = cell; + } } diff --git a/migrations/2025/Version20251019194523.php b/migrations/2025/Version20251019194523.php new file mode 100644 index 000000000..6e594f5ba --- /dev/null +++ b/migrations/2025/Version20251019194523.php @@ -0,0 +1,31 @@ +addSql('ALTER TABLE pa_group ADD is_reminders_enabled TINYINT(1) DEFAULT 0 NOT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE pa_group DROP is_reminders_enabled'); + } +} diff --git a/tests/_data/acceptance_init.sql b/tests/_data/acceptance_init.sql index 674a29ea9..41c0c68cd 100644 --- a/tests/_data/acceptance_init.sql +++ b/tests/_data/acceptance_init.sql @@ -1,4 +1,12 @@ +-- google_oauth INSERT INTO google_oauth (id, unit_id, email, token, updated_at) -VALUES ('42288e92-27fb-453c-9904-36a7ebd14fe2', 27266, 'test@hospodareni.loc', '1//02yV7BM31saaQCgYIAPOOREPSNwF-L9Irbcw-iJEHRUnfxt2KULTjXQkPI-jl8LEN-SwVp6OybduZT21RiDf7RZBA4ZoZu86UXC8', '2017-06-15 00:00:00'); +VALUES ('42288e92-27fb-453c-9904-36a7ebd14fe2', 27266, 'test@hospodareni.loc', + '1//02yV7BM31saaQCgYIAPOOREPSNwF-L9Irbcw-iJEHRUnfxt2KULTjXQkPI-jl8LEN-SwVp6OybduZT21RiDf7RZBA4ZoZu86UXC8', + '2017-06-15 00:00:00') + ON DUPLICATE KEY UPDATE + unit_id = VALUES(unit_id), + email = VALUES(email), + token = VALUES(token), + updated_at = VALUES(updated_at); INSERT INTO pa_bank_account (name, unit_id, token, created_at, allowed_for_subunits, number_prefix, number_number, number_bank_code) VALUES ('Acceptance', 27266, NULL, '2017-08-24 00:00:00', 1, NULL, '2000942144', '2010'); diff --git a/tests/acceptance/CampCest.php b/tests/acceptance/CampCest.php new file mode 100644 index 000000000..1810cac8a --- /dev/null +++ b/tests/acceptance/CampCest.php @@ -0,0 +1,30 @@ +I = $I; + $I->login(AcceptanceTester::UNIT_LEADER_ROLE); + } + + public function createListCamp(): void + { + $I = $this->I; + $I->wantTo('List camps'); + $I->click('Tábory'); + $I->waitForText('Tábory'); + $I->selectOption('Rok', 2024); + } +} diff --git a/tests/acceptance/TravelCest.php b/tests/acceptance/TravelCest.php new file mode 100644 index 000000000..0de801a69 --- /dev/null +++ b/tests/acceptance/TravelCest.php @@ -0,0 +1,270 @@ +I = $I; + $I->login(AcceptanceTester::UNIT_LEADER_ROLE); + $this->licensePlate = 'RZ-' . time(); + } + + public function createTravelOrder(AcceptanceTester $I): void + { + $name = 'Porada s vedoucími'; + $unitRepresentative = 'Pavel Zástupce'; + $licensePlate = 'RZA-' . time(); + $I->wantTo('Create travel order'); + $this->navigateToVehicle($I); + $this->newVehicle($I, $licensePlate); + $this->navigationToContract($I); + $this->newContract($I, $unitRepresentative); + $this->navigateToTravelOrder($I); + $this->newTravelOrder($I, $name, $licensePlate); + $this->navigateToTravelOrder($I); + $this->deleteTravelOrder($I, $name); + $this->navigateToVehicle($I); + $this->deleteVehicle($I, $licensePlate); + $this->navigationToContract($I); + $this->deleteContract($I, $unitRepresentative); + } + + protected function navigateToTravelOrder(AcceptanceTester $I): void + { + $I->amOnPage('/'); + $I->click('Cesťáky'); + $I->seeInCurrentUrl('/cestaky'); + } + + protected function newTravelOrder(AcceptanceTester $I, string $name, string $licensePlate): void + { + $I->click('Založit cestovní příkaz'); + $I->waitForText('Založit cestovní příkaz'); + + $I->waitForElementVisible('#frm-form-form-purpose', 10); + + $I->fillField('#frm-form-form-purpose', $name); + $I->fillField('#frm-form-form-place', 'Praha'); + $I->fillField('#frm-form-form-fellowPassengers', 'Pepa Novák, Alena Malá'); + $I->fillField('#frm-form-form-note', 'Vzít materiál ze skladu'); + + $I->selectOption('#frm-form-form-type', ['car']); + $I->click(['xpath' => '//*[@id="frm-form-form-contract_id"]/option[@value!=""][1]']); + + $I->waitForElementNotVisible('#passengerName', 5); + $I->waitForElementNotVisible('#passengerContact', 5); + $I->waitForElementNotVisible('#passengerAddress', 5); + + // (Alternativa bez smlouvy – odkomentuj a vyplň) + /* + $I->selectOption('#frm-form-form-contract_id', ''); + $I->executeJS('document.getElementById("frm-form-form-contract_id").dispatchEvent(new Event("change",{bubbles:true}));'); + $I->waitForElementVisible('#passengerName', 5); + $I->fillField('#frm-form-form-passenger-name', 'Jan Novák'); + $I->fillField('#frm-form-form-passenger-contact', '777123456'); + $I->fillField('#frm-form-form-passenger-address', 'Ulice 1, Praha'); + */ + + // 5) Vozidlo – po výběru "car" jsou pole povinná + $I->waitForElementVisible('#frm-form-form-vehicle_id', 5); + $I->selectOption('#frm-form-form-vehicle_id', ['text' => 'Osobní (' . $licensePlate . ')']); + $I->fillField('#frm-form-form-fuel_price', '38.50'); + $I->fillField('#frm-form-form-amortization', '1.20'); + + // 6) Odeslání + $I->scrollTo('footer'); + $I->waitForElementVisible('[name=send]', 5); + $I->click('[name=send]'); + + // 7) Ověření (uprav dle app – flash zpráva / redirect / nadpis) + $I->waitForText('Cestovní příkaz byl založen', 10); + $I->seeInCurrentUrl('/cestaky'); + } + + protected function deleteTravelOrder(AcceptanceTester $I, string $name): void + { + $I->click($name); + $I->waitForText('Cestovní příkaz'); + $I->click('Smazat'); + try { + $I->acceptPopup(); + } catch (Throwable) { + } + + $I->waitForText('Cestovní příkaz byl smazán.'); + } + + public function createVehicle(AcceptanceTester $I): void + { + $I->wantTo('Create vehicle'); + $this->navigateToVehicle($I); + $this->newVehicle($I, $this->licensePlate); + $this->navigateToVehicle($I); + $this->checkVehicle($I, $this->licensePlate); + $this->navigateToVehicle($I); + $this->deleteVehicle($I, $this->licensePlate); + } + + protected function navigateToVehicle(AcceptanceTester $I): void + { + $I->amOnPage('/'); + $I->click('Cesťáky'); + $I->seeInCurrentUrl('/cestaky'); + $I->click('Vozidla'); + $I->seeInCurrentUrl('/cestaky/vozidla'); + } + + protected function newVehicle(AcceptanceTester $I, string $licensePlate): void + { + $I->click('Založit nové vozidlo'); + $I->see('Nové vozidlo'); + + $I->fillField(['id' => 'frm-formCreateVehicle-type'], $this->vehicleType); + $I->fillField(['id' => 'frm-formCreateVehicle-registration'], $licensePlate); + $I->fillField(['id' => 'frm-formCreateVehicle-consumption'], $this->harmonizedConsumption); + $I->selectOption(['id' => 'frm-formCreateVehicle-subunitId'], $this->division); + $I->click('Založit'); + $I->click('Založit nové vozidlo'); + $I->see('Nové vozidlo'); + + $I->fillField(['id' => 'frm-formCreateVehicle-type'], $this->vehicleType); + $I->fillField(['id' => 'frm-formCreateVehicle-registration'], $licensePlate); + $I->fillField(['id' => 'frm-formCreateVehicle-consumption'], $this->harmonizedConsumption); + $I->selectOption(['id' => 'frm-formCreateVehicle-subunitId'], $this->division); + $I->click('Založit'); + } + + protected function checkVehicle(AcceptanceTester $I, string $licensePlate): void + { + $I->see('Vozidla'); + $I->see($licensePlate); + $I->see($this->vehicleType); + $I->see($this->division); + $I->click('#frm-grid-grid-filter-filter-search'); + $I->fillField('#frm-grid-grid-filter-filter-search', 'AUV'); + $I->pressKey('#frm-grid-grid-filter-filter-search', [WebDriverKeys::ENTER]); + $I->wait(3); + $I->dontSee($licensePlate, '#snippet-grid-grid-table'); + $I->see('Nenalezeny žádné záznamy.', '#snippet-grid-grid-table'); + $I->click('#frm-grid-grid-filter-filter-search'); + $I->fillField('#frm-grid-grid-filter-filter-search', $licensePlate); + $I->pressKey('#frm-grid-grid-filter-filter-search', [WebDriverKeys::ENTER]); + $I->waitForText($licensePlate, 10, '#snippet-grid-grid-table'); + $I->wait(3); + $I->click($licensePlate, '#snippet-grid-grid-table'); + $I->waitForText('Údaje o vozidle'); + } + + protected function deleteVehicle(AcceptanceTester $I, string $licensePlate): void + { + $I->click($licensePlate, '#snippet-grid-grid-table'); + $I->waitForText('Údaje o vozidle'); + $I->click('Smazat vozidlo'); + try { + $I->acceptPopup(); + } catch (Throwable) { + } + + $I->waitForText('Vozidlo bylo odebráno.'); + } + + public function createContact(AcceptanceTester $I): void + { + $I->wantTo('Create contract'); + $this->navigationToContract($I); + $this->newContract($I, $this->unitRepresentative); + $this->navigationToContract($I); + $this->detailContract($I, $this->unitRepresentative); + $this->navigationToContract($I); + $this->deleteContract($I, $this->unitRepresentative); + } + + protected function navigationToContract(AcceptanceTester $I): void + { + $I->amOnPage('/'); + $I->click('Cesťáky'); + $I->seeInCurrentUrl('/cestaky'); + $I->click('Smlouvy'); + $I->seeInCurrentUrl('/cestaky/smlouvy'); + $I->waitForText('Smlouvy'); + } + + protected function newContract(AcceptanceTester $I, string $unitRepresentative): void + { + $I->click('Založit smlouvu'); + $I->waitForText('Nová smlouva o proplácení cestovních náhrad'); + $I->waitForElementVisible('#frm-formCreateContract', 10); + $I->fillField('#frm-formCreateContract-passengerName', 'Jan Novák'); + $I->fillField('#frm-formCreateContract-passengerAddress', 'Ulice 1, 100 00 Praha'); + $I->fillField('#frm-formCreateContract-passengerContact', '777123456'); + $I->fillField('#frm-formCreateContract-unitRepresentative', $unitRepresentative); + $I->fillField('#frm-formCreateContract-start', '18.10.2025'); + $I->click('#frm-formCreateContract-passengerBirthday'); + $I->fillField('#frm-formCreateContract-passengerBirthday', '01.01.1990'); + $I->pressKey('#frm-formCreateContract-passengerBirthday', [WebDriverKeys::TAB]); + $I->scrollTo('footer'); + $I->click('#frm-formCreateContract [name=send]'); + $I->waitForText('Smlouva byla založena.'); + $I->see($unitRepresentative); + } + + protected function detailContract(AcceptanceTester $I, string $unitRepresentative): void + { + $I->click(sprintf('[data-test="%s"]', $unitRepresentative)); + $I->waitForElementVisible('body', 10); + $I->see('Smlouva o proplácení cestovních náhrad'); + + // --- Vytisknout -> PDF ve stejném tabu --- + $I->waitForElementVisible('[data-test="contract-print"]', 10); + $urlBefore = $I->grabFromCurrentUrl(); + $I->click('[data-test="contract-print"]'); + + $I->waitForJS('return window.location.href !== ' . json_encode($urlBefore) . ';', 10); + $I->seeInCurrentUrl('/print'); + + $I->moveBack(); + + $I->waitForText('Údaje smlouvy', 10); + } + + protected function deleteContract(AcceptanceTester $I, string $unitRepresentative): void + { + $I->click(sprintf('[data-test="%s"]', $unitRepresentative)); + $I->waitForElementVisible('body', 10); + $I->see('Smlouva o proplácení cestovních náhrad'); + $I->waitForElementVisible('[data-test="contract-delete"]', 10); + // --- Smazat --- + $I->click('[data-test="contract-delete"]'); + try { + $I->acceptPopup(); + } catch (Throwable) { + } + + $I->waitForText('Smlouva byla smazána', 10); + $I->seeInCurrentUrl('/smlouvy'); + } +}