Compare commits

..

1 Commits

Author SHA1 Message Date
philipp lang 0724052313 Add skipping for yearly prevention
continuous-integration/drone/push Build is failing Details
2026-03-15 04:42:26 +01:00
38 changed files with 175 additions and 406 deletions

View File

@ -1,4 +1,4 @@
FROM php:8.3.36-fpm AS php FROM php:8.3.11-fpm AS php
WORKDIR /app WORKDIR /app
RUN ls /app RUN ls /app
RUN apt-get update RUN apt-get update

View File

@ -31,15 +31,10 @@ steps:
commands: commands:
- while ! curl --silent 'http://owncloudserver:8080/ocs/v1.php/cloud/capabilities?format=json' -u admin:admin | grep '"status":"ok"'; do sleep 1; done - while ! curl --silent 'http://owncloudserver:8080/ocs/v1.php/cloud/capabilities?format=json' -u admin:admin | grep '"status":"ok"'; do sleep 1; done
- name: node_submodules
image: node:20.15.0-slim
commands:
- cd packages/adrema-form && npm ci && npm run build-import && rm -R node_modules && cd ../../
- name: node - name: node
image: node:20.15.0-slim image: node:20.15.0-slim
commands: commands:
- npm ci && npm run img && npm run prod && rm -R node_modules - npm ci && cd packages/adrema-form && npm ci && npm run build && rm -R node_modules && cd ../../ && npm run img && npm run prod && rm -R node_modules
- name: tests - name: tests
image: zoomyboy/adrema-base:latest image: zoomyboy/adrema-base:latest
@ -146,7 +141,7 @@ steps:
- composer install --ignore-platform-reqs --no-dev - composer install --ignore-platform-reqs --no-dev
- name: compress - name: compress
image: php:8.3.36 image: php:8.1.6
commands: commands:
- apt-get update -yqq - apt-get update -yqq
- apt-get install -yqq zip tar - apt-get install -yqq zip tar

View File

@ -1,21 +1,5 @@
# Letzte Änderungen # Letzte Änderungen
### 1.12.29
- Veranstaltungen können nun "bereinigt", sprich alle TN gelöscht werden
### 1.12.27
- Bei Rechnungs-und Erinnerungsmails wird nun der Stammesname angezeigt. Zudm kann eine ReplyTo gesetzt werden, wenn gewünscht
### 1.12.26
- Felder im Form-Builder können nun verschoben und kopiert werden
### 1.12.25
- Ein Bug wurde behoben, sodass nun wieder Bedingungen für Formular-Mails vergeben werden können
### 1.12.24 ### 1.12.24
- Gruppen werden nun wöchentlich aus NaMi neu abgerufen - Gruppen werden nun wöchentlich aus NaMi neu abgerufen

View File

@ -1,57 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project
AdReMa ("AddressManagement") is a Laravel 11 + Inertia/Vue 3 app for German Scout groups (DPSG) to manage member data, courses, invoicing/contributions, and syncs with the external NaMi member database. Backend is PHP 8.3, frontend is Vue 3 + TypeScript rendered via Inertia (no separate SPA API).
## Commands
### PHP / backend
- Run all tests: `php artisan test` (or `vendor/bin/pest`)
- Run a single test file: `php artisan test tests/Feature/Member/SomeTest.php`
- Run a single test by name: `php artisan test --filter=test_name`
- Static analysis: `vendor/bin/phpstan analyse` (larastan, level 6, config in `phpstan.neon`)
- Test suites are split in `phpunit.xml`: `Unit`, `Feature`, `Fileshare`, `Arch` (architecture rules in `tests/Arch.php`), `NamiUnit` (packages/laravel-nami), `EndToEnd`
### JS / frontend
- Dev server: `npm run dev` (alias for `vite`)
- Production build: `npm run prod`
- Lint: `npm run lint`, autofix: `npm run fix`
- Rebuild SVG sprite: `npm run img`
- The `packages/adrema-form` package (public event-registration form, embeddable) has its own `npm run build` / `build-import` (import mode is used when embedded in the main app) and is built separately.
### Environment
- Local dev runs via Docker Compose (`docker-compose.yml`); `.app.env` holds environment config, copied from `.app.env.example`.
- Submodules must be initialized: `git submodule update --init`.
- CI (`.drone.yml`) runs: composer install → npm build (main app + `adrema-form`) → `php artisan migrate` → `php artisan test` → `vendor/bin/phpstan analyse`.
## Architecture
### Backend structure
Code under `app/` is organized by **domain module**, not by technical layer (no global `app/Http/Controllers` for most features). Each domain (e.g. `Member`, `Activity`, `Form`, `Invoice`, `Course`, `Fileshare`, `Contribution`, `Mailman`, `Mailgateway`, `Efz`, `Prevention`) typically contains its own `Actions/`, models, requests, and resources together. Older/shared code still lives in `app/Actions`.
- **Actions pattern**: business logic lives in single-purpose Action classes (`lorisleiva/laravel-actions`, `use AsAction`) with a `handle()` method, invoked directly from routes in `routes/web.php` rather than through traditional controllers.
- **NaMi integration**: `packages/laravel-nami` is a local Composer package (symlinked via `repositories` path in `composer.json`) wrapping the external NaMi API (members, courses, memberships, confessions, fees, regions, etc.). App-level `Pull*Action`/`Insert*Action` classes (`app/Actions/PullMemberAction.php`, `InsertMemberAction.php`, etc.) sync NaMi data into local models. - Please never edit files here, unless explicitly specified.
- **Local packages** (all under `packages/`, wired via Composer path repositories): `laravel-nami` (NaMi API client), `table-document` (PDF/table document generation), `flysystem-webdav` (WebDAV filesystem driver, used for cloud file storage/CardDAV-adjacent features), `tex` (LaTeX rendering, used for invoices/Bescheinigungen), `medialibrary-helper` (Vue components + backend helpers for file uploads, e.g. `FSinglefile`/`FMultiplefiles`), `adrema-form` (standalone Vite/Vue app for public event registration forms, built separately and embedded/imported into the main app). - Please never edit files here, unless explicitly specified
- **Modules feature flag**: `App\Module\Module` enum (`bill`, `course`, `event`) + `ModuleSettings` gate optional features per-installation; checked server-side via `hasModule()` and client-side via the `hasModule` Vue mixin (`resources/js/mixins/hasModule.js`).
- Data transfer uses `spatie/laravel-data` (`Data` classes) in several domains instead of plain arrays/DTOs.
- PHPStan type aliases for complex array shapes (e.g. contribution API payloads) are defined centrally in `phpstan.neon`.
### Frontend structure
- Entry point `resources/js/app.js` bootstraps Inertia + Pinia + Vue; pages are Vue SFCs under `resources/js/views/**`, resolved by path via Inertia's `resolve`.
- `resources/js/layouts/AppLayout.vue` is the default page layout, applied automatically unless a page sets its own `layout`.
- Global mixins (`hasModule`, `hasFlash`) are applied app-wide instead of per-component imports.
- State is managed with Pinia stores (`resources/js/stores`).
### Testing conventions
- Tests use Pest (`pestphp/pest`), with `tests/TestCase.php` / `tests/EndToEndTestCase.php` / `tests/FileshareTestCase.php` as base cases for different suites.
- `tests/RequestFactories` (worksome/request-factories) and `tests/Datasets` provide reusable test data/request builders.
- `tests/Arch.php` enforces architectural constraints via Pest's arch testing.

View File

@ -1,4 +1,4 @@
@servers(['docker' => ['stamm-silva@zoomyboy.de', 'stammgallier@stamm-gallier.de', 'dpsg-lennep@zoomyboy.de', 'dpsgbergischland@zoomyboy.de', 'dpsg@adrema.dpsg-koeln.de']]) @servers(['docker' => ['stamm-silva@zoomyboy.de', 'stammgallier@stamm-gallier.de', 'dpsg-lennep@zoomyboy.de', 'dpsgbergischland@zoomyboy.de', 'dpsg-koeln@dpsg-koeln.de']])
@task('deploy', ['on' => 'docker']) @task('deploy', ['on' => 'docker'])
cd $ADREMA_PATH cd $ADREMA_PATH

View File

@ -41,7 +41,7 @@ abstract class ConnectionType extends Data
{ {
$filesystem = $this->getFilesystem(); $filesystem = $this->getFilesystem();
return collect($filesystem->directories($parent ?: '/'))->sort()->values()->toArray(); return $filesystem->directories($parent ?: '/');
} }
/** /**

View File

@ -1,28 +0,0 @@
<?php
namespace App\Form\Actions;
use App\Form\Models\Form;
use App\Lib\Events\Succeeded;
use Illuminate\Http\JsonResponse;
use Lorisleiva\Actions\Concerns\AsAction;
class FormCleanupAction
{
use AsAction;
public function handle(Form $form): void
{
$form->participants()->get()->each->delete();
$form->save();
ClearFrontendCacheAction::run();
}
public function asController(Form $form): JsonResponse
{
$this->handle($form);
Succeeded::message('Teilnehmende gelöscht.')->dispatch();
return response()->json([]);
}
}

View File

@ -190,8 +190,7 @@ class Form extends Model implements HasMedia
return Sorting::from($this->meta['sorting']); return Sorting::from($this->meta['sorting']);
} }
public function isInDates(): bool public function isInDates(): bool {
{
if ($this->registration_from && $this->registration_from->gt(now())) { if ($this->registration_from && $this->registration_from->gt(now())) {
return false; return false;
} }

View File

@ -65,7 +65,6 @@ class FormResource extends JsonResource
'participant_root_index' => route('form.participant.index', ['form' => $this->getModel(), 'parent' => -1]), 'participant_root_index' => route('form.participant.index', ['form' => $this->getModel(), 'parent' => -1]),
'update' => route('form.update', $this->getModel()), 'update' => route('form.update', $this->getModel()),
'destroy' => route('form.destroy', $this->getModel()), 'destroy' => route('form.destroy', $this->getModel()),
'cleanup' => route('form.cleanup', $this->getModel()),
'is_dirty' => route('form.is-dirty', $this->getModel()), 'is_dirty' => route('form.is-dirty', $this->getModel()),
'frontend' => str(app(FormSettings::class)->registerUrl)->replace('{slug}', $this->slug), 'frontend' => str(app(FormSettings::class)->registerUrl)->replace('{slug}', $this->slug),
'export' => route('form.export', $this->getModel()), 'export' => route('form.export', $this->getModel()),

View File

@ -30,7 +30,7 @@ class MemberNewInvoiceAction
*/ */
public function handle(Member $member, Subscription $subscription, int $year): array public function handle(Member $member, Subscription $subscription, int $year): array
{ {
$invoice = Invoice::createForMember($member, collect([$member]), $year, $subscription); $invoice = Invoice::createForMember($member, Member::where('id', $member->id)->get(), $year, $subscription);
return [ return [
...$invoice->getAttributes(), ...$invoice->getAttributes(),

View File

@ -3,6 +3,8 @@
namespace App\Invoice; namespace App\Invoice;
use App\Invoice\Models\Invoice; use App\Invoice\Models\Invoice;
use App\Payment\Payment;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Zoomyboy\Tex\Document; use Zoomyboy\Tex\Document;
use Zoomyboy\Tex\Engine; use Zoomyboy\Tex\Engine;

View File

@ -18,7 +18,6 @@ class InvoiceSettings extends LocalSettings implements Storeable
public ?string $zip; public ?string $zip;
public ?string $iban; public ?string $iban;
public ?string $bic; public ?string $bic;
public ?string $replyTo;
public ?int $rememberWeeks; public ?int $rememberWeeks;
public static function group(): string public static function group(): string
@ -44,7 +43,6 @@ class InvoiceSettings extends LocalSettings implements Storeable
'iban' => $this->iban, 'iban' => $this->iban,
'bic' => $this->bic, 'bic' => $this->bic,
'rememberWeeks' => $this->rememberWeeks, 'rememberWeeks' => $this->rememberWeeks,
'replyTo' => $this->replyTo,
] ]
]; ];
} }
@ -66,7 +64,6 @@ class InvoiceSettings extends LocalSettings implements Storeable
'iban' => '', 'iban' => '',
'bic' => '', 'bic' => '',
'rememberWeeks' => '', 'rememberWeeks' => '',
'replyTo' => '',
]; ];
} }

View File

@ -2,7 +2,6 @@
namespace App\Invoice\Mails; namespace App\Invoice\Mails;
use App\Invoice\InvoiceSettings;
use App\Invoice\Models\Invoice; use App\Invoice\Models\Invoice;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailable;
@ -13,8 +12,6 @@ class BillMail extends Mailable
use Queueable; use Queueable;
use SerializesModels; use SerializesModels;
public InvoiceSettings $settings;
/** /**
* Create a new message instance. * Create a new message instance.
* *
@ -22,7 +19,6 @@ class BillMail extends Mailable
*/ */
public function __construct(public Invoice $invoice, public string $filename) public function __construct(public Invoice $invoice, public string $filename)
{ {
$this->settings = app(InvoiceSettings::class);
} }
/** /**
@ -34,7 +30,7 @@ class BillMail extends Mailable
{ {
return $this->markdown('mail.invoice.bill') return $this->markdown('mail.invoice.bill')
->attach($this->filename) ->attach($this->filename)
->when($this->settings->replyTo, fn ($mail) => $mail->replyTo($this->settings->replyTo)) ->replyTo('kasse@stamm-silva.de')
->subject('Rechnung | '.$this->settings->from_long); ->subject('Rechnung | DPSG Stamm Silva');
} }
} }

View File

@ -2,7 +2,6 @@
namespace App\Invoice\Mails; namespace App\Invoice\Mails;
use App\Invoice\InvoiceSettings;
use App\Invoice\Models\Invoice; use App\Invoice\Models\Invoice;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailable;
@ -13,8 +12,6 @@ class RememberMail extends Mailable
use Queueable; use Queueable;
use SerializesModels; use SerializesModels;
public InvoiceSettings $settings;
/** /**
* Create a new message instance. * Create a new message instance.
* *
@ -22,7 +19,6 @@ class RememberMail extends Mailable
*/ */
public function __construct(public Invoice $invoice, public string $filename) public function __construct(public Invoice $invoice, public string $filename)
{ {
$this->settings = app(InvoiceSettings::class);
} }
/** /**
@ -34,7 +30,7 @@ class RememberMail extends Mailable
{ {
return $this->markdown('mail.invoice.remember') return $this->markdown('mail.invoice.remember')
->attach($this->filename) ->attach($this->filename)
->when($this->settings->replyTo, fn ($mail) => $mail->replyTo($this->settings->replyTo)) ->replyTo('kasse@stamm-silva.de')
->subject('Zahlungserinnerung | '.$this->settings->from_long); ->subject('Zahlungserinnerung | DPSG Stamm Silva');
} }
} }

View File

@ -8,14 +8,15 @@ use App\Invoice\Enums\InvoiceStatus;
use App\Invoice\InvoiceDocument; use App\Invoice\InvoiceDocument;
use App\Invoice\InvoiceSettings; use App\Invoice\InvoiceSettings;
use App\Invoice\RememberDocument; use App\Invoice\RememberDocument;
use App\Invoice\Scopes\InvoiceFilterScope;
use App\Member\Member; use App\Member\Member;
use App\Payment\Subscription; use App\Payment\Subscription;
use Database\Factories\Invoice\Models\InvoiceFactory; use Database\Factories\Invoice\Models\InvoiceFactory;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Collection;
use Laravel\Scout\Searchable; use Laravel\Scout\Searchable;
use stdClass; use stdClass;
@ -46,7 +47,7 @@ class Invoice extends Model
/** /**
* @param Collection<int, Member> $members * @param Collection<int, Member> $members
*/ */
public static function createForMember(Member $member, Collection $members, int $year, ?Subscription $subscription = null): self public static function createForMember(Member $member, Collection $members, int $year, Subscription $subscription = null): self
{ {
$invoice = new self([ $invoice = new self([
'to' => [ 'to' => [

View File

@ -3,6 +3,7 @@
namespace App\Lib; namespace App\Lib;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
use Laravel\Scout\Builder; use Laravel\Scout\Builder;
use Spatie\LaravelData\Data; use Spatie\LaravelData\Data;
@ -40,4 +41,20 @@ abstract class ScoutFilter extends Data
{ {
return static::factory()->withoutMagicalCreation()->from($post ?: []); return static::factory()->withoutMagicalCreation()->from($post ?: []);
} }
/**
* @param Collection<int, string> $filter
* @param array<string, bool|null> $conditions
* @return Collection<int, string>
*/
public function switches(Collection $filter, array $conditions): Collection
{
foreach ($conditions as $field => $value) {
if ($value !== null) {
$filter->push($field . ' = ' . ($value ? 'true' : 'false'));
}
}
return $filter;
}
} }

View File

@ -48,6 +48,8 @@ class FilterScope extends ScoutFilter
public ?bool $hasBirthday = null, public ?bool $hasBirthday = null,
public ?bool $hasSvk = null, public ?bool $hasSvk = null,
public ?bool $hasVk = null, public ?bool $hasVk = null,
public ?bool $skipYearlyPrevention = null,
public ?bool $skipEventPrevention = null,
) {} ) {}
/** /**
@ -72,6 +74,7 @@ class FilterScope extends ScoutFilter
$this->search = $this->search ?: ''; $this->search = $this->search ?: '';
return Member::search($this->search, function ($engine, string $query, array $options) { return Member::search($this->search, function ($engine, string $query, array $options) {
/** @var Collection<int, string> */
$filter = collect([]); $filter = collect([]);
if ($this->hasFullAddress === true) { if ($this->hasFullAddress === true) {
@ -86,12 +89,12 @@ class FilterScope extends ScoutFilter
if ($this->hasBirthday === true) { if ($this->hasBirthday === true) {
$filter->push('birthday IS NOT NULL'); $filter->push('birthday IS NOT NULL');
} }
if ($this->hasSvk !== null) { $filter = $this->switches($filter, [
$filter->push('has_svk = ' . ($this->hasSvk ? 'true' : 'false')); 'skip_yearly_prevention' => $this->skipYearlyPrevention,
} 'skip_event_prevention' => $this->skipEventPrevention,
if ($this->hasVk !== null) { 'has_vk' => $this->hasVk,
$filter->push('has_vk = ' . ($this->hasVk ? 'true' : 'false')); 'has_svk' => $this->hasSvk,
} ]);
if ($this->ausstand === true) { if ($this->ausstand === true) {
$filter->push('ausstand > 0'); $filter->push('ausstand > 0');
} }

View File

@ -79,6 +79,8 @@ class Member extends Model implements Geolocatable, Preventable
'nami_id' => 'integer', 'nami_id' => 'integer',
'has_svk' => 'boolean', 'has_svk' => 'boolean',
'has_vk' => 'boolean', 'has_vk' => 'boolean',
'skip_yearly_prevention' => 'boolean',
'skip_event_prevention' => 'boolean',
'multiply_pv' => 'boolean', 'multiply_pv' => 'boolean',
'multiply_more_pv' => 'boolean', 'multiply_more_pv' => 'boolean',
'is_leader' => 'boolean', 'is_leader' => 'boolean',
@ -597,6 +599,8 @@ class Member extends Model implements Geolocatable, Preventable
'group_name' => $this->group->inner_name ?: $this->group->name, 'group_name' => $this->group->inner_name ?: $this->group->name,
'has_vk' => $this->has_vk, 'has_vk' => $this->has_vk,
'has_svk' => $this->has_svk, 'has_svk' => $this->has_svk,
'skip_yearly_prevention' => $this->skip_yearly_prevention,
'skip_event_prevention' => $this->skip_event_prevention,
'links' => [ 'links' => [
'show' => route('member.show', ['member' => $this], false), 'show' => route('member.show', ['member' => $this], false),
'edit' => route('member.edit', ['member' => $this], false), 'edit' => route('member.edit', ['member' => $this], false),

View File

@ -26,7 +26,7 @@ class YearlyRememberAction
return; return;
} }
foreach ($settings->yearlyMemberFilter->getQuery()->get() as $member) { foreach ($settings->getYearlyMemberFilter()->getQuery()->get() as $member) {
// @todo add this check to FilterScope // @todo add this check to FilterScope
if ($member->getMailRecipient() === null) { if ($member->getMailRecipient() === null) {
continue; continue;

View File

@ -53,4 +53,11 @@ class PreventionSettings extends LocalSettings
'replyToMail' => $this->replyToMail, 'replyToMail' => $this->replyToMail,
]; ];
} }
public function getYearlyMemberFilter(): FilterScope
{
$this->yearlyMemberFilter->skipYearlyPrevention = false;
return $this->yearlyMemberFilter;
}
} }

View File

@ -138,10 +138,10 @@ return [
'key' => env('MEILI_MASTER_KEY', null), 'key' => env('MEILI_MASTER_KEY', null),
'index-settings' => [ 'index-settings' => [
Member::class => [ Member::class => [
'filterableAttributes' => ['address', 'birthday', 'ausstand', 'bill_kind', 'group_id', 'memberships', 'has_vk', 'has_svk', 'id'], 'filterableAttributes' => ['address', 'birthday', 'ausstand', 'bill_kind', 'group_id', 'memberships', 'has_vk', 'has_svk', 'id', 'skip_yearly_prevention', 'skip_event_prevention'],
'searchableAttributes' => ['fullname', 'address'], 'searchableAttributes' => ['fullname', 'address'],
'sortableAttributes' => ['lastname', 'firstname'], 'sortableAttributes' => ['lastname', 'firstname'],
'displayedAttributes' => ['age_group_icon', 'group_name', 'links', 'is_leader', 'lastname', 'firstname', 'fullname', 'address', 'ausstand', 'birthday', 'id', 'memberships', 'bill_kind', 'group_id'], 'displayedAttributes' => ['age_group_icon', 'group_name', 'links', 'is_leader', 'lastname', 'firstname', 'fullname', 'address', 'ausstand', 'birthday', 'id', 'memberships', 'bill_kind', 'group_id', 'skip_yearly_prevention', 'skip_event_prevention', 'has_vk', 'has_svk'],
'pagination' => [ 'pagination' => [
'maxTotalHits' => 1000000, 'maxTotalHits' => 1000000,
] ]

View File

@ -39,6 +39,8 @@ class MemberFactory extends Factory
'keepdata' => false, 'keepdata' => false,
'has_svk' => $this->faker->boolean(), 'has_svk' => $this->faker->boolean(),
'has_vk' => $this->faker->boolean(), 'has_vk' => $this->faker->boolean(),
'skip_yearly_prevention' => false,
'skip_event_prevention' => false,
]; ];
} }

View File

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('members', function (Blueprint $table) {
$table->boolean('skip_yearly_prevention')->default(false);
$table->boolean('skip_event_prevention')->default(false);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('members', function (Blueprint $table) {
$table->dropColumn('skip_yearly_prevention');
$table->dropColumn('skip_event_prevention');
});
}
};

View File

@ -1,11 +0,0 @@
<?php
use Spatie\LaravelSettings\Migrations\SettingsMigration;
return new class extends SettingsMigration
{
public function up(): void
{
$this->migrator->add('bill.replyTo', '');
}
};

View File

@ -30,7 +30,10 @@ export default typescriptEslint.config(
'vue/multi-word-component-names': 'off', 'vue/multi-word-component-names': 'off',
'vue/max-attributes-per-line': 'off', 'vue/max-attributes-per-line': 'off',
'vue/singleline-html-element-content-newline': 'off', 'vue/singleline-html-element-content-newline': 'off',
'vue/first-attribute-linebreak': 'off', "vue/first-attribute-linebreak": ["error", {
"singleline": "beside",
"multiline": "beside"
}],
'vue/no-undef-properties': ['error', { 'vue/no-undef-properties': ['error', {
'ignores': ['/^\\$/'] 'ignores': ['/^\\$/']
}] }]

@ -1 +1 @@
Subproject commit 010825124f7791dfc0dc607e23ec99f49be8aef5 Subproject commit a4a2a2b3fd7a099cf1e5a9360d5d5450030bf673

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="height: 512px; width: 512px;"><g class="" transform="translate(0,0)" style=""><path d="m153.654 18 52.57 134.734c1.698 3.994 4.05 5.83 7.243 6.977 3.2 1.15 7.36 1.2 11.058.17 3.698-1.03 6.71-3.146 7.996-4.915 1.288-1.77 1.634-2.564.505-5.24l-.046-.112L181.57 18h-27.916zm94.168 120.143 1.88 4.81-.09-.223c3.346 7.937 1.828 16.822-2.532 22.82-4.36 5.996-10.773 9.734-17.723 11.67-6.95 1.937-14.653 2.065-21.98-.57-7.327-2.634-14.155-8.447-17.742-16.923l-.05-.118-1.757-4.5c-31.31 19.804-42.47 42.026-35.367 68.89 1.24 4.681 3.422 12.364 5.964 22.13 74.37-5.274 139.945-23.872 199.808-51.6-10.297-13.867-22.5-25.83-38.232-34.53-20.505-11.34-47.652-20.157-72.178-21.857zm120.557 71.52c-61.497 28.81-129.173 48.378-205.575 54.196 2.03 8.683 4.08 18.28 5.95 28.495 89.592-10.084 163.043-26.22 217.755-48.767-5.743-11.72-11.593-23.19-18.13-33.924zm26.04 50.16c-57.093 23.772-131.99 40.087-222.73 50.322C180.697 371.423 179.614 446.752 128 480c16.27 0 31.892-.152 46.926-.45 17.84-25.554 31.27-66.222 32.08-86.146 8.27 16.793 3.297 59.32-5.36 85.434 2.735-.093 5.435-.193 8.127-.297 11.824-12.397 11.724-28.632 14.72-47.284 3.324 14.92 7 32.967 9.505 46.156 11.273-.616 22.152-1.34 32.606-2.183 16.38-20.358 21.65-49.604 18.63-85.48 4.226 29.1 9.116 62.138 11.873 82.55a771.724 771.724 0 0 0 27.807-3.614c5.04-18.787-4.1-48.444-2.072-69.54 11.123 43.113 22.247 55.45 33.37 64.043a456.166 456.166 0 0 0 15.733-3.526c-4.7-13.95 1.573-22.497 1.18-39.986 5.647 18.99 14.625 26.958 24.428 32.816 6.506-2.1 12.66-4.336 18.492-6.697-10.538-6.57-10.113-26.374-12.38-42.926 5.954 21.703 14.413 32.418 24.083 37.816 29.124-13.8 48.69-31.534 60.398-53.657-9.078-3.82-18.674-13.002-28.068-20.092 13.214 7.477 23.684 10.614 32.37 10.93a112.386 112.386 0 0 0 3.552-9.868c-56.326-19.528-80.07-64.018-101.58-108.178z" fill="#fff" fill-opacity="1"></path></g></svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

View File

@ -23,54 +23,33 @@
<div v-show="active === 0" class="grid grid-cols-4 gap-3"> <div v-show="active === 0" class="grid grid-cols-4 gap-3">
<div class="flex space-x-3 col-span-2"> <div class="flex space-x-3 col-span-2">
<f-text id="name" v-model="single.name" class="grow" label="Name" required /> <f-text id="name" v-model="single.name" class="grow" label="Name" required />
<f-switch <f-switch id="is_active" v-model="single.is_active" name="is_active" label="Aktiv" hint="Inaktive Veranstaltungen werden außerhalb von Adrema wie nicht existierende Veranstaltungen betrachtet. Insbesondere ist eine Anmeldung dann nicht möglich und die Veranstaltung erscheint auch nicht in der Veranstaltungs-Übersicht." />
id="is_active" <f-switch id="is_private" v-model="single.is_private" name="is_private" label="Privat" hint="Ist eine Veranstaltung privat, so wird diese nicht auf der Website angezeigt. Eine Anmeldung ist jedoch trotzdem möglich, wenn man über den Anmelde-Link verfügt." />
v-model="single.is_active"
name="is_active"
label="Aktiv"
hint="Inaktive Veranstaltungen werden außerhalb von Adrema wie nicht existierende Veranstaltungen betrachtet. Insbesondere ist eine Anmeldung dann nicht möglich und die Veranstaltung erscheint auch nicht in der Veranstaltungs-Übersicht."
/>
<f-switch
id="is_private"
v-model="single.is_private"
name="is_private"
label="Privat"
hint="Ist eine Veranstaltung privat, so wird diese nicht auf der Website angezeigt. Eine Anmeldung ist jedoch trotzdem möglich, wenn man über den Anmelde-Link verfügt."
/>
</div> </div>
<f-singlefile <f-singlefile id="header_image"
id="header_image" v-model="single.header_image"
v-model="single.header_image" class="col-span-2"
class="col-span-2" label="Bild"
label="Bild" name="header_image"
name="header_image" parent-name="form"
parent-name="form" :parent-id="single.id"
:parent-id="single.id" collection="headerImage"
collection="headerImage" required
required
/> />
<f-text id="from" v-model="single.from" type="date" label="Von" required /> <f-text id="from" v-model="single.from" type="date" label="Von" required />
<f-text id="to" v-model="single.to" type="date" label="Bis" required /> <f-text id="to" v-model="single.to" type="date" label="Bis" required />
<f-text id="zip" v-model="single.zip" label="PLZ" /> <f-text id="zip" v-model="single.zip" label="PLZ" />
<f-text id="location" v-model="single.location" label="Ort" /> <f-text id="location" v-model="single.location" label="Ort" />
<f-select id="country" v-model="single.country" class="col-span-2" name="country" label="Land" :options="meta.countries" /> <f-select id="country" v-model="single.country" class="col-span-2" name="country" label="Land" :options="meta.countries" />
<f-text <f-text id="registration_from" v-model="single.registration_from" type="datetime-local" label="Registrierung von" hint="Ist eine Anmeldung laut dieser zwei Datumsangaben möglich, kann man sich anmelden. Andernfalls wird die Veranstaltung (mit Beschreibungstext) auf der Übersichtsseite angezeigt, man kommt allerdings nicht zum Anmeldeformular." required />
id="registration_from"
v-model="single.registration_from"
type="datetime-local"
label="Registrierung von"
hint="Ist eine Anmeldung laut dieser zwei Datumsangaben möglich, kann man sich anmelden. Andernfalls wird die Veranstaltung (mit Beschreibungstext) auf der Übersichtsseite angezeigt, man kommt allerdings nicht zum Anmeldeformular."
required
/>
<f-text id="registration_until" v-model="single.registration_until" type="datetime-local" label="Registrierung bis" required /> <f-text id="registration_until" v-model="single.registration_until" type="datetime-local" label="Registrierung bis" required />
<f-textarea <f-textarea id="excerpt"
id="excerpt" v-model="single.excerpt"
v-model="single.excerpt" hint="Gebe hier eine kurze Beschreibung für die Veranstaltungs-Übersicht ein (Maximal 130 Zeichen)."
hint="Gebe hier eine kurze Beschreibung für die Veranstaltungs-Übersicht ein (Maximal 130 Zeichen)." label="Auszug"
label="Auszug" :rows="5"
:rows="5" class="col-span-full"
class="col-span-full" required
required
/> />
</div> </div>
<div v-if="active === 1"> <div v-if="active === 1">
@ -82,33 +61,32 @@
</div> </div>
<div v-show="active === 3" class="grid grid-cols-[1fr_300px] gap-3"> <div v-show="active === 3" class="grid grid-cols-[1fr_300px] gap-3">
<ui-note class="mt-2 col-span-full"> <ui-note class="mt-2 col-span-full">
Hier kannst du die E-Mail anpassen, die nach der Anmeldung an den Teilnehmer verschickt wird.<br /> Hier kannst du die E-Mail anpassen, die nach der Anmeldung an den Teilnehmer verschickt wird.<br>
Es gibt dafür einen ersten E-Mail-Teil und einen zweiten E-Mail-Teil. Dazwischen werden die Daten des Teilnehmers aufgelistet.<br /> Es gibt dafür einen ersten E-Mail-Teil und einen zweiten E-Mail-Teil. Dazwischen werden die Daten des Teilnehmers aufgelistet.<br>
Die Anrede ("Hallo Max Mustermann") wird automatisch an den Anfang gesetzt.<br /> Die Anrede ("Hallo Max Mustermann") wird automatisch an den Anfang gesetzt.<br>
Außerdem kannst du Dateien hochladen, die automatisch mit angehangen werden. Außerdem kannst du Dateien hochladen, die automatisch mit angehangen werden.
</ui-note> </ui-note>
<div> <div>
<ui-tabs v-model="activeMailTab" :entries="mailTabs" /> <ui-tabs v-model="activeMailTab" :entries="mailTabs" />
<f-editor v-if="activeMailTab === 0" id="mail_top" v-model="single.mail_top" name="mail_top" label="E-Mail-Teil 1" :rows="8" conditions required> <f-editor v-if="activeMailTab === 0" id="mail_top" v-model="single.mail_top" name="mail_top" label="E-Mail-Teil 1" :rows="8" conditions required>
<template #conditions="{data, resolve}"> <template #conditions="{cData, resolve}">
<conditions-form id="mail_top_conditions" :single="single" :value="data" @save="resolve" /> <conditions-form id="mail_top_conditions" :single="single" :value="cData" @save="resolve" />
</template> </template>
</f-editor> </f-editor>
<f-editor v-if="activeMailTab === 1" id="mail_bottom" v-model="single.mail_bottom" name="mail_bottom" label="E-Mail-Teil 2" :rows="8" conditions required> <f-editor v-if="activeMailTab === 1" id="mail_bottom" v-model="single.mail_bottom" name="mail_bottom" label="E-Mail-Teil 2" :rows="8" conditions required>
<template #conditions="{data, resolve}"> <template #conditions="{d, resolve}">
<conditions-form id="mail_bottom_conditions" :single="single" :value="data" @save="resolve" /> <conditions-form id="mail_bottom_conditions" :single="single" :value="d" @save="resolve" />
</template> </template>
</f-editor> </f-editor>
</div> </div>
<f-multiplefiles <f-multiplefiles id="mailattachments"
id="mailattachments" v-model="single.mailattachments"
v-model="single.mailattachments" label="Anhänge"
label="Anhänge" name="mailattachments"
name="mailattachments" parent-name="form"
parent-name="form" :parent-id="single.id"
:parent-id="single.id" collection="mailattachments"
collection="mailattachments" class="row-span-2"
class="row-span-2"
> >
<template #buttons="{file, buttonClass, iconClass}"> <template #buttons="{file, buttonClass, iconClass}">
<a v-tooltip="`Bedingungen`" href="#" :class="[buttonClass, 'bg-blue-200', 'relative']" @click.prevent="fileSettingPopup = file"> <a v-tooltip="`Bedingungen`" href="#" :class="[buttonClass, 'bg-blue-200', 'relative']" @click.prevent="fileSettingPopup = file">
@ -127,12 +105,11 @@
</div> </div>
<div v-show="active === 5" class="grid grid-cols-2 gap-3"> <div v-show="active === 5" class="grid grid-cols-2 gap-3">
<f-switch id="needs_prevention" v-model="single.needs_prevention" name="needs_prevention" label="Prävention" /> <f-switch id="needs_prevention" v-model="single.needs_prevention" name="needs_prevention" label="Prävention" />
<f-editor <f-editor id="prevention_text"
id="prevention_text" v-model="single.prevention_text"
v-model="single.prevention_text" hint="Wird an die Präventions-Email angehangen, die Teilnehmende dieser Veranstaltung erhalten"
hint="Wird an die Präventions-Email angehangen, die Teilnehmende dieser Veranstaltung erhalten" :rows="6"
:rows="6" label="Präventions-Hinweis"
label="Präventions-Hinweis"
/> />
<ui-box heading="Bedingung für Präventions-Unterlagen"> <ui-box heading="Bedingung für Präventions-Unterlagen">
<conditions id="prevention_conditions" v-model="single.prevention_conditions" :single="single" /> <conditions id="prevention_conditions" v-model="single.prevention_conditions" :single="single" />
@ -205,8 +182,7 @@
<ui-action-button tooltip="Nachmelde-Link kopieren" class="btn-info" icon="externallink" @click="copyLaterLink(form)" /> <ui-action-button tooltip="Nachmelde-Link kopieren" class="btn-info" icon="externallink" @click="copyLaterLink(form)" />
<ui-action-button tooltip="Zuschuss-Liste erstellen" class="btn-info" icon="contribution" @click="onGenerateContribution(form)" /> <ui-action-button tooltip="Zuschuss-Liste erstellen" class="btn-info" icon="contribution" @click="onGenerateContribution(form)" />
<ui-action-button :href="form.links.export" target="_BLANK" tooltip="als Tabellendokument exportieren" class="btn-info" icon="document" /> <ui-action-button :href="form.links.export" target="_BLANK" tooltip="als Tabellendokument exportieren" class="btn-info" icon="document" />
<ui-action-button tooltip="alle Teilnehmende löschen" class="btn-danger" icon="broom" @click.prevent="onCleanup(form)" /> <ui-action-button tooltip="Löschen" class="btn-danger" icon="trash" @click.prevent="onDelete(form)" />
<ui-action-button tooltip="Veranstaltung löschen" class="btn-danger" icon="trash" @click.prevent="onDelete(form)" />
</div> </div>
</td> </td>
</tr> </tr>
@ -219,19 +195,19 @@
</template> </template>
<script lang="js" setup> <script lang="js" setup>
import {ref, inject, computed} from 'vue'; import { ref, inject, computed } from 'vue';
import {indexProps, useIndex} from '../../composables/useInertiaApiIndex.js'; import { indexProps, useIndex } from '../../composables/useInertiaApiIndex.js';
import FormBuilder from '../formtemplate/FormBuilder.vue'; import FormBuilder from '../formtemplate/FormBuilder.vue';
import Participants from './Participants.vue'; import Participants from './Participants.vue';
import Conditions from './Conditions.vue'; import Conditions from './Conditions.vue';
import ConditionsForm from './ConditionsForm.vue'; import ConditionsForm from './ConditionsForm.vue';
import {useToast} from 'vue-toastification'; import { useToast } from 'vue-toastification';
import useSwal from '@/stores/swalStore.ts'; import useSwal from '@/stores/swalStore.ts';
import useDownloads from '@/composables/useDownloads.ts'; import useDownloads from '@/composables/useDownloads.ts';
import useClipboard from 'vue-clipboard3'; import useClipboard from 'vue-clipboard3';
const props = defineProps(indexProps); const props = defineProps(indexProps);
const {meta, data, reloadPage, reload, create, single, edit, cancel, submit, remove, getFilter, setFilter} = useIndex(props.data, 'form'); const { meta, data, reloadPage, reload, create, single, edit, cancel, submit, remove, getFilter, setFilter } = useIndex(props.data, 'form');
const axios = inject('axios'); const axios = inject('axios');
const toast = useToast(); const toast = useToast();
const {download} = useDownloads(); const {download} = useDownloads();
@ -242,8 +218,8 @@ const fileSettingPopup = ref(null);
const active = ref(0); const active = ref(0);
const activeMailTab = ref(0); const activeMailTab = ref(0);
const tabs = [{title: 'Allgemeines'}, {title: 'Beschreibung'}, {title: 'Formular'}, {title: 'Bestätigungs-E-Mail'}, {title: 'Export'}, {title: 'Prävention'}, {title: 'Zuschüsse'}]; const tabs = [{ title: 'Allgemeines' }, { title: 'Beschreibung' }, { title: 'Formular' }, { title: 'Bestätigungs-E-Mail' }, { title: 'Export' }, { title: 'Prävention' }, {title: 'Zuschüsse'}];
const mailTabs = [{title: 'vor Daten'}, {title: 'nach Daten'}]; const mailTabs = [{ title: 'vor Daten' }, { title: 'nach Daten' }];
const swal = useSwal(); const swal = useSwal();
const allFields = computed(() => { const allFields = computed(() => {
@ -254,7 +230,7 @@ const allFields = computed(() => {
const result = []; const result = [];
single.value.config.sections.forEach((section) => { single.value.config.sections.forEach((section) => {
section.fields.forEach((field) => { section.fields.forEach((field) => {
result.push({id: field.key, name: field.name}); result.push({ id: field.key, name: field.name });
}); });
}); });
@ -262,28 +238,21 @@ const allFields = computed(() => {
}); });
async function onCopy(form) { async function onCopy(form) {
await swal.confirm( await swal.confirm('Diese Veranstaltung kopieren?', 'Nach dem Kopieren wird die Veranstaltung auf inaktiv gesetzt. Bitte aktiviere den Filter "inaktive zeigen", um die kopierte Veranstaltung zu sehen.');
'Diese Veranstaltung kopieren?',
'Nach dem Kopieren wird die Veranstaltung auf inaktiv gesetzt. Bitte aktiviere den Filter "inaktive zeigen", um die kopierte Veranstaltung zu sehen.',
);
await axios.post(form.links.copy, {}); await axios.post(form.links.copy, {});
reload(false); reload(false);
} }
async function onGenerateContribution(form) { async function onGenerateContribution(form) {
const response = await swal.ask( const response = await swal.ask('Zuschussliste erstellen', 'Hiermit erstellst du eine Zuschussliste mit allen angemeldeten Mitgliedern. Bite wähle aus, für welche Organisation du eine Liste erstellen willst.', [
'Zuschussliste erstellen', {
'Hiermit erstellst du eine Zuschussliste mit allen angemeldeten Mitgliedern. Bite wähle aus, für welche Organisation du eine Liste erstellen willst.', name: 'type',
[ label: 'Organisation',
{ required: true,
name: 'type', type: 'select',
label: 'Organisation', options: meta.value.contribution_types,
required: true, }
type: 'select', ]);
options: meta.value.contribution_types,
},
],
);
await download(form.links.contribution, {type: response.type, validate: '1'}); await download(form.links.contribution, {type: response.type, validate: '1'});
await download(form.links.contribution, {type: response.type}); await download(form.links.contribution, {type: response.type});
} }
@ -293,12 +262,6 @@ async function onDelete(form) {
await remove(form); await remove(form);
} }
async function onCleanup(form) {
await swal.confirm('Teilnehmende löschen?', `Alle Teilnehmenden der Veranstaltung ${form.name} werden unwiderruflich gelöscht. Die Veranstaltung selbst bleibt erhalten.`);
await axios.delete(form.links.cleanup);
reload(false);
}
function setTemplate(template) { function setTemplate(template) {
active.value = 0; active.value = 0;
single.value.config = template.config; single.value.config = template.config;

View File

@ -11,17 +11,6 @@
<f-text id="sectionform-name" v-model="singleSection.model.name" label="Name"></f-text> <f-text id="sectionform-name" v-model="singleSection.model.name" label="Name"></f-text>
<f-textarea id="sectionform-intro" v-model="singleSection.model.intro" label="Einleitung"></f-textarea> <f-textarea id="sectionform-intro" v-model="singleSection.model.intro" label="Einleitung"></f-textarea>
</asideform> </asideform>
<asideform
v-if="moving !== null"
heading="Feld verschieben nach Sektion …"
@close="moving = null"
>
<div class="mt-3 grid gap-3">
<a v-for="(section, index) in modelValue.sections" :key="index" class="py-2 px-3 border rounded bg-zinc-800 hover:bg-zinc-700 transition" href="#" @click.prevent="moveFieldToSection(index)">
<span v-text="section.name"></span>
</a>
</div>
</asideform>
<asideform v-if="singleSection !== null && singleSection.mode === 'reorder'" heading="Felder ordnen" @close="singleSection = null" @submit="storeSection"> <asideform v-if="singleSection !== null && singleSection.mode === 'reorder'" heading="Felder ordnen" @close="singleSection = null" @submit="storeSection">
<draggable v-model="singleSection.model.fields" item-key="key" :component-data="{class: 'mt-3 grid gap-3'}"> <draggable v-model="singleSection.model.fields" item-key="key" :component-data="{class: 'mt-3 grid gap-3'}">
<template #item="{element}"> <template #item="{element}">
@ -70,8 +59,6 @@
@editFields="startReordering($event.detail[0])" @editFields="startReordering($event.detail[0])"
@deleteSection="deleteSection($event.detail[0])" @deleteSection="deleteSection($event.detail[0])"
@active="updateActive($event.detail[0])" @active="updateActive($event.detail[0])"
@copy-field="copyField($event.detail[0], $event.detail[1])"
@move-field="moving = {section: $event.detail[0], field: $event.detail[1]}"
></event-form> ></event-form>
</ui-box> </ui-box>
</form> </form>
@ -80,7 +67,7 @@
<script lang="js" setup> <script lang="js" setup>
import { watch, computed, ref } from 'vue'; import { watch, computed, ref } from 'vue';
import { snakeCase } from 'change-case'; import { snakeCase } from 'change-case';
import '!/adrema-form/dist/assets/main.js'; import '!/adrema-form/dist/main.js';
import Asideform from './Asideform.vue'; import Asideform from './Asideform.vue';
import TextareaField from './TextareaField.vue'; import TextareaField from './TextareaField.vue';
import TextField from './TextField.vue'; import TextField from './TextField.vue';
@ -96,7 +83,6 @@ import Draggable from 'vuedraggable';
const singleSection = ref(null); const singleSection = ref(null);
const singleField = ref(null); const singleField = ref(null);
const moving = ref(null);
const active = ref(null); const active = ref(null);
async function onReorder() { async function onReorder() {
@ -138,29 +124,6 @@ function editSection(sectionIndex) {
mode: 'edit', mode: 'edit',
}; };
} }
function moveFieldToSection(newSectionIndex) {
if (!moving.value) {
return;
}
singleField.value = {
model: JSON.parse(JSON.stringify(inner.value.sections[moving.value.section].fields[moving.value.field])),
sectionIndex: newSectionIndex,
index: null,
};
storeField();
deleteField(moving.value.section, moving.value.field);
moving.value = null;
}
function copyField(sectionIndex, fieldIndex) {
var field = JSON.parse(JSON.stringify(inner.value.sections[sectionIndex].fields[fieldIndex]));
field.name = field.name + ' - Kopie';
singleField.value = {
model: field,
sectionIndex: sectionIndex,
index: null,
};
}
function startReordering(index) { function startReordering(index) {
singleSection.value = { singleSection.value = {

View File

@ -18,7 +18,6 @@
<f-text id="iban" v-model="inner.iban" label="IBAN"></f-text> <f-text id="iban" v-model="inner.iban" label="IBAN"></f-text>
<f-text id="bic" v-model="inner.bic" label="BIC"></f-text> <f-text id="bic" v-model="inner.bic" label="BIC"></f-text>
<f-text id="remember_weeks" v-model="inner.rememberWeeks" type="number" label="Erinnerung alle X Wochen versenden"></f-text> <f-text id="remember_weeks" v-model="inner.rememberWeeks" type="number" label="Erinnerung alle X Wochen versenden"></f-text>
<f-text id="reply_to" v-model="inner.replyTo" label="Reply-To E-Mail-Adresse"></f-text>
</form> </form>
</setting-layout> </setting-layout>
</page-layout> </page-layout>

View File

@ -1,7 +1,7 @@
@component('mail::message') @component('mail::message')
# {{ $invoice->greeting }}, # {{ $invoice->greeting }},
Im Anhang findet ihr die aktuelle Rechnung an {{$settings->from}} für das laufende Jahr. Bitte begleicht diese bis zum angegebenen Datum. Im Anhang findet ihr die aktuelle Rechnung des Stammes Silva für das laufende Jahr. Bitte begleicht diese bis zum angegebenen Datum.
@component('mail::subcopy') @component('mail::subcopy')

View File

@ -1,7 +1,7 @@
@component('mail::message') @component('mail::message')
# {{ $invoice->greeting }}, # {{ $invoice->greeting }},
Hiermit möchten wir euch an die noch ausstehenden Mitgliedsbeiträge an {{$settings->from}} für das laufende Jahr erinnern. Bitte begleicht diese bis zum angegebenen Datum. Hiermit möchten wir euch an die noch ausstehenden Mitgliedsbeiträge des Stammes Silva für das laufende Jahr erinnern. Bitte begleicht diese bis zum angegebenen Datum.
@component('mail::subcopy') @component('mail::subcopy')

View File

@ -22,7 +22,6 @@ use App\Fileshare\Actions\FileshareStoreAction;
use App\Fileshare\Actions\FileshareUpdateAction; use App\Fileshare\Actions\FileshareUpdateAction;
use App\Fileshare\Actions\ListFilesAction; use App\Fileshare\Actions\ListFilesAction;
use App\Form\Actions\ExportAction as ActionsExportAction; use App\Form\Actions\ExportAction as ActionsExportAction;
use App\Form\Actions\FormCleanupAction;
use App\Form\Actions\FormCopyAction; use App\Form\Actions\FormCopyAction;
use App\Form\Actions\FormDestroyAction; use App\Form\Actions\FormDestroyAction;
use App\Form\Actions\FormGenerateLaterlinkAction; use App\Form\Actions\FormGenerateLaterlinkAction;
@ -168,7 +167,6 @@ Route::group(['middleware' => 'auth:web'], function (): void {
Route::get('/form', FormIndexAction::class)->name('form.index'); Route::get('/form', FormIndexAction::class)->name('form.index');
Route::patch('/form/{form}', FormUpdateAction::class)->name('form.update'); Route::patch('/form/{form}', FormUpdateAction::class)->name('form.update');
Route::delete('/form/{form}', FormDestroyAction::class)->name('form.destroy'); Route::delete('/form/{form}', FormDestroyAction::class)->name('form.destroy');
Route::delete('/form/{form}/cleanup', FormCleanupAction::class)->name('form.cleanup');
Route::post('/formtemplate', FormtemplateStoreAction::class)->name('formtemplate.store'); Route::post('/formtemplate', FormtemplateStoreAction::class)->name('formtemplate.store');
Route::patch('/formtemplate/{formtemplate}', FormtemplateUpdateAction::class)->name('formtemplate.update'); Route::patch('/formtemplate/{formtemplate}', FormtemplateUpdateAction::class)->name('formtemplate.update');
Route::delete('/formtemplate/{formtemplate}', FormtemplateDestroyAction::class)->name('formtemplate.destroy'); Route::delete('/formtemplate/{formtemplate}', FormtemplateDestroyAction::class)->name('formtemplate.destroy');

View File

@ -248,6 +248,18 @@ it('notices a few weeks before', function ($date, bool $shouldSend) {
[fn() => now()->subYears(5)->addWeeks(2)->subDay(), false], [fn() => now()->subYears(5)->addWeeks(2)->subDay(), false],
]); ]);
it('skips members that are marked as skipped for yearly mail', function (bool $skip) {
Mail::fake();
createMember(['efz' => null, 'skip_yearly_prevention' => $skip]);
sleep(2);
YearlyRememberAction::run();
$skip
? Mail::assertNotSent(YearlyMail::class)
: Mail::assertSent(YearlyMail::class);
})->with([true, false]);
it('sets reply to mail', function () { it('sets reply to mail', function () {
Mail::fake(); Mail::fake();
app(PreventionSettings::class)->fill(['replyToMail' => 'admin@example.com'])->save(); app(PreventionSettings::class)->fill(['replyToMail' => 'admin@example.com'])->save();
@ -256,7 +268,7 @@ it('sets reply to mail', function () {
sleep(2); sleep(2);
YearlyRememberAction::run(); YearlyRememberAction::run();
Mail::assertSent(YearlyMail::class, fn ($message) => $message->hasReplyTo('admin@example.com')); Mail::assertSent(YearlyMail::class, fn($message) => $message->hasReplyTo('admin@example.com'));
}); });
it('remembers members yearly', function ($date, $shouldSend) { it('remembers members yearly', function ($date, $shouldSend) {

View File

@ -1,98 +0,0 @@
<?php
namespace Tests\Feature\Form;
use App\Form\Models\Form;
use App\Form\Models\Participant;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\Lib\CreatesFormFields;
uses(DatabaseTransactions::class);
uses(CreatesFormFields::class);
beforeEach(function () {
test()->fakeMessages();
test()->setUpForm();
});
it('deletes all participants of a form', function () {
test()->login()->loginNami()->withoutExceptionHandling();
$form = Form::factory()
->has(Participant::factory()->count(2))
->sections([])
->create();
test()->deleteJson(route('form.cleanup', ['form' => $form]))
->assertOk();
test()->assertDatabaseCount('participants', 0);
});
it('does not delete the form itself', function () {
test()->login()->loginNami()->withoutExceptionHandling();
$form = Form::factory()
->has(Participant::factory())
->sections([])
->create();
test()->deleteJson(route('form.cleanup', ['form' => $form]))
->assertOk();
test()->assertDatabaseHas('forms', ['id' => $form->id]);
});
it('does not delete participants of other forms', function () {
test()->login()->loginNami()->withoutExceptionHandling();
$form = Form::factory()
->has(Participant::factory())
->sections([])
->create();
$otherForm = Form::factory()
->has(Participant::factory())
->sections([])
->create();
test()->deleteJson(route('form.cleanup', ['form' => $form]))
->assertOk();
test()->assertDatabaseCount('participants', 1);
test()->assertDatabaseHas('participants', ['id' => $otherForm->participants->first()->id]);
});
it('deletes participants that are children of other participants', function () {
test()->login()->loginNami()->withoutExceptionHandling();
$form = Form::factory()->sections([])->create();
$parent = Participant::factory()->for($form)->create();
Participant::factory()->for($form)->create(['parent_id' => $parent->id]);
test()->deleteJson(route('form.cleanup', ['form' => $form]))
->assertOk();
test()->assertDatabaseCount('participants', 0);
});
it('clears the frontend cache', function () {
test()->login()->loginNami()->withoutExceptionHandling();
$form = Form::factory()
->has(Participant::factory())
->sections([])
->create();
test()->deleteJson(route('form.cleanup', ['form' => $form]))
->assertOk();
test()->assertFrontendCacheCleared();
});
it('shows success message', function () {
test()->login()->loginNami()->withoutExceptionHandling();
$form = Form::factory()
->has(Participant::factory())
->sections([])
->create();
test()->deleteJson(route('form.cleanup', ['form' => $form]))
->assertOk();
test()->assertSuccessMessage('Teilnehmende gelöscht.');
});

View File

@ -48,7 +48,6 @@ class InvoiceSendActionTest extends TestCase
Tex::spy(); Tex::spy();
Storage::fake('temp'); Storage::fake('temp');
$this->withoutExceptionHandling()->login()->loginNami(); $this->withoutExceptionHandling()->login()->loginNami();
app(InvoiceSettings::class)->fill(['from_long' => 'Stammname', 'replyTo' => 'reply@mail.com'])->save();
$invoice = Invoice::factory() $invoice = Invoice::factory()
->to(ReceiverRequestFactory::new()->name('Familie Muster')) ->to(ReceiverRequestFactory::new()->name('Familie Muster'))
->has(InvoicePosition::factory()->withMember(), 'positions') ->has(InvoicePosition::factory()->withMember(), 'positions')
@ -58,13 +57,7 @@ class InvoiceSendActionTest extends TestCase
InvoiceSendAction::run(); InvoiceSendAction::run();
Mail::assertSent(RememberMail::class, fn ($mail) => $mail->build() Mail::assertSent(RememberMail::class, fn ($mail) => $mail->build() && $mail->hasTo('max@muster.de', 'Familie Muster') && Storage::disk('temp')->path('zahlungserinnerung-fur-familie-muster.pdf') === $mail->filename && Storage::disk('temp')->exists('zahlungserinnerung-fur-familie-muster.pdf'));
&& $mail->hasTo('max@muster.de', 'Familie Muster')
&& $mail->hasSubject('Zahlungserinnerung | Stammname')
&& Storage::disk('temp')->path('zahlungserinnerung-fur-familie-muster.pdf') === $mail->filename
&& Storage::disk('temp')->exists('zahlungserinnerung-fur-familie-muster.pdf')
&& $mail->hasReplyTo('reply@mail.com')
);
Tex::assertCompiled(RememberDocument::class, fn ($document) => 'Familie Muster' === $document->toName); Tex::assertCompiled(RememberDocument::class, fn ($document) => 'Familie Muster' === $document->toName);
$this->assertEquals(now()->format('Y-m-d'), $invoice->fresh()->last_remembered_at->format('Y-m-d')); $this->assertEquals(now()->format('Y-m-d'), $invoice->fresh()->last_remembered_at->format('Y-m-d'));
} }

View File

@ -33,8 +33,7 @@ class SettingTest extends TestCase
'zip' => '12345', 'zip' => '12345',
'iban' => 'DE05', 'iban' => 'DE05',
'bic' => 'SOLSDE', 'bic' => 'SOLSDE',
'rememberWeeks' => 6, 'rememberWeeks' => 6
'replyTo' => 'reply@example.com',
])->save(); ])->save();
$this->get(route('setting.data', ['settingGroup' => 'bill'])) $this->get(route('setting.data', ['settingGroup' => 'bill']))
@ -50,8 +49,7 @@ class SettingTest extends TestCase
->assertInertiaPath('data.zip', '12345') ->assertInertiaPath('data.zip', '12345')
->assertInertiaPath('data.iban', 'DE05') ->assertInertiaPath('data.iban', 'DE05')
->assertInertiaPath('data.bic', 'SOLSDE') ->assertInertiaPath('data.bic', 'SOLSDE')
->assertInertiaPath('data.rememberWeeks', 6) ->assertInertiaPath('data.rememberWeeks', 6);
->assertInertiaPath('data.replyTo', 'reply@example.com');
} }
public function testItReturnsTabs(): void public function testItReturnsTabs(): void
@ -80,8 +78,7 @@ class SettingTest extends TestCase
'zip' => '12345', 'zip' => '12345',
'iban' => 'DE05', 'iban' => 'DE05',
'bic' => 'SOLSDE', 'bic' => 'SOLSDE',
'rememberWeeks' => 10, 'rememberWeeks' => 10
'replyTo' => 'reply@example.com2',
]); ]);
$response->assertRedirect('/setting/bill'); $response->assertRedirect('/setting/bill');
@ -89,7 +86,6 @@ class SettingTest extends TestCase
$this->assertEquals('DPSG Stamm Muster', $settings->from_long); $this->assertEquals('DPSG Stamm Muster', $settings->from_long);
$this->assertEquals('DE05', $settings->iban); $this->assertEquals('DE05', $settings->iban);
$this->assertEquals('SOLSDE', $settings->bic); $this->assertEquals('SOLSDE', $settings->bic);
$this->assertEquals('reply@example.com2', $settings->replyTo);
$this->assertEquals(10, $settings->rememberWeeks); $this->assertEquals(10, $settings->rememberWeeks);
} }
} }

View File

@ -27,6 +27,7 @@ services:
timeout: 10s timeout: 10s
retries: 5 retries: 5
volumes: volumes:
- ./oc_tmp/files:/mnt/data
- ./08-skeleton.sh:/etc/entrypoint.d/08-skeleton.sh - ./08-skeleton.sh:/etc/entrypoint.d/08-skeleton.sh
mariadb: mariadb:
@ -43,6 +44,8 @@ services:
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
volumes:
- ./oc_tmp/db:/var/lib/mysql
nextclouddb: nextclouddb:
image: mariadb:10.6 image: mariadb:10.6
@ -52,6 +55,8 @@ services:
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
volumes:
- ./oc_tmp/ncdb:/var/lib/mysql
environment: environment:
- MYSQL_ROOT_PASSWORD=secret - MYSQL_ROOT_PASSWORD=secret
- MYSQL_PASSWORD=secret - MYSQL_PASSWORD=secret