Compare commits

..

61 Commits

Author SHA1 Message Date
Philipp Lang 8bc9f30459 update docs
continuous-integration/drone/push Build is passing Details
2023-06-15 16:58:52 +02:00
Philipp Lang 6fd16a7dfe fix queue name
continuous-integration/drone/push Build is passing Details
2023-06-15 15:40:58 +02:00
Philipp Lang 277259320a Fixed: Age group membership should be active
continuous-integration/drone/push Build is failing Details
2023-06-15 15:22:47 +02:00
Philipp Lang 45a1e165fa Add deploy for dpsgbergischland 2023-06-15 15:19:54 +02:00
philipp lang d38989f302 Lint
continuous-integration/drone/tag Build is passing Details
continuous-integration/drone/push Build is passing Details
2023-06-15 00:33:13 +02:00
philipp lang a434388bcb Fixed tests 2023-06-15 00:12:20 +02:00
philipp lang 48d9d9cc92 Fix: Delete local addresses when deleting maildispatcher 2023-06-15 00:08:01 +02:00
philipp lang c2016af587 Store maildispatcher 2023-06-14 23:20:37 +02:00
philipp lang 4057f9fc8d Fix Uuids for select 2023-06-14 23:20:13 +02:00
philipp lang 89f5489e86 Fix height of MultipleSelect 2023-06-14 23:19:46 +02:00
philipp lang 7497593bb2 Add ValidationAttributes for Maildispatcher 2023-06-14 23:19:19 +02:00
Philipp Lang 0080a54aaf Add mailgateway form
continuous-integration/drone/push Build is failing Details
2023-06-14 17:29:22 +02:00
Philipp Lang d41aa466b1 Add member filter 2023-06-14 17:12:03 +02:00
Philipp Lang 188429af55 Add Search action for contribution search 2023-06-14 17:12:03 +02:00
Philipp Lang 2cb04ee142 Add max height for multiple select window 2023-06-14 17:12:03 +02:00
philipp lang b87b37a673 Add create action for maildispatcher 2023-06-14 17:12:03 +02:00
philipp lang 8f3cc95300 Fixed mailman test 2023-06-14 17:12:03 +02:00
Philipp Lang 904ec829c1 Add member management for mailman 2023-06-14 17:12:03 +02:00
Philipp Lang a74d0936a2 --wip-- [skip ci] 2023-06-14 17:12:03 +02:00
philipp lang da3197395f Fixed: Match setting path when URL contains query string 2023-06-14 17:12:03 +02:00
philipp lang 20973b3664 Fixed: Add listeners for button 2023-06-14 17:12:03 +02:00
philipp lang b54472c14e Update mailgateway 2023-06-14 17:12:03 +02:00
philipp lang 8b2bdae5d6 Add update for mailgateway 2023-06-14 17:12:03 +02:00
philipp lang ab43d42869 Fixed tests 2023-06-14 17:12:03 +02:00
philipp lang 79f818c0f9 Fix index 2023-06-14 17:12:03 +02:00
philipp lang 4a452ef1fe Add type validation 2023-06-14 17:12:03 +02:00
philipp lang 36dcff9738 Simplify tests 2023-06-14 17:12:03 +02:00
philipp lang a880333f35 Add store button 2023-06-14 17:12:03 +02:00
philipp lang 68ecce179e Add params as type 2023-06-14 17:12:03 +02:00
philipp lang 06d512e8ae rename route 2023-06-14 17:12:03 +02:00
philipp lang 9442714086 Add indexTest for mailgateway 2023-06-14 17:12:03 +02:00
philipp lang c653a4b0c1 Add test for mailman type 2023-06-14 17:12:02 +02:00
Philipp Lang 0dcb3b7d5b Add mailman check 2023-06-14 17:12:02 +02:00
Philipp Lang a15b54f98d Add mailman type with params 2023-06-14 17:12:02 +02:00
Philipp Lang f75201dfaa Add: Store new mailgateway 2023-06-14 17:12:02 +02:00
Philipp Lang 27c61ff8af Add index for mail gateways 2023-06-14 17:12:02 +02:00
Philipp Lang d2a000cb31 Ad Contract for settings 2023-06-14 17:12:02 +02:00
Philipp Lang 62fb792e65 Lint
continuous-integration/drone/push Build is passing Details
2023-06-14 17:11:42 +02:00
Philipp Lang b2a7d2a760 Add multiple select for members
continuous-integration/drone/push Build is failing Details
continuous-integration/drone/tag Build is failing Details
2023-06-14 16:20:18 +02:00
Philipp Lang 9e1fe63c33 Fix svg sprite
continuous-integration/drone/tag Build is passing Details
continuous-integration/drone/push Build is passing Details
2023-06-01 11:31:12 +02:00
Philipp Lang 1d8b5b4670 Mod gitignore 2023-06-01 11:07:48 +02:00
Philipp Lang 526f2d3a6c add vite config 2023-06-01 11:05:24 +02:00
Philipp Lang fbb501519a add vite script 2023-06-01 11:05:13 +02:00
Philipp Lang 3485832262 fix component names 2023-06-01 11:05:03 +02:00
Philipp Lang 4bece4a761 Remove tailwind component layer 2023-06-01 11:03:59 +02:00
Philipp Lang b2b53c558e Lint 2023-06-01 11:03:53 +02:00
Philipp Lang 2e5385b565 Add vite package 2023-06-01 11:03:14 +02:00
Philipp Lang 754e3a0a82 ignore build folder 2023-06-01 11:02:55 +02:00
philipp lang 4e27dbfe67 Move other components
continuous-integration/drone/push Build is failing Details
2023-05-20 02:48:08 +02:00
philipp lang c68f1e00c4 Move boolean display 2023-05-20 02:44:41 +02:00
philipp lang d78740d508 Add new slot scope syntax 2023-05-20 02:38:38 +02:00
philipp lang 641f3a1098 Lint 2023-05-20 01:59:20 +02:00
philipp lang dbbe6f6171 Remove links 2023-05-20 01:46:59 +02:00
philipp lang e26b572575 Move page header component 2023-05-20 01:45:43 +02:00
philipp lang a526683082 Add tooltip slot to sidebar header 2023-05-20 01:12:53 +02:00
philipp lang 07d309f606 Update Tailwindcss 2023-05-20 00:57:57 +02:00
philipp lang 234380120e Load page components synchronously 2023-05-20 00:57:05 +02:00
Philipp Lang 72375affee Move components
continuous-integration/drone/push Build is passing Details
2023-05-19 01:07:34 +02:00
Philipp Lang d01322b1ad Move component subfolders 2023-05-18 23:22:45 +02:00
Philipp Lang f30eec0e80 update README
continuous-integration/drone/push Build is passing Details
2023-05-18 09:19:48 +02:00
philipp lang f1c55bedce Add options for search layers
continuous-integration/drone/push Build is passing Details
2023-05-18 01:13:28 +02:00
142 changed files with 3916 additions and 7297 deletions

View File

@ -24,7 +24,7 @@ steps:
- name: node - name: node
image: node:17.9.0-slim image: node:17.9.0-slim
commands: commands:
- npm ci && npm run prod && npm run img && rm -R node_modules - npm ci && npm run img && npm run prod && rm -R node_modules
- name: phpunit_tests - name: phpunit_tests
image: php:8.1.6 image: php:8.1.6
@ -144,6 +144,24 @@ steps:
branch: master branch: master
event: push event: push
- name: deploy dpsgbergischland
image: drillster/drone-rsync
settings:
hosts: ['zoomyboy.de']
user: dpsgbergischland
source: ./
target: ~/adrema
exclude: ['.git']
key:
from_secret: deploy_private_key
script:
- cd ~/adrema
- /usr/bin/php8.1 artisan migrate --force
- sudo systemctl restart adremabl-horizon
when:
branch: master
event: push
- name: github push - name: github push
image: alpine/git image: alpine/git
environment: environment:

10
.gitignore vendored
View File

@ -1,11 +1,7 @@
/node_modules /node_modules
/public/build
/public/sprite.svg
/public/hot /public/hot
/public/storage
/public/js
/public/css
/public/fonts
/public/img
/public/images
/storage/*.key /storage/*.key
/vendor /vendor
.env .env
@ -17,14 +13,12 @@ Homestead.yaml
npm-debug.log npm-debug.log
yarn-error.log yarn-error.log
tags tags
/public/vendor
/storage/temp /storage/temp
# Temporary files # Temporary files
*.swp *.swp
*.swo *.swo
*.swm *.swm
/public/mix-manifest.json
resources/img/sprite.svg resources/img/sprite.svg
/.php-cs-fixer.cache /.php-cs-fixer.cache
/data /data

View File

@ -6,7 +6,7 @@ Da du diese Seite besuchst, gehörst du sicherlich zu den Leuten, die möglichst
Die AdReMa (= "AddRessManagement") macht das auch, nur einfacher, schöner und intuitiver als es NaMi tut. Die AdReMa (= "AddRessManagement") macht das auch, nur einfacher, schöner und intuitiver als es NaMi tut.
![Mitglieder-Übersicht](https://git.zoomyboy.de/silva/adrema/raw/branch/master/doc/assets/member.jpg) ![Mitglieder-Übersicht](https://git.zoomyboy.de/silva/adrema/raw/branch/master/doc/page/assets/img/member.jpg)
AdReMa kann von jedem und jeder genutzt werden, die einen NaMi-Account besitzt und Schreibrechte hat (i.d.R. sind das Stammesvorstände, e.V.-Mitglieder und andere, die Mitgliederdaten und deren Abrechungen und Beiträge pflegen müssen). AdReMa kann von jedem und jeder genutzt werden, die einen NaMi-Account besitzt und Schreibrechte hat (i.d.R. sind das Stammesvorstände, e.V.-Mitglieder und andere, die Mitgliederdaten und deren Abrechungen und Beiträge pflegen müssen).
@ -30,7 +30,6 @@ Ziel dieses Projektes ist es, viele Dinge, die man normalerweise manuell zu tun
Außerdem ist AdReMa auch problemlos auf Handys und Tablets bedienbar ("mobiles Design") Außerdem ist AdReMa auch problemlos auf Handys und Tablets bedienbar ("mobiles Design")
![Mobile Ansicht](https://git.zoomyboy.de/silva/adrema/raw/branch/master/doc/assets/member-mobile.jpg)
# Installation # Installation
Submodules updaten: Submodules updaten:

View File

@ -11,7 +11,7 @@ class RedirectIfNotInitializedMiddleware
/** /**
* @var array<int, string> * @var array<int, string>
*/ */
public array $dontRedirect = ['initialize.form', 'initialize.store', 'nami.login-check', 'nami.search']; public array $dontRedirect = ['initialize.form', 'initialize.store', 'nami.login-check', 'nami.search', 'nami.get-search-layer'];
/** /**
* Handle an incoming request. * Handle an incoming request.

View File

@ -0,0 +1,49 @@
<?php
namespace App\Initialize\Actions;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Collection;
use Lorisleiva\Actions\ActionRequest;
use Lorisleiva\Actions\Concerns\AsAction;
use Zoomyboy\LaravelNami\Data\SearchLayerOption;
use Zoomyboy\LaravelNami\Enum\SearchLayer;
use Zoomyboy\LaravelNami\Nami;
class NamiGetSearchLayerAction
{
use AsAction;
/**
* @param array<string, mixed> $input
*
* @return Collection<int, SearchLayerOption>
*/
public function handle(array $input): Collection
{
return Nami::login((int) $input['mglnr'], $input['password'])->searchLayerOptions(
SearchLayer::from($input['layer'] ?: 0),
$input['parent'] ?: null
);
}
/**
* @return array<string, string>
*/
public function rules(): array
{
return [
'mglnr' => 'required|numeric|min:0',
'password' => 'required|string',
'parent' => 'present',
'layer' => 'required|numeric',
];
}
public function asController(ActionRequest $request): JsonResponse
{
$response = $this->handle($request->validated());
return response()->json($response);
}
}

View File

@ -2,9 +2,11 @@
namespace App\Invoice; namespace App\Invoice;
use App\Setting\Contracts\Indexable;
use App\Setting\Contracts\Storeable;
use App\Setting\LocalSettings; use App\Setting\LocalSettings;
class InvoiceSettings extends LocalSettings class InvoiceSettings extends LocalSettings implements Indexable, Storeable
{ {
public string $from_long; public string $from_long;
@ -41,7 +43,7 @@ class InvoiceSettings extends LocalSettings
return SettingIndexAction::class; return SettingIndexAction::class;
} }
public static function saveAction(): string public static function storeAction(): string
{ {
return SettingSaveAction::class; return SettingSaveAction::class;
} }

View File

@ -0,0 +1,23 @@
<?php
namespace App\Maildispatcher\Actions;
use App\Maildispatcher\Resources\MaildispatcherResource;
use Inertia\Inertia;
use Inertia\Response;
use Lorisleiva\Actions\Concerns\AsAction;
class CreateAction
{
use AsAction;
public function asController(): Response
{
session()->put('menu', 'maildispatcher');
session()->put('title', 'Mail-Verteiler erstellen');
return Inertia::render('maildispatcher/MaildispatcherForm', [
'meta' => MaildispatcherResource::meta(),
]);
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\Maildispatcher\Actions;
use App\Maildispatcher\Models\Maildispatcher;
use Illuminate\Http\RedirectResponse;
use Lorisleiva\Actions\Concerns\AsAction;
class DestroyAction
{
use AsAction;
public function handle(Maildispatcher $maildispatcher): void
{
$maildispatcher->delete();
}
public function asController(Maildispatcher $maildispatcher): RedirectResponse
{
$this->handle($maildispatcher);
return redirect()->back();
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Maildispatcher\Actions;
use App\Maildispatcher\Models\Maildispatcher;
use App\Maildispatcher\Resources\MaildispatcherResource;
use Inertia\Inertia;
use Inertia\Response;
use Lorisleiva\Actions\Concerns\AsAction;
class EditAction
{
use AsAction;
public function asController(Maildispatcher $maildispatcher): Response
{
session()->put('menu', 'maildispatcher');
session()->put('title', 'Mail-Verteiler bearbeiten');
return Inertia::render('maildispatcher/MaildispatcherForm', [
'data' => new MaildispatcherResource($maildispatcher),
'meta' => MaildispatcherResource::meta(),
]);
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Maildispatcher\Actions;
use App\Maildispatcher\Models\Maildispatcher;
use App\Maildispatcher\Resources\MaildispatcherResource;
use Inertia\Inertia;
use Inertia\Response;
use Lorisleiva\Actions\ActionRequest;
use Lorisleiva\Actions\Concerns\AsAction;
class IndexAction
{
use AsAction;
public function asController(ActionRequest $request): Response
{
session()->put('menu', 'maildispatcher');
session()->put('title', 'Mail-Verteiler');
return Inertia::render('maildispatcher/MaildispatcherIndex', [
'data' => MaildispatcherResource::collection(Maildispatcher::with('gateway')->paginate(10)),
]);
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Maildispatcher\Actions;
use App\Maildispatcher\Data\MailEntry;
use App\Maildispatcher\Models\Maildispatcher;
use App\Member\FilterScope;
use App\Member\Member;
use Illuminate\Support\Collection;
use Lorisleiva\Actions\Concerns\AsAction;
class ResyncAction
{
use AsAction;
public function handle(): void
{
foreach (Maildispatcher::get() as $dispatcher) {
$dispatcher->gateway->type->sync($dispatcher->name, $dispatcher->gateway->domain, $this->getResults($dispatcher));
}
}
/**
* @return Collection<int, MailEntry>
*/
public function getResults(Maildispatcher $dispatcher): Collection
{
return Member::search(data_get($dispatcher->filter, 'search', ''))->query(
fn ($q) => $q->select('*')->withFilter(FilterScope::fromPost($dispatcher->filter))
)->get()->filter(fn ($member) => $member->email || $member->email_parents)->map(fn ($member) => MailEntry::from(['email' => $member->email ?: $member->email_parents]));
}
}

View File

@ -0,0 +1,54 @@
<?php
namespace App\Maildispatcher\Actions;
use App\Maildispatcher\Models\Maildispatcher;
use Illuminate\Http\JsonResponse;
use Lorisleiva\Actions\ActionRequest;
use Lorisleiva\Actions\Concerns\AsAction;
class StoreAction
{
use AsAction;
/**
* @param array<string, mixed> $input
*/
public function handle(array $input): void
{
Maildispatcher::create([
...$input,
'filter' => (object) $input['filter'],
]);
ResyncAction::dispatch();
}
/**
* @return array<string, string>
*/
public function getValidationAttributes(): array
{
return [
'gateway_id' => 'Verbindung',
];
}
/**
* @return array<string, string>
*/
public function rules(): array
{
return [
'gateway_id' => 'required|exists:mailgateways,id',
'name' => 'required|max:50',
'filter' => 'present|array',
];
}
public function asController(ActionRequest $request): JsonResponse
{
$this->handle($request->validated());
return response()->json('', 201);
}
}

View File

@ -0,0 +1,54 @@
<?php
namespace App\Maildispatcher\Actions;
use App\Maildispatcher\Models\Maildispatcher;
use Illuminate\Http\JsonResponse;
use Lorisleiva\Actions\ActionRequest;
use Lorisleiva\Actions\Concerns\AsAction;
class UpdateAction
{
use AsAction;
/**
* @param array<string, mixed> $input
*/
public function handle(Maildispatcher $maildispatcher, array $input): void
{
$maildispatcher->update([
...$input,
'filter' => (object) $input['filter'],
]);
ResyncAction::dispatch();
}
/**
* @return array<string, string>
*/
public function getValidationAttributes(): array
{
return [
'gateway_id' => 'Verbindung',
];
}
/**
* @return array<string, string>
*/
public function rules(): array
{
return [
'gateway_id' => 'required|exists:mailgateways,id',
'name' => 'required|max:50',
'filter' => 'present|array',
];
}
public function asController(Maildispatcher $maildispatcher, ActionRequest $request): JsonResponse
{
$this->handle($maildispatcher, $request->validated());
return response()->json('', 201);
}
}

View File

@ -0,0 +1,12 @@
<?php
namespace App\Maildispatcher\Data;
use Spatie\LaravelData\Data;
class MailEntry extends Data
{
public function __construct(public string $email)
{
}
}

View File

@ -0,0 +1,17 @@
<?php
namespace App\Maildispatcher\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Localmaildispatcher extends Model
{
use HasFactory;
use HasUuids;
public $guarded = [];
public $timestamps = false;
}

View File

@ -0,0 +1,39 @@
<?php
namespace App\Maildispatcher\Models;
use App\Mailgateway\Models\Mailgateway;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Maildispatcher extends Model
{
use HasFactory;
use HasUuids;
public $guarded = [];
public $timestamps = false;
public $casts = [
'filter' => 'json',
];
public static function booted(): void
{
static::deleting(function ($dispatcher) {
foreach ($dispatcher->gateway->type->list($dispatcher->name, $dispatcher->gateway->domain) as $email) {
$dispatcher->gateway->type->remove($dispatcher->name, $dispatcher->gateway->domain, $email->email);
}
});
}
/**
* @return BelongsTo<Mailgateway, self>
*/
public function gateway(): BelongsTo
{
return $this->belongsTo(Mailgateway::class);
}
}

View File

@ -0,0 +1,62 @@
<?php
namespace App\Maildispatcher\Resources;
use App\Lib\HasMeta;
use App\Maildispatcher\Models\Maildispatcher;
use App\Mailgateway\Models\Mailgateway;
use App\Mailgateway\Resources\MailgatewayResource;
use App\Member\FilterScope;
use App\Member\Member;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin Maildispatcher
*/
class MaildispatcherResource extends JsonResource
{
use HasMeta;
/**
* Transform the resource into an array.
*
* @param \Illuminate\Http\Request $request
*
* @return array<string, mixed>
*/
public function toArray($request)
{
return [
'name' => $this->name,
'gateway' => new MailgatewayResource($this->whenLoaded('gateway')),
'gateway_id' => $this->gateway_id,
'filter' => $this->filter,
'id' => $this->id,
'links' => [
'edit' => route('maildispatcher.edit', ['maildispatcher' => $this->getModel()]),
'update' => route('maildispatcher.update', ['maildispatcher' => $this->getModel()]),
'delete' => route('maildispatcher.destroy', ['maildispatcher' => $this->getModel()]),
],
];
}
/**
* @return array<string, mixed>
*/
public static function meta(): array
{
return [
'links' => [
'create' => route('maildispatcher.create'),
'index' => route('maildispatcher.index'),
],
'default_model' => [
'name' => '',
'gateway_id' => null,
'filter' => FilterScope::from([])->toArray(),
],
'members' => Member::ordered()->get()->map(fn ($member) => ['id' => $member->id, 'name' => $member->fullname]),
'gateways' => Mailgateway::pluck('name', 'id'),
];
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace App\Mailgateway\Actions;
use App\Mailgateway\Models\Mailgateway;
use App\Mailgateway\Resources\MailgatewayResource;
use Illuminate\Database\Eloquent\Builder;
use Inertia\Inertia;
use Inertia\Response;
use Lorisleiva\Actions\Concerns\AsAction;
class IndexAction
{
use AsAction;
/**
* @return Builder<Mailgateway>
*/
public function handle(): Builder
{
return (new Mailgateway())->newQuery();
}
public function asController(): Response
{
session()->put('menu', 'setting');
session()->put('title', 'E-Mail-Verbindungen');
return Inertia::render('mailgateway/Index', [
'data' => MailgatewayResource::collection($this->handle()->paginate(10)),
]);
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace App\Mailgateway\Actions;
use App\Mailgateway\Models\Mailgateway;
use Lorisleiva\Actions\ActionRequest;
use Lorisleiva\Actions\Concerns\AsAction;
class StoreAction
{
use AsAction;
use ValidatesRequests;
/**
* @param array<string, mixed> $input
*/
public function handle(array $input): void
{
$this->checkIfWorks($input);
Mailgateway::create($input);
}
public function asController(ActionRequest $request): void
{
$this->handle($request->validated());
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace App\Mailgateway\Actions;
use App\Mailgateway\Models\Mailgateway;
use Lorisleiva\Actions\ActionRequest;
use Lorisleiva\Actions\Concerns\AsAction;
class UpdateAction
{
use AsAction;
use ValidatesRequests;
/**
* @param array<string, mixed> $input
*/
public function handle(Mailgateway $mailgateway, array $input): void
{
$this->checkIfWorks($input);
$mailgateway->update($input);
}
public function asController(Mailgateway $mailgateway, ActionRequest $request): void
{
$this->handle($mailgateway, $request->validated());
}
}

View File

@ -0,0 +1,65 @@
<?php
namespace App\Mailgateway\Actions;
use App\Mailgateway\Types\Type;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Lorisleiva\Actions\ActionRequest;
trait ValidatesRequests
{
/**
* @param array<string, mixed> $input
*/
public function checkIfWorks(array $input): void
{
if (!app(data_get($input, 'type.cls'))->setParams($input['type']['params'])->works()) {
throw ValidationException::withMessages(['connection' => 'Verbindung fehlgeschlagen.']);
}
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'name' => 'required|string|max:255',
'domain' => 'required|string|max:255',
...$this->typeValidation(),
'type.params' => 'present|array',
...collect(request()->input('type.cls')::rules('storeValidator'))->mapWithKeys(fn ($rules, $key) => ["type.params.{$key}" => $rules]),
];
}
/**
* @return array<string, mixed>
*/
public function getValidationAttributes(): array
{
return [
'type.cls' => 'Typ',
'name' => 'Beschreibung',
'domain' => 'Domain',
...collect(request()->input('type.cls')::fieldNames())->mapWithKeys(fn ($attribute, $key) => ["type.params.{$key}" => $attribute]),
];
}
/**
* @return array<string, mixed>
*/
private function typeValidation(): array
{
return [
'type.cls' => ['required', 'string', 'max:255', Rule::in(app('mail-gateways'))],
];
}
public function prepareForValidation(ActionRequest $request): void
{
if (!is_subclass_of(request()->input('type.cls'), Type::class)) {
throw ValidationException::withMessages(['type.cls' => 'Typ ist nicht valide.']);
}
}
}

View File

@ -0,0 +1,42 @@
<?php
namespace App\Mailgateway\Casts;
use App\Mailgateway\Types\Type;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
/**
* @implements CastsAttributes<Type, Type>
*/
class TypeCast implements CastsAttributes
{
/**
* Cast the given value.
*
* @param \Illuminate\Database\Eloquent\Model $model
* @param mixed $value
* @param array<string, mixed> $attributes
*
* @return mixed
*/
public function get($model, string $key, $value, array $attributes)
{
$value = json_decode($value, true);
return app($value['cls'])->setParams($value['params']);
}
/**
* Prepare the given value for storage.
*
* @param \Illuminate\Database\Eloquent\Model $model
* @param mixed $value
* @param array<string, mixed> $attributes
*
* @return mixed
*/
public function set($model, string $key, $value, array $attributes)
{
return json_encode($value);
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Mailgateway;
use App\Mailgateway\Actions\IndexAction;
use App\Setting\Contracts\Indexable;
use App\Setting\LocalSettings;
class MailgatewaySettings extends LocalSettings implements Indexable
{
public static function group(): string
{
return 'mailgateway';
}
public static function slug(): string
{
return 'mailgateway';
}
public static function indexAction(): string
{
return IndexAction::class;
}
public static function title(): string
{
return 'E-Mail-Verbindungen';
}
}

View File

@ -0,0 +1,17 @@
<?php
namespace App\Mailgateway\Models;
use App\Mailgateway\Casts\TypeCast;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Mailgateway extends Model
{
use HasFactory;
use HasUuids;
public $casts = ['type' => TypeCast::class];
public $guarded = [];
}

View File

@ -0,0 +1,68 @@
<?php
namespace App\Mailgateway\Resources;
use App\Lib\HasMeta;
use App\Mailgateway\Models\Mailgateway;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin Mailgateway
*/
class MailgatewayResource extends JsonResource
{
use HasMeta;
/**
* Transform the resource into an array.
*
* @param \Illuminate\Http\Request $request
*
* @return array<string, mixed>
*/
public function toArray($request)
{
return [
'name' => $this->name,
'domain' => $this->domain,
'type_human' => $this->type::name(),
'works' => $this->type->works(),
'type' => $this->type->toResource(),
'id' => $this->id,
'links' => [
'update' => route('mailgateway.update', ['mailgateway' => $this->getModel()]),
],
];
}
/**
* @return array<string, mixed>
*/
public static function meta(): array
{
return [
'links' => [
'store' => route('mailgateway.store'),
],
'types' => app('mail-gateways')->map(fn ($gateway) => [
'id' => $gateway,
'name' => $gateway::name(),
'fields' => $gateway::presentFields('storeValidator'),
'defaults' => (object) $gateway::defaults(),
])->prepend([
'id' => null,
'name' => '-- kein --',
'fields' => [],
'defaults' => (object) [],
]),
'default' => [
'domain' => '',
'name' => '',
'type' => [
'params' => [],
'cls' => null,
],
],
];
}
}

View File

@ -0,0 +1,60 @@
<?php
namespace App\Mailgateway\Types;
use App\Maildispatcher\Data\MailEntry;
use App\Maildispatcher\Models\Localmaildispatcher;
use Illuminate\Support\Collection;
class LocalType extends Type
{
public static function name(): string
{
return 'Lokal';
}
public function works(): bool
{
return true;
}
public static function fields(): array
{
return [];
}
public function setParams(array $params): static
{
return $this;
}
/**
* {@inheritdoc}
*/
public function list(string $name, string $domain): Collection
{
return Localmaildispatcher::where('from', "{$name}@{$domain}")->get()->map(fn ($mail) => MailEntry::from(['email' => $mail->to]));
}
public function search(string $name, string $domain, string $email): ?MailEntry
{
$result = Localmaildispatcher::where('from', "{$name}@{$domain}")->where('to', $email)->first();
return $result ? MailEntry::from([
'email' => $result->to,
]) : null;
}
public function add(string $name, string $domain, string $email): void
{
Localmaildispatcher::create([
'from' => "{$name}@{$domain}",
'to' => $email,
]);
}
public function remove(string $name, string $domain, string $email): void
{
Localmaildispatcher::where('from', "{$name}@{$domain}")->where('to', $email)->delete();
}
}

View File

@ -0,0 +1,108 @@
<?php
namespace App\Mailgateway\Types;
use App\Maildispatcher\Data\MailEntry;
use App\Mailman\Exceptions\MailmanServiceException;
use App\Mailman\Support\MailmanService;
use Illuminate\Support\Collection;
class MailmanType extends Type
{
public string $url;
public string $user;
public string $password;
public function setParams(array $params): static
{
$this->url = data_get($params, 'url');
$this->user = data_get($params, 'user');
$this->password = data_get($params, 'password');
return $this;
}
public static function name(): string
{
return 'Mailman';
}
public function works(): bool
{
return $this->service()->check();
}
/**
* {@inheritdoc}
*/
public static function fields(): array
{
return [
[
'name' => 'url',
'label' => 'URL',
'type' => 'text',
'storeValidator' => 'required|max:255',
'updateValidator' => 'required|max:255',
'default' => '',
],
[
'name' => 'user',
'label' => 'Benutzer',
'type' => 'text',
'storeValidator' => 'required|max:255',
'updateValidator' => 'required|max:255',
'default' => '',
],
[
'name' => 'password',
'label' => 'Passwort',
'type' => 'password',
'storeValidator' => 'required|max:255',
'updateValidator' => 'nullable|max:255',
'default' => '',
],
];
}
public function search(string $name, string $domain, string $email): ?MailEntry
{
$list = $this->service()->getLists()->first(fn ($list) => $list->fqdnListname === "{$name}@{$domain}");
throw_if(!$list, MailmanServiceException::class, "List for {$name}@{$domain} not found");
$member = $this->service()->members($list)->first(fn ($member) => $member->email === $email);
return $member ? MailEntry::from(['email' => $member->email]) : null;
}
public function add(string $name, string $domain, string $email): void
{
$list = $this->service()->getLists()->first(fn ($list) => $list->fqdnListname === "{$name}@{$domain}");
throw_if(!$list, MailmanServiceException::class, "List for {$name}@{$domain} not found");
$this->service()->addMember($list, $email);
}
/**
* {@inheritdoc}
*/
public function list(string $name, string $domain): Collection
{
$list = $this->service()->getLists()->first(fn ($list) => $list->fqdnListname === "{$name}@{$domain}");
throw_if(!$list, MailmanServiceException::class, "List for {$name}@{$domain} not found");
return collect($this->service()->members($list)->map(fn ($member) => MailEntry::from(['email' => $member->email]))->all());
}
public function remove(string $name, string $domain, string $email): void
{
$list = $this->service()->getLists()->first(fn ($list) => $list->fqdnListname === "{$name}@{$domain}");
throw_if(!$list, MailmanServiceException::class, "List for {$name}@{$domain} not found");
$member = $this->service()->members($list)->first(fn ($member) => $member->email === $email);
throw_if(!$member, MailmanServiceException::class, 'Member for removing not found');
$this->service()->removeMember($member);
}
private function service(): MailmanService
{
return app(MailmanService::class)->setCredentials($this->url, $this->user, $this->password);
}
}

View File

@ -0,0 +1,102 @@
<?php
namespace App\Mailgateway\Types;
use App\Maildispatcher\Data\MailEntry;
use Illuminate\Support\Collection;
abstract class Type
{
abstract public static function name(): string;
/**
* @return array<int, MailgatewayCustomField>
*/
abstract public static function fields(): array;
abstract public function works(): bool;
abstract public function search(string $name, string $domain, string $email): ?MailEntry;
abstract public function add(string $name, string $domain, string $email): void;
abstract public function remove(string $name, string $domain, string $email): void;
/**
* @return Collection<int, MailEntry>
*/
abstract public function list(string $name, string $domain): Collection;
/**
* @param array<string, mixed> $params
*/
abstract public function setParams(array $params): static;
/**
* @return array<string, string>
*/
public static function defaults(): array
{
return collect(static::fields())->mapWithKeys(fn ($field) => [
$field['name'] => $field['default'],
])->toArray();
}
/**
* @return array<int, MailgatewayParsedCustomField>
*/
public static function presentFields(string $validator): array
{
return array_map(fn ($field) => [
...$field,
'is_required' => str_contains($field[$validator], 'required'),
], static::fields());
}
/**
* @return array<string, mixed>
*/
public static function rules(string $validator): array
{
return collect(static::fields())->mapWithKeys(fn ($field) => [
$field['name'] => $field[$validator],
])->toArray();
}
/**
* @return array<string, array<string, mixed>>
*/
public function toResource(): array
{
return [
'cls' => get_class($this),
'params' => get_object_vars($this),
];
}
/**
* @param Collection<int, MailEntry> $results
*/
public function sync(string $name, string $domain, Collection $results): void
{
foreach ($results as $result) {
if ($this->search($name, $domain, $result->email)) {
continue;
}
$this->add($name, $domain, $result->email);
}
$this->list($name, $domain)
->filter(fn ($listEntry) => null === $results->first(fn ($r) => $r->email === $listEntry->email))
->each(fn ($listEntry) => $this->remove($name, $domain, $listEntry->email));
}
/**
* @return array<string, string>
*/
public static function fieldNames(): array
{
return collect(static::fields())->mapWithKeys(fn ($field) => [$field['name'] => $field['label']])->toArray();
}
}

View File

@ -6,6 +6,7 @@ use Spatie\LaravelData\Attributes\MapName;
use Spatie\LaravelData\Attributes\MapOutputName; use Spatie\LaravelData\Attributes\MapOutputName;
use Spatie\LaravelData\Data; use Spatie\LaravelData\Data;
use Spatie\LaravelData\Mappers\SnakeCaseMapper; use Spatie\LaravelData\Mappers\SnakeCaseMapper;
use Tests\RequestFactories\MailinglistFactory;
#[MapName(SnakeCaseMapper::class)] #[MapName(SnakeCaseMapper::class)]
class MailingList extends Data class MailingList extends Data
@ -24,4 +25,9 @@ class MailingList extends Data
public int $volume, public int $volume,
) { ) {
} }
public static function factory(): MailinglistFactory
{
return MailinglistFactory::new();
}
} }

View File

@ -0,0 +1,17 @@
<?php
namespace App\Mailman\Data;
use Spatie\LaravelData\Attributes\MapName;
use Spatie\LaravelData\Data;
use Spatie\LaravelData\Mappers\SnakeCaseMapper;
#[MapName(SnakeCaseMapper::class)]
class Member extends Data
{
public function __construct(
public string $email,
public string $memberId,
) {
}
}

View File

@ -4,9 +4,11 @@ namespace App\Mailman;
use App\Mailman\Actions\SettingIndexAction; use App\Mailman\Actions\SettingIndexAction;
use App\Mailman\Actions\SettingSaveAction; use App\Mailman\Actions\SettingSaveAction;
use App\Setting\Contracts\Indexable;
use App\Setting\Contracts\Storeable;
use App\Setting\LocalSettings; use App\Setting\LocalSettings;
class MailmanSettings extends LocalSettings class MailmanSettings extends LocalSettings implements Storeable, Indexable
{ {
public ?string $base_url; public ?string $base_url;
@ -39,7 +41,7 @@ class MailmanSettings extends LocalSettings
return SettingIndexAction::class; return SettingIndexAction::class;
} }
public static function saveAction(): string public static function storeAction(): string
{ {
return SettingSaveAction::class; return SettingSaveAction::class;
} }

View File

@ -3,6 +3,7 @@
namespace App\Mailman\Support; namespace App\Mailman\Support;
use App\Mailman\Data\MailingList; use App\Mailman\Data\MailingList;
use App\Mailman\Data\Member;
use App\Mailman\Exceptions\MailmanServiceException; use App\Mailman\Exceptions\MailmanServiceException;
use App\Mailman\MailmanSettings; use App\Mailman\MailmanSettings;
use Illuminate\Http\Client\ConnectionException; use Illuminate\Http\Client\ConnectionException;
@ -43,26 +44,50 @@ class MailmanService
} }
/** /**
* @return LazyCollection<int, string> * @return LazyCollection<int, Member>
*/ */
public function members(string $listId): LazyCollection public function members(MailingList $list): LazyCollection
{ {
return app(Paginator::class)->result( return app(Paginator::class)->result(
fn ($page) => $this->http()->get("/lists/{$listId}/roster/member?page={$page}&count=10"), fn ($page) => $this->http()->get("/lists/{$list->listId}/roster/member?page={$page}&count=10"),
function ($response) use ($listId) { function ($response) use ($list) {
throw_unless($response->ok(), MailmanServiceException::class, 'Fetching members for listId '.$listId.' failed.'); throw_unless($response->ok(), MailmanServiceException::class, 'Fetching members for listId '.$list->listId.' failed.');
/** @var array<int, array{email: string}>|null */ /** @var array<int, array{email: string, self_link: string}>|null */
$entries = data_get($response->json(), 'entries'); $entries = data_get($response->json(), 'entries', []);
throw_if(is_null($entries), MailmanServiceException::class, 'Failed getting member list from response'); throw_if(is_null($entries), MailmanServiceException::class, 'Failed getting member list from response');
foreach ($entries as $entry) { foreach ($entries as $entry) {
yield $entry['email']; yield Member::from([
...$entry,
'member_id' => strrev(preg_split('/\//', strrev($entry['self_link']))[0]),
]);
} }
}, },
fn ($response) => data_get($response->json(), 'total_size') fn ($response) => data_get($response->json(), 'total_size')
); );
} }
public function addMember(MailingList $list, string $email): void
{
$response = $this->http()->post('members', [
'list_id' => $list->listId,
'subscriber' => $email,
'pre_verified' => 'true',
'pre_approved' => 'true',
'send_welcome_message' => 'false',
'pre_confirmed' => 'true',
]);
throw_unless(201 === $response->status(), MailmanServiceException::class, 'Adding member '.$email.' to '.$list->listId.' failed');
}
public function removeMember(Member $member): void
{
$response = $this->http()->delete("members/{$member->memberId}");
throw_unless(204 === $response->status(), MailmanServiceException::class, 'Removing member failed');
}
private function http(): PendingRequest private function http(): PendingRequest
{ {
return Http::withBasicAuth($this->username, $this->password)->withOptions(['base_uri' => $this->baseUrl]); return Http::withBasicAuth($this->username, $this->password)->withOptions(['base_uri' => $this->baseUrl]);

View File

@ -2,10 +2,11 @@
namespace App\Member\Actions; namespace App\Member\Actions;
use App\Member\FilterScope;
use App\Member\Member; use App\Member\Member;
use App\Member\MemberResource; use App\Member\MemberResource;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection; use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Pagination\LengthAwarePaginator;
use Lorisleiva\Actions\ActionRequest; use Lorisleiva\Actions\ActionRequest;
use Lorisleiva\Actions\Concerns\AsAction; use Lorisleiva\Actions\Concerns\AsAction;
@ -14,27 +15,18 @@ class SearchAction
use AsAction; use AsAction;
/** /**
* @return Collection<int, Member> * @return LengthAwarePaginator<int, Member>
*/ */
public function handle(string $search): Collection public function handle(FilterScope $filter): LengthAwarePaginator
{ {
return Member::search($search)->query(fn ($query) => $query->ordered())->get(); return Member::search($filter->search)->query(fn ($q) => $q->select('*')
->withFilter($filter)
->ordered()
)->paginate(15);
} }
public function asController(ActionRequest $request): AnonymousResourceCollection public function asController(ActionRequest $request): AnonymousResourceCollection
{ {
if (null !== $request->input('minLength') && strlen($request->input('search', '')) < $request->input('minLength')) { return MemberResource::collection($this->handle(FilterScope::fromRequest($request->input('filter', ''))));
return MemberResource::collection($this->empty());
}
return MemberResource::collection($this->handle($request->input('search', '')));
}
/**
* @return Collection<int, Member>
*/
private function empty(): Collection
{
return Member::where('id', -1)->get();
} }
} }

View File

@ -16,13 +16,20 @@ use Spatie\LaravelData\Mappers\SnakeCaseMapper;
#[MapOutputName(SnakeCaseMapper::class)] #[MapOutputName(SnakeCaseMapper::class)]
class FilterScope extends Filter class FilterScope extends Filter
{ {
/**
* @param array<int, int> $activityIds
* @param array<int, int> $subactivityIds
* @param array<int, int> $groupIds
* @param array<int, int> $additional
*/
public function __construct( public function __construct(
public bool $ausstand = false, public bool $ausstand = false,
public ?string $billKind = null, public ?string $billKind = null,
public ?int $activityId = null, public array $activityIds = [],
public ?int $subactivityId = null, public array $subactivityIds = [],
public string $search = '', public ?string $search = '',
public ?int $groupId = null, public array $groupIds = [],
public array $additional = [],
) { ) {
} }
@ -41,6 +48,7 @@ class FilterScope extends Filter
*/ */
public function apply(Builder $query): Builder public function apply(Builder $query): Builder
{ {
$query->orWhere(function ($query) {
if ($this->ausstand) { if ($this->ausstand) {
$query->whereAusstand(); $query->whereAusstand();
} }
@ -49,21 +57,26 @@ class FilterScope extends Filter
$query->where('bill_kind', BillKind::fromValue($this->billKind)); $query->where('bill_kind', BillKind::fromValue($this->billKind));
} }
if ($this->groupId) { if (count($this->groupIds)) {
$query->where('group_id', $this->groupId); $query->whereIn('group_id', $this->groupIds);
} }
if ($this->subactivityId || $this->activityId) { if (count($this->subactivityIds) + count($this->activityIds) > 0) {
$query->whereHas('memberships', function ($q) { $query->whereHas('memberships', function ($q) {
$q->active(); $q->active();
if ($this->subactivityId) { if (count($this->subactivityIds)) {
$q->where('subactivity_id', $this->subactivityId); $q->whereIn('subactivity_id', $this->subactivityIds);
} }
if ($this->activityId) { if (count($this->activityIds)) {
$q->where('activity_id', $this->activityId); $q->whereIn('activity_id', $this->activityIds);
} }
}); });
} }
})->orWhere(function ($query) {
if (count($this->additional)) {
$query->whereIn('id', $this->additional);
}
});
return $query; return $query;
} }

View File

@ -282,7 +282,7 @@ class Member extends Model implements Geolocatable
*/ */
public function ageGroupMemberships(): HasMany public function ageGroupMemberships(): HasMany
{ {
return $this->memberships()->isAgeGroup(); return $this->memberships()->isAgeGroup()->active();
} }
public static function booted() public static function booted()

View File

@ -4,6 +4,7 @@ namespace App\Member;
use App\Country; use App\Country;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Maildispatcher\Actions\ResyncAction;
use App\Setting\GeneralSettings; use App\Setting\GeneralSettings;
use App\Setting\NamiSettings; use App\Setting\NamiSettings;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
@ -86,6 +87,7 @@ class MemberController extends Controller
} }
$member->delete(); $member->delete();
ResyncAction::dispatch();
return redirect()->back(); return redirect()->back();
} }

View File

@ -5,6 +5,7 @@ namespace App\Member;
use App\Activity; use App\Activity;
use App\Group; use App\Group;
use App\Invoice\BillKind; use App\Invoice\BillKind;
use App\Maildispatcher\Actions\ResyncAction;
use App\Member\Actions\NamiPutMemberAction; use App\Member\Actions\NamiPutMemberAction;
use App\Setting\NamiSettings; use App\Setting\NamiSettings;
use App\Subactivity; use App\Subactivity;
@ -99,6 +100,7 @@ class MemberRequest extends FormRequest
Subactivity::find($this->input('first_subactivity_id')), Subactivity::find($this->input('first_subactivity_id')),
); );
} }
ResyncAction::dispatch();
} }
public function persistUpdate(Member $member): void public function persistUpdate(Member $member): void
@ -118,5 +120,6 @@ class MemberRequest extends FormRequest
if (!$this->input('has_nami') && null !== $member->nami_id) { if (!$this->input('has_nami') && null !== $member->nami_id) {
DeleteJob::dispatch($member->nami_id); DeleteJob::dispatch($member->nami_id);
} }
ResyncAction::dispatch();
} }
} }

View File

@ -140,6 +140,7 @@ class MemberResource extends JsonResource
'genders' => Gender::pluck('name', 'id'), 'genders' => Gender::pluck('name', 'id'),
'billKinds' => BillKind::forSelect(), 'billKinds' => BillKind::forSelect(),
'nationalities' => Nationality::pluck('name', 'id'), 'nationalities' => Nationality::pluck('name', 'id'),
'members' => Member::ordered()->get()->map(fn ($member) => ['id' => $member->id, 'name' => $member->fullname]),
'links' => [ 'links' => [
'index' => route('member.index'), 'index' => route('member.index'),
'create' => route('member.create'), 'create' => route('member.create'),

View File

@ -2,6 +2,8 @@
namespace App\Providers; namespace App\Providers;
use App\Mailgateway\Types\LocalType;
use App\Mailgateway\Types\MailmanType;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
@ -26,6 +28,11 @@ class AppServiceProvider extends ServiceProvider
return $this; return $this;
}); });
app()->bind('mail-gateways', fn () => collect([
LocalType::class,
MailmanType::class,
]));
} }
/** /**

View File

@ -0,0 +1,11 @@
<?php
namespace App\Setting\Contracts;
interface Indexable
{
/**
* @return class-string
*/
public static function indexAction(): string;
}

View File

@ -0,0 +1,11 @@
<?php
namespace App\Setting\Contracts;
interface Storeable
{
/**
* @return class-string
*/
public static function storeAction(): string;
}

View File

@ -14,16 +14,6 @@ class GeneralSettings extends Settings
/** @var array<int, int> */ /** @var array<int, int> */
public array $allowed_nami_accounts; public array $allowed_nami_accounts;
/**
* @return array<int, string>
*/
public function moduleOptions(): array
{
return [
'bill',
];
}
public static function group(): string public static function group(): string
{ {
return 'general'; return 'general';

View File

@ -8,16 +8,6 @@ abstract class LocalSettings extends Settings
{ {
abstract public static function slug(): string; abstract public static function slug(): string;
/**
* @return class-string
*/
abstract public static function indexAction(): string;
/**
* @return class-string
*/
abstract public static function saveAction(): string;
abstract public static function title(): string; abstract public static function title(): string;
public static function url(): string public static function url(): string

View File

@ -2,6 +2,8 @@
namespace App\Setting; namespace App\Setting;
use App\Setting\Contracts\Indexable;
use App\Setting\Contracts\Storeable;
use Illuminate\Routing\Router; use Illuminate\Routing\Router;
class SettingFactory class SettingFactory
@ -12,14 +14,19 @@ class SettingFactory
private array $settings = []; private array $settings = [];
/** /**
* @param class-string<LocalSettings> $setting * @param class-string $setting
*/ */
public function register(string $setting): void public function register(string $setting): void
{ {
$this->settings[] = $setting; $this->settings[] = $setting;
if (new $setting() instanceof Indexable) {
app(Router::class)->middleware(['web', 'auth:web', SettingMiddleware::class])->get($setting::url(), $setting::indexAction()); app(Router::class)->middleware(['web', 'auth:web', SettingMiddleware::class])->get($setting::url(), $setting::indexAction());
app(Router::class)->middleware(['web', 'auth:web', SettingMiddleware::class])->post($setting::url(), $setting::saveAction()); }
if (new $setting() instanceof Storeable) {
app(Router::class)->middleware(['web', 'auth:web', SettingMiddleware::class])->post($setting::url(), $setting::storeAction());
}
if (1 === count($this->settings)) { if (1 === count($this->settings)) {
app(Router::class)->redirect('/setting', '/setting/'.$setting::slug()); app(Router::class)->redirect('/setting', '/setting/'.$setting::slug());
@ -33,7 +40,7 @@ class SettingFactory
{ {
return collect($this->settings)->map(fn ($setting) => [ return collect($this->settings)->map(fn ($setting) => [
'url' => $setting::url(), 'url' => $setting::url(),
'is_active' => request()->fullUrlIs(url($setting::url())), 'is_active' => '/'.request()->path() === $setting::url(),
'title' => $setting::title(), 'title' => $setting::title(),
]) ])
->toArray(); ->toArray();

View File

@ -3,6 +3,7 @@
namespace App\Setting; namespace App\Setting;
use App\Invoice\InvoiceSettings; use App\Invoice\InvoiceSettings;
use App\Mailgateway\MailgatewaySettings;
use App\Mailman\MailmanSettings; use App\Mailman\MailmanSettings;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
@ -27,5 +28,6 @@ class SettingServiceProvider extends ServiceProvider
{ {
app(SettingFactory::class)->register(InvoiceSettings::class); app(SettingFactory::class)->register(InvoiceSettings::class);
app(SettingFactory::class)->register(MailmanSettings::class); app(SettingFactory::class)->register(MailmanSettings::class);
app(SettingFactory::class)->register(MailgatewaySettings::class);
} }
} }

View File

@ -0,0 +1,23 @@
<?php
namespace Database\Factories\Maildispatcher\Models;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Maildispatcher\Models\Maildispatcher>
*/
class MaildispatcherFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition()
{
return [
//
];
}
}

View File

@ -0,0 +1,54 @@
<?php
namespace Database\Factories\Mailgateway\Models;
use App\Mailgateway\Models\Mailgateway;
use App\Mailgateway\Types\Type;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Mailgateway\Models\Mailgateway>
*/
class MailgatewayFactory extends Factory
{
protected $model = Mailgateway::class;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition()
{
return [
'name' => $this->faker->words(5, true),
'type' => [
'cls' => app('mail-gateways')->random(),
'params' => [],
],
'domain' => $this->faker->safeEmailDomain(),
];
}
/**
* @param class-string<Type> $type
* @param array<string, mixed> $params
*/
public function type(string $type, array $params): self
{
return $this->state(['type' => [
'cls' => $type,
'params' => $params,
]]);
}
public function name(string $name): self
{
return $this->state(['name' => $name]);
}
public function domain(string $domain): self
{
return $this->state(['domain' => $domain]);
}
}

View File

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class() extends Migration {
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('mailgateways', function (Blueprint $table) {
$table->uuid('id');
$table->string('name');
$table->string('domain');
$table->json('type');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('mailgateways');
}
};

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class() extends Migration {
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('maildispatchers', function (Blueprint $table) {
$table->uuid('id');
$table->string('name');
$table->uuid('gateway_id');
$table->json('filter');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('maildispatchers');
}
};

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class() extends Migration {
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('localmaildispatchers', function (Blueprint $table) {
$table->uuid('id');
$table->string('from');
$table->string('to');
$table->unique(['from', 'to']);
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('localmaildispatchers');
}
};

View File

@ -38,7 +38,7 @@ Für einige Prozesse benötigt Adrema die Standard-Gruppierungsnummer. Dies ist
{% include imgcap.html img='init-default-groupid' caption="Standard-Gruppierungsnummer" %} {% include imgcap.html img='init-default-groupid' caption="Standard-Gruppierungsnummer" %}
## 4. Initialisierung starten ## Initialisierung starten
Danach führt Adrema im Hintergrund selbstständig einen ersten Abgleich durch. Dies kann je nach Datenmenge einige Minuten bis Stunden dauern. Danach führt Adrema im Hintergrund selbstständig einen ersten Abgleich durch. Dies kann je nach Datenmenge einige Minuten bis Stunden dauern.

15
doc/page/mail/index.md Normal file
View File

@ -0,0 +1,15 @@
---
layout: page
title: Mailverteiler
has_children: true
nav_order: 2
---
# Mailverteiler
Mit der Adrema kannst du E-Mail-Verteiler (also E-Mail-Adressen, die an sämtliche andere E-Mail-Adressen weiterleiten, sowas wie "eltern@stamm-bipi.de") verwalten.
Dabei werden E-Mail-Adressen anhand von Filter-Regeln automatisch in Verteiler aufgenommen oder entfernt bzw aktuell gehalten. Die Verteiler-Verwaltung ist damit Adrema-intern möglich.
Auf den folgenden Seiten erkläre ich dir, wie du eine Verbindung mit deinem bestehenden E-Mail-System herstellst, danach Verteiler anlegst und diese aktuell hälst.

View File

@ -0,0 +1,23 @@
---
layout: page
title: Verteiler verwalten
parent: Mailverteiler
nav_order: 2
---
# Verteiler verwalten
Wähle als Name den Namen der E-Mail-Adresse des Verteilers (das ist alles was vor dem "@" Zeichen steht). Es wird empfohlen, hier nur Kleinbuchstaben und "-" zu verwenden.
Wähle danach die Verbindung aus. Es wird für den Verteiler die Domain dieser Verbindung verwendet.
Wenn du also eine Verbindung mit der Domain "bipi.de" angelegt hast, einen Verteiler mit dieser Verbindung erstellst und dabei als Namen "leute" vergibst, so würde sich daraus die E-Mail-Adresse
leute@bipi.de
ergeben.
Wähle als nächstes die Kriterien aus, nach denen Mitglieder in den Verteiler aufgenommen werden sollen. Du erhälst darunter eine Vorschau der Mitglieder, so wie sie im Verteiler stehen würden. Dies ist natürlich nur eine Momentaufnahme.
Mitglieder ohne E-Mail-Adressen werden nicht hinzugefügt.

View File

@ -0,0 +1,51 @@
---
layout: page
title: Verbindungen verwalten
parent: Mailverteiler
nav_order: 1
---
# E-Mail-Verbindung anlegen
Die Adrema selbst leitet keine E-Mails weiter, sondern regelt nur, wer mit welcher E-Mail-Adresse Mitglied in einem bereits bestehendem Verteiler ist.
Um also die E-Mail-Verteiler-Funktion nutzen zu können, musst du vorher eine Verbindung zu einem bestehenden E-Mail-System herstellen.
## Unterstützte Systeme
### Lokal
Dies ist genau genommen kein Verteiler-System, sondern lediglich eine Datenbank, die intern gepflegt wird.
Wähle dies, wenn du einen eigene E-Mail-Server betreibst (Postfix, Qmail, o.ä.).
### Mailman (3.0)
Mailman ist ein Programm, mit dem man Mailinglisten verwalten kann. Es unterstützt eine Vielzahl von Typen von Mailinglisten, wie z.B. allgemeine Diskussionslisten aber auch reine Informationslisten für Veröffentlichungen.
{: .info }
Es werden von AdReMa keine neuen Verteiler erstellt oder entfernt. Es werden nur die Mitglieder eines bestehenden Verteilers verwaltet. Du musst die E-Mail-Verteiler also vorher in Mailman angelegt haben, damit die AdReMa diese verwalten kann.
[Zur Dokumentation vom Mailman](https://list.org/)
Wenn bei deiner Stammes-Domain Mailman zum Einsatz kommt, kannst du dich mit deinem bestehenden Mailman-System verbinden.
## Mailingliste erstellen
Gehe hierzu auf "Einstellungen -> E-Mail-Verbindungen" und wähle "Neue Verbindung".
Fülle nun die folgenden Felder aus:
* Bezeichnung: frei wählbarer Name - technisch keine Bedeutung
* Domain: Domain der E-Mail-Adressen. Alle angelegten Verteiler, die diese Verbindung nutzen haben eine E-Mail-Adresse mit dieser Domain
* Typ: Wähle hier das System aus (siehe oben)
### Zusatzeinstellungen bei Lokal
Bei lokalen Verteilern sind keine weiteren Einstellungen erforderlich. Diese werden hingegen in deinem existierenden Mailserver (Postfix, Qmail, etc) konfiguriert.
### Zusatzeinstellungen bei Mailman
Trage hier die URL, Benutzer und Passwort für die Mailman-Schnittstelle ein. Diese hast du entweder beim Einrichten von Mailman selbst konfiguriert, oder dein Hoster wird diese Daten bereitstellen.

7183
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -2,12 +2,12 @@
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "npm run development", "dev": "npm run development",
"development": "npx mix build", "development": "npx vite",
"watch": "npx mix watch", "watch": "npx vite",
"hot": "npx mix watch --hot", "hot": "npx vite",
"prod": "npx mix build --production", "prod": "npx vite build",
"production": "npm run prod", "production": "npm run prod",
"img": "rm -R public/img && cd resources/img/svg && npx svg-sprite -s --symbol-dest=sprite *.svg && mv sprite/svg/sprite.css.svg ../sprite.svg && rm -R sprite && cd ../../../ && cp -R resources/img public/img", "img": "cd resources/img/svg && npx svg-sprite -s --symbol-dest=sprite *.svg && mv sprite/svg/sprite.css.svg ../../../public/sprite.svg && rm -R sprite",
"lint": "eslint \"resources/js/**/*.{js,vue}\"", "lint": "eslint \"resources/js/**/*.{js,vue}\"",
"fix": "eslint \"resources/js/**/*.{js,vue}\" --fix" "fix": "eslint \"resources/js/**/*.{js,vue}\" --fix"
}, },
@ -16,9 +16,8 @@
"axios": "^1.3.4", "axios": "^1.3.4",
"eslint": "^8.9.0", "eslint": "^8.9.0",
"eslint-plugin-vue": "^8.4.1", "eslint-plugin-vue": "^8.4.1",
"laravel-mix": "^6.0.1",
"postcss": "^8.4.6", "postcss": "^8.4.6",
"tailwindcss": "^3.2", "tailwindcss": "^3.3",
"vue": "2.7", "vue": "2.7",
"vue-axios": "^3.5.2", "vue-axios": "^3.5.2",
"vue-loader": "^15.9.8", "vue-loader": "^15.9.8",
@ -28,6 +27,9 @@
"@inertiajs/inertia": "^0.11.0", "@inertiajs/inertia": "^0.11.0",
"@inertiajs/inertia-vue": "^0.8.0", "@inertiajs/inertia-vue": "^0.8.0",
"@tailwindcss/typography": "^0.5.9", "@tailwindcss/typography": "^0.5.9",
"@vitejs/plugin-vue2": "^2.2.0",
"change-case": "^4.1.2",
"laravel-vite-plugin": "^0.7.7",
"leaflet": "^1.9.3", "leaflet": "^1.9.3",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"merge": "^2.1.1", "merge": "^2.1.1",
@ -36,6 +38,7 @@
"postcss-import": "^14.0.1", "postcss-import": "^14.0.1",
"svg-sprite": "^2.0.2", "svg-sprite": "^2.0.2",
"v-tooltip": "^2.1.3", "v-tooltip": "^2.1.3",
"vite": "^4.3.8",
"vue-toasted": "^1.1.28", "vue-toasted": "^1.1.28",
"vue2-leaflet": "^2.7.1", "vue2-leaflet": "^2.7.1",
"wnumb": "^1.2.0" "wnumb": "^1.2.0"

@ -1 +1 @@
Subproject commit 33875d36fa5bd6fab4147e95f4aa705092f42d93 Subproject commit c5ea29af1bb1591238bb037da93739f5bd874334

View File

@ -18,6 +18,8 @@ parameters:
ContributionMemberData: 'array<string, mixed>' ContributionMemberData: 'array<string, mixed>'
ContributionRequestArray: 'array{dateFrom: string, dateUntil: string, zipLocation: string, country: int, eventName: string, members: array<int, int>}' ContributionRequestArray: 'array{dateFrom: string, dateUntil: string, zipLocation: string, country: int, eventName: string, members: array<int, int>}'
ContributionApiRequestArray: 'array{dateFrom: string, dateUntil: string, zipLocation: string, country: int, eventName: string, member_data: array<int, ContributionMemberData>}' ContributionApiRequestArray: 'array{dateFrom: string, dateUntil: string, zipLocation: string, country: int, eventName: string, member_data: array<int, ContributionMemberData>}'
MailgatewayCustomField: 'array{name: string, label: string, type: string, storeValidator: string, updateValidator: string, default: string}'
MailgatewayParsedCustomField: 'array{name: string, label: string, type: string, storeValidator: string, updateValidator: string, default: string, is_required: bool}'
ignoreErrors: ignoreErrors:
- -
@ -633,3 +635,43 @@ parameters:
message: "#^Return type of call to method Illuminate\\\\Support\\\\Collection\\<int,class\\-string\\<App\\\\Setting\\\\LocalSettings\\>\\>\\:\\:map\\(\\) contains unresolvable type\\.$#" message: "#^Return type of call to method Illuminate\\\\Support\\\\Collection\\<int,class\\-string\\<App\\\\Setting\\\\LocalSettings\\>\\>\\:\\:map\\(\\) contains unresolvable type\\.$#"
count: 1 count: 1
path: app/Setting/SettingFactory.php path: app/Setting/SettingFactory.php
-
message: "#^Access to an undefined property Illuminate\\\\Database\\\\Eloquent\\\\Model\\:\\:\\$email\\.$#"
count: 2
path: app/Maildispatcher/Actions/ResyncAction.php
-
message: "#^Access to an undefined property Illuminate\\\\Database\\\\Eloquent\\\\Model\\:\\:\\$email_parents\\.$#"
count: 2
path: app/Maildispatcher/Actions/ResyncAction.php
-
message: "#^Unable to resolve the template type TKey in call to function collect$#"
count: 2
path: app/Mailgateway/Actions/StoreAction.php
-
message: "#^Unable to resolve the template type TValue in call to function collect$#"
count: 2
path: app/Mailgateway/Actions/StoreAction.php
-
message: "#^Unable to resolve the template type TKey in call to function collect$#"
count: 2
path: app/Mailgateway/Actions/UpdateAction.php
-
message: "#^Unable to resolve the template type TValue in call to function collect$#"
count: 2
path: app/Mailgateway/Actions/UpdateAction.php
-
message: "#^Return type of call to method Illuminate\\\\Support\\\\Collection\\<\\(int\\|string\\),mixed\\>\\:\\:map\\(\\) contains unresolvable type\\.$#"
count: 1
path: app/Mailgateway/Resources/MailgatewayResource.php
-
message: "#^Generic type Illuminate\\\\Pagination\\\\LengthAwarePaginator\\<int, App\\\\Member\\\\Member\\> in PHPDoc tag @return specifies 2 template types, but class Illuminate\\\\Pagination\\\\LengthAwarePaginator supports only 1\\: TValue$#"
count: 1
path: app/Member/Actions/SearchAction.php

10
postcss.config.js vendored Normal file
View File

@ -0,0 +1,10 @@
const tailwindcss = require('tailwindcss');
module.exports = {
plugins: {
'postcss-import': {},
'tailwindcss/nesting': {},
'tailwindcss': {},
'autoprefixer': {},
},
};

22
resources/css/app.css vendored
View File

@ -2,15 +2,13 @@
@import 'tailwindcss/components'; @import 'tailwindcss/components';
@import 'tailwindcss/utilities'; @import 'tailwindcss/utilities';
@layer components { @import 'base.css';
@import 'base'; @import 'switch';
@import 'switch'; @import 'layout';
@import 'layout'; @import 'buttons';
@import 'buttons'; @import 'table';
@import 'table'; @import 'sidebar';
@import 'sidebar'; @import 'bool';
@import 'bool'; @import 'form';
@import 'form'; @import 'tooltip';
@import 'tooltip'; @import 'leaflet';
@import 'leaflet';
}

View File

@ -7,7 +7,6 @@
@apply mb-4; @apply mb-4;
} }
.form-control-font { .form-control-font {
@apply leading-tight text-gray-600 text-sm; @apply leading-tight text-gray-600 text-sm;
} }
@ -18,7 +17,8 @@
top: 34px; top: 34px;
transition: color 02s, padding-bottom 02s, font-size 02s, top 02s; transition: color 02s, padding-bottom 02s, font-size 02s, top 02s;
} }
.field-wrapperfocused label-placeholder, label-placeholder-focused { .field-wrapperfocused label-placeholder,
label-placeholder-focused {
top: 0; top: 0;
@apply text-sm text-gray-600; @apply text-sm text-gray-600;
padding-bottom: 10px; padding-bottom: 10px;
@ -42,24 +42,25 @@
@apply py-2; @apply py-2;
} }
input, select { input,
select {
outline: none; outline: none;
} }
input[type="date"], input[type='date'],
input[type="datetime-local"], input[type='datetime-local'],
input[type="time"], input[type='time'],
input[type="month"], input[type='month'],
select, select,
input[type="week"] { input[type='week'] {
height: 37px; height: 37px;
&::-webkit-inner-spin-button { &::-webkit-inner-spin-button {
display: none; display: none;
} }
} }
input[type="date"]::-webkit-inner-spin-button, input[type='date']::-webkit-inner-spin-button,
input[type="date"]::-webkit-calendar-picker-indicator { input[type='date']::-webkit-calendar-picker-indicator {
display: none; display: none;
-webkit-appearance: none; -webkit-appearance: none;
} }

View File

@ -33,6 +33,9 @@
} }
&.btn-danger { &.btn-danger {
@apply bg-red-400 text-red-100 hover:bg-red-300; @apply bg-red-400 text-red-100 hover:bg-red-300;
&:not(.disabled):hover {
@apply bg-red-500 text-red-100;
}
} }
&.label { &.label {

View File

@ -82,10 +82,8 @@
} }
&.popover { &.popover {
$color: #f9f9f9;
.popover-inner { .popover-inner {
background: $color; background: #f9f9f9;
color: black; color: black;
padding: 24px; padding: 24px;
border-radius: 5px; border-radius: 5px;
@ -93,7 +91,7 @@
} }
.popover-arrow { .popover-arrow {
border-color: $color; border-color: #f9f9f9;
} }
} }

52
resources/img/svg/at.svg Normal file
View File

@ -0,0 +1,52 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 512 512" style="enable-background:new 0 0 512 512;" xml:space="preserve">
<g>
<g>
<path d="M257,210c-24.814,0-45,20.186-45,45c0,24.814,20.186,45,45,45c24.814,0,45-20.186,45-45C302,230.186,281.814,210,257,210z
"/>
</g>
</g>
<g>
<g>
<path d="M255,0C114.39,0,0,114.39,0,255s114.39,257,255,257s257-116.39,257-257S395.61,0,255,0z M362,330
c-20.273,0-38.152-10.161-49.017-25.596C299.23,319.971,279.354,330,257,330c-41.353,0-75-33.647-75-75c0-41.353,33.647-75,75-75
c16.948,0,32.426,5.865,45,15.383V195c0-8.291,6.709-15,15-15c8.291,0,15,6.709,15,15c0,33.36,0,41.625,0,75
c0,16.538,13.462,30,30,30c16.538,0,30-13.462,30-30c0-100.391-66.432-150-135-150c-74.443,0-135,60.557-135,135
s60.557,135,135,135c30,0,58.374-9.609,82.061-27.803c15.822-12.078,33.94,11.765,18.281,23.789
C328.353,408.237,293.665,420,257,420c-90.981,0-165-74.019-165-165S166.019,90,257,90c82.897,0,165,61.135,165,180
C422,303.091,395.091,330,362,330z"/>
</g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

60
resources/js/app.js vendored
View File

@ -1,56 +1,50 @@
import Vue from 'vue'; import Vue from 'vue';
import {App as InertiaApp, plugin, Link as ILink} from '@inertiajs/inertia-vue'; import {App as InertiaApp, plugin, Link as ILink} from '@inertiajs/inertia-vue';
import {Inertia} from '@inertiajs/inertia'; import {Inertia} from '@inertiajs/inertia';
import SvgSprite from './components/SvgSprite.js';
import VPages from './components/VPages.vue';
import VLabel from './components/VLabel.vue';
import VBool from './components/VBool.vue';
import Box from './components/Box.vue';
import Heading from './components/Heading.vue';
import IconButton from './components/Ui/IconButton.vue';
import ToolbarButton from './components/Ui/ToolbarButton.vue';
import PageLayout from './components/Page/Layout.vue';
import AppLayout from './layouts/AppLayout.vue';
import VTooltip from 'v-tooltip';
import hasModule from './mixins/hasModule.js';
import hasFlash from './mixins/hasFlash.js';
import PortalVue from 'portal-vue'; import PortalVue from 'portal-vue';
import axios from 'axios'; import axios from 'axios';
import VueAxios from 'vue-axios'; import VueAxios from 'vue-axios';
import Toasted from 'vue-toasted'; import Toasted from 'vue-toasted';
import VTooltip from 'v-tooltip';
import {createPinia, PiniaVuePlugin} from 'pinia'; import {createPinia, PiniaVuePlugin} from 'pinia';
import requireModules from './lib/requireModules.js';
import AppLayout from './layouts/AppLayout.vue';
import hasModule from './mixins/hasModule.js';
import hasFlash from './mixins/hasFlash.js';
import '../css/app.css';
// ---------------------------------- Assets -----------------------------------
import.meta.glob(['../img/**']);
// ---------------------------------- Plugins ----------------------------------
Vue.use(plugin); Vue.use(plugin);
Vue.use(PortalVue); Vue.use(PortalVue);
Vue.use(VTooltip); Vue.use(VTooltip);
Vue.use(Toasted); Vue.use(Toasted);
Vue.use(VueAxios, axios); Vue.use(VueAxios, axios);
Vue.use(PiniaVuePlugin); Vue.use(PiniaVuePlugin);
Vue.component('f-text', () => import(/* webpackChunkName: "form" */ './components/FText'));
Vue.component('f-switch', () => import(/* webpackChunkName: "form" */ './components/FSwitch'));
Vue.component('f-select', () => import(/* webpackChunkName: "form" */ './components/FSelect'));
Vue.component('f-textarea', () => import(/* webpackChunkName: "form" */ './components/FTextarea'));
Vue.component('SvgSprite', SvgSprite);
Vue.component('VPages', VPages);
Vue.component('v-bool', VBool);
Vue.component('v-label', VLabel);
Vue.component('box', Box);
Vue.component('heading', Heading);
Vue.component('icon-button', IconButton);
Vue.component('toolbar-button', ToolbarButton);
Vue.component('page-layout', PageLayout);
Vue.component('save-button', () => import(/* webpackChunkName: "form" */ './components/SaveButton'));
Vue.component('SvgSprite', () => import('./components/SvgSprite.js'));
Vue.component('ILink', ILink);
// -------------------------------- Components ---------------------------------
requireModules(import.meta.glob('./components/form/*.vue'), Vue, 'f');
requireModules(import.meta.glob('./components/ui/*.vue'), Vue, 'ui');
requireModules(import.meta.glob('./components/page/*.vue', {eager: true}), Vue, 'page');
// ---------------------------------- mixins -----------------------------------
Vue.mixin(hasModule);
Vue.mixin(hasFlash);
// ----------------------------------- init ------------------------------------
const el = document.getElementById('app'); const el = document.getElementById('app');
const pinia = createPinia(); const pinia = createPinia();
Vue.mixin(hasModule);
Vue.mixin(hasFlash);
Vue.component('ILink', ILink);
Inertia.on('start', (event) => window.dispatchEvent(new Event('inertiaStart'))); Inertia.on('start', (event) => window.dispatchEvent(new Event('inertiaStart')));
let views = import.meta.glob('./views/**/*.vue');
new Vue({ new Vue({
pinia, pinia,
render: (h) => render: (h) =>
@ -58,7 +52,7 @@ new Vue({
props: { props: {
initialPage: JSON.parse(el.dataset.page), initialPage: JSON.parse(el.dataset.page),
resolveComponent: async (name) => { resolveComponent: async (name) => {
var page = (await import(`./views/${name}`)).default; var page = (await views[`./views/${name}.vue`]()).default;
if (page.layout === undefined) { if (page.layout === undefined) {
page.layout = AppLayout; page.layout = AppLayout;

View File

@ -1,144 +0,0 @@
<template>
<label class="flex flex-col relative field-checkbox cursor-pointer" :for="id" :class="{[`size-${size}`]: true}">
<span
v-if="label && inset"
class="z-10 absolute top-0 left-0 -mt-2 px-1 ml-3 inset-bg font-semibold text-gray-700"
>{{ label }}</span
>
<div class="relative flex items-start">
<input :id="id" type="checkbox" v-model="v" :disabled="disabled" class="invisible absolute" />
<span class="display-wrapper flex items-center">
<span
class="relative cursor-pointer flex flex-none justify-center items-center display"
:class="{'bg-terminoto-2': v === true, 'bg-white': v === false}"
>
<svg-sprite src="check" class="w-4 h-4 check-icon text-white"></svg-sprite>
</span>
</span>
<span
v-if="label && !inset"
class="text-sm leading-tight ml-3 text-gray-700 checkbox-label flex items-center"
>
<span>
<span v-text="label" v-if="!html"></span>
<span v-html="label" v-if="html"></span>
<span v-show="required" class="font-semibold text-red-700">*</span>
</span>
</span>
</div>
</label>
</template>
<script>
export default {
model: {
prop: 'items',
event: 'input',
},
props: {
html: {
type: Boolean,
default: false,
},
required: {
type: Boolean,
default: false,
},
inset: {
type: Boolean,
default: false,
},
size: {
default: null,
required: false,
},
id: {
required: true,
},
disabled: {
type: Boolean,
default: false,
},
value: {
default: false,
},
label: {
default: false,
},
items: {
default: undefined,
},
},
computed: {
v: {
set(v) {
if (this.disabled === true) {
return;
}
if (typeof this.items === 'boolean') {
this.$emit('input', v);
return;
}
var a = this.items.filter((i) => i !== this.value);
if (v) {
a.push(this.value);
}
this.$emit('input', a);
},
get() {
if (typeof this.items === 'boolean') {
return this.items;
}
if (typeof this.items === 'undefined') {
return this.$emit('input', false);
}
return this.items.indexOf(this.value) !== -1;
},
},
},
created() {
if (typeof this.items === 'undefined') {
this.$emit('input', false);
}
},
};
</script>
<style lang="css">
:root {
--checkbox-width: 30px;
--margin: 0.2rem;
}
.field-checkbox {
input:checked + span {
transition: background 0.2s;
}
.display-wrapper,
.checkbox-label {
min-height: 34px;
}
.display {
width: var(--checkbox-width);
height: var(--checkbox-width);
border-radius: 0.3rem;
border: solid 2px hsl(60, 1.8%, 10.8%);
.check-icon {
opacity: 0;
transition: opacity 0.2s;
}
}
input:checked + .display-wrapper .display .check-icon {
opacity: 1;
transition: opacity 0.2s;
}
}
</style>

View File

@ -1,9 +0,0 @@
<template>
<div class="font-semibold text-gray-300">
<slot></slot>
</div>
</template>
<script>
export default {};
</script>

View File

@ -1,48 +0,0 @@
<template>
<div class="h-16 px-6 flex justify-between items-center border-b border-solid border-gray-500">
<div class="flex items-center">
<span class="mr-1 text-xl font-semibold leading-none text-white" v-html="title"></span>
<a
v-for="(link, index) in links.filter((link) => link.icon === undefined)"
:key="index"
@click.prevent="$emit(link.event)"
href="#"
class="btn label btn-primary-light"
>
<span v-if="link.label" v-text="link.label"></span>
<svg-sprite v-if="link.icon" :src="link.icon"></svg-sprite>
</a>
<a
v-for="(link, index) in links.filter((link) => link.icon !== undefined)"
:key="index"
:href="link.href"
class="btn label icon btn-primary-light ml-1"
>
<span v-if="link.label" v-text="link.label"></span>
<svg-sprite v-if="link.icon" :src="link.icon"></svg-sprite>
</a>
</div>
<div class="flex ml-4">
<a href="#" @click.prevent="$emit('close')" class="btn label btn-primary-light icon">
<svg-sprite class="w-3 h-3" src="close"></svg-sprite>
</a>
</div>
</div>
</template>
<script>
export default {
props: {
links: {
default: function () {
return [];
},
},
title: {
default: function () {
return '';
},
},
},
};
</script>

View File

@ -1,17 +1,25 @@
export default { export default {
props: { props: {
src: { required: true, type: String } src: {required: true, type: String},
}, },
render: function(createElement) { render: function (createElement) {
var attr = this.$attrs.class ? this.$attrs.class : ''; var attr = this.$attrs.class ? this.$attrs.class : '';
return createElement('svg', { return createElement(
class: attr + ' fill-current' 'svg',
}, [ {
createElement('use', { class: attr + ' fill-current',
'attrs': { },
'xlink:href': `/img/sprite.svg#${this.$props.src}` [
} createElement(
}, '') 'use',
] ); {
} attrs: {
'xlink:href': `/sprite.svg#${this.$props.src}`,
},
},
''
),
]
);
},
}; };

View File

@ -0,0 +1,111 @@
<template>
<label class="field-wrap" :for="id" :class="`field-wrap-${size}`">
<span v-if="label" class="field-label">
{{ label }}
<span v-show="required" class="text-red-800">&nbsp;*</span>
</span>
<div class="relative real-field-wrap" :class="`size-${size}`">
<div
@click="visible = !visible"
class="flex items-center border-gray-600 text-gray-300 leading-none border-solid bg-gray-700 w-full appearance-none outline-none rounded-lg size-sm text-xs px-1 border pr-6"
v-text="`${value.length} Einträge ausgewählt`"
></div>
<div v-show="visible" class="absolute w-[max-content] z-30 max-h-[31rem] overflow-auto shadow-lg bg-gray-600 border border-gray-500 rounded-lg p-2 top-7">
<div v-for="(option, index) in parsedOptions" class="flex items-center space-x-2" :key="index">
<f-switch :id="`${id}-${index}`" size="sm" :items="value.includes(option.id)" :value="option.id" @input="trigger(option, $event)"></f-switch>
<div class="text-sm text-gray-200" v-text="option.name"></div>
</div>
</div>
<div class="info-wrap">
<div v-if="hint" v-tooltip="hint">
<svg-sprite src="info-button" class="info-button"></svg-sprite>
</div>
<div class="px-1 relative" v-if="size != 'xs'">
<svg-sprite class="chevron w-3 h-3 fill-current" src="chevron-down"></svg-sprite>
</div>
<div class="px-1 relative" v-if="size == 'xs'">
<svg-sprite class="chevron w-2 h-2 fill-current" src="chevron-down"></svg-sprite>
</div>
</div>
</div>
</label>
</template>
<script>
import map from 'lodash/map';
export default {
data: function () {
return {
visible: false,
};
},
props: {
disabled: {
type: Boolean,
default: function () {
return false;
},
},
id: {},
inset: {
type: Boolean,
default: false,
},
size: {
default: function () {
return 'base';
},
},
emptyLabel: {
default: false,
type: Boolean,
},
value: {
default: undefined,
},
label: {
default: null,
},
required: {
type: Boolean,
default: false,
},
name: {
required: true,
},
hint: {},
options: {
default: function () {
return [];
},
},
},
computed: {
parsedOptions() {
return Array.isArray(this.options)
? this.options
: map(this.options, (value, key) => {
return {name: value, id: key};
});
},
},
methods: {
trigger(option, v) {
var value = [...this.value];
this.$emit('input', value.includes(option.id) ? value.filter((cv) => cv !== option.id) : [...value, option.id]);
},
clear() {
this.$emit('input', null);
},
},
};
</script>
<style scope>
.inset-bg {
background: linear-gradient(to bottom, hsl(247.5, 66.7%, 97.6%) 0%, hsl(247.5, 66.7%, 97.6%) 41%, hsl(0deg 0% 100%) 41%, hsl(180deg 0% 100%) 100%);
}
</style>

View File

@ -8,12 +8,7 @@
<select :disabled="disabled" :name="name" :value="value" @change="trigger"> <select :disabled="disabled" :name="name" :value="value" @change="trigger">
<option v-if="placeholder" v-html="placeholder" :value="null"></option> <option v-if="placeholder" v-html="placeholder" :value="null"></option>
<option <option v-for="option in parsedOptions" :key="option.id" v-html="option.name" :value="option.id"></option>
v-for="option in parsedOptions"
:key="option.id"
v-html="option.name"
:value="option.id"
></option>
</select> </select>
<div class="info-wrap"> <div class="info-wrap">
<div v-if="hint" v-tooltip="hint"> <div v-if="hint" v-tooltip="hint">
@ -95,10 +90,7 @@ export default {
}, },
methods: { methods: {
trigger(v) { trigger(v) {
this.$emit( this.$emit('input', /^[0-9]+$/.test(v.target.value) ? parseInt(v.target.value) : v.target.value ? v.target.value : null);
'input',
isNaN(parseInt(v.target.value)) ? (v.target.value ? v.target.value : null) : parseInt(v.target.value)
);
}, },
clear() { clear() {
this.$emit('input', null); this.$emit('input', null);
@ -119,12 +111,6 @@ export default {
<style scope> <style scope>
.inset-bg { .inset-bg {
background: linear-gradient( background: linear-gradient(to bottom, hsl(247.5, 66.7%, 97.6%) 0%, hsl(247.5, 66.7%, 97.6%) 41%, hsl(0deg 0% 100%) 41%, hsl(180deg 0% 100%) 100%);
to bottom,
hsl(247.5, 66.7%, 97.6%) 0%,
hsl(247.5, 66.7%, 97.6%) 41%,
hsl(0deg 0% 100%) 41%,
hsl(180deg 0% 100%) 100%
);
} }
</style> </style>

View File

@ -0,0 +1,7 @@
<template>
<h1 class="text-xl border-b-2 pb-1 mb-4 text-primary-100 text-center border-primary-800"><slot></slot></h1>
</template>
<script>
export default {};
</script>

View File

@ -0,0 +1,27 @@
<template>
<div class="h-16 px-6 flex items-center justify-between border-b border-solid border-gray-600 group-[.is-bright]:border-gray-500">
<div class="flex items-center space-x-2">
<slot name="before-title"></slot>
<page-title>{{ title }}</page-title>
<slot name="toolbar"></slot>
</div>
<div class="flex items-center space-x-2 ml-2">
<a href="#" v-if="$listeners.close" @click.prevent="$emit('close')" class="btn label btn-primary-light icon">
<svg-sprite class="w-3 h-3" src="close"></svg-sprite>
</a>
<slot name="right"></slot>
</div>
</div>
</template>
<script>
export default {
props: {
title: {
default: function () {
return '';
},
},
},
};
</script>

View File

@ -1,16 +1,18 @@
<template> <template>
<div class="grow bg-gray-900 flex flex-col transition-all" :class="{'ml-56': menuStore.visible, 'ml-0': !menuStore.visible}"> <div class="grow bg-gray-900 flex flex-col transition-all" :class="{'ml-56': menuStore.visible, 'ml-0': !menuStore.visible}">
<div class="h-16 px-6 flex items-center space-x-3 border-b border-gray-600"> <page-header :title="$page.props.title">
<a href="#" @click.prevent="menuStore.toggle()" class="lg:hidden"> <template #before-title>
<a href="#" @click.prevent="menuStore.toggle()" class="mr-2 lg:hidden">
<svg-sprite src="menu" class="text-gray-100 w-5 h-5"></svg-sprite> <svg-sprite src="menu" class="text-gray-100 w-5 h-5"></svg-sprite>
</a> </a>
<span class="text-sm md:text-xl font-semibold text-white leading-none" v-html="$page.props.title"></span> </template>
<template #toolbar>
<slot name="toolbar"></slot> <slot name="toolbar"></slot>
<div class="flex grow justify-between"> </template>
<portal-target name="toolbar-left"> </portal-target> <template #right>
<portal-target name="toolbar-right"> </portal-target> <portal-target name="toolbar-right"> </portal-target>
</div> </template>
</div> </page-header>
<div :class="pageClass" class="grow flex flex-col"> <div :class="pageClass" class="grow flex flex-col">
<slot></slot> <slot></slot>

View File

@ -0,0 +1,7 @@
<template>
<span class="text-sm md:text-xl font-semibold leading-none text-white"><slot></slot></span>
</template>
<script>
export default {};
</script>

View File

@ -1,5 +1,5 @@
<template> <template>
<i-link :href="href" class="btn label mr-2" :class="colors[color]" v-tooltip="menuStore.tooltipsVisible ? $slots.default[0].text : ''"> <i-link :href="href" v-on="$listeners" class="btn label" :class="colors[color]" v-tooltip="menuStore.tooltipsVisible ? $slots.default[0].text : ''">
<svg-sprite v-show="icon" class="w-3 h-3 xl:mr-2" :src="icon"></svg-sprite> <svg-sprite v-show="icon" class="w-3 h-3 xl:mr-2" :src="icon"></svg-sprite>
<span class="hidden xl:inline"><slot></slot></span> <span class="hidden xl:inline"><slot></slot></span>
</i-link> </i-link>

View File

@ -1,17 +1,7 @@
<template> <template>
<div class="flex gap-1 justify-center items-center"> <div class="flex gap-1 justify-center items-center">
<svg-sprite <svg-sprite class="flex-none" v-if="member.is_leader" :class="[ageColors.leiter, iconClass]" src="lilie"></svg-sprite>
class="flex-none" <svg-sprite class="flex-none" v-if="member.age_group_icon" :class="[ageColors[member.age_group_icon], iconClass]" src="lilie"></svg-sprite>
v-if="member.is_leader"
:class="[ageColors.leiter, iconClass]"
src="lilie"
></svg-sprite>
<svg-sprite
class="flex-none"
v-if="member.age_group_icon"
:class="[ageColors[member.age_group_icon], iconClass]"
src="lilie"
></svg-sprite>
</div> </div>
</template> </template>

View File

@ -1,14 +1,7 @@
<template> <template>
<div v-tooltip="longLabel" class="flex space-x-2 items-center"> <div v-tooltip="longLabel" class="flex space-x-2 items-center">
<div <div class="border-2 rounded-full w-5 h-5 flex items-center justify-center" :class="value ? 'border-green-700' : 'border-red-700'">
class="border-2 rounded-full w-4 h-4 flex items-center justify-center" <svg-sprite :src="value ? 'check' : 'close'" :class="value ? 'text-green-800' : 'text-red-800'" class="w-3 h-3 flex-none"></svg-sprite>
:class="value ? 'border-green-700' : 'border-red-700'"
>
<svg-sprite
:src="value ? 'check' : 'close'"
:class="value ? 'text-green-800' : 'text-red-800'"
class="w-3 h-3 flex-none"
></svg-sprite>
</div> </div>
<div class="text-gray-400 text-xs" v-text="label"></div> <div class="text-gray-400 text-xs" v-text="label"></div>
</div> </div>

View File

@ -1,7 +1,7 @@
<template> <template>
<section class="p-3 rounded-lg flex flex-col" :class="{'bg-gray-800': second === false, 'bg-gray-700': second === true}"> <section class="p-3 rounded-lg flex flex-col" :class="{'bg-gray-800': second === false, 'bg-gray-700': second === true}">
<div class="flex items-center"> <div class="flex items-center">
<heading class="col-span-full" v-if="heading">{{ heading }}</heading> <div class="col-span-full font-semibold text-gray-300" v-if="heading" v-text="heading"></div>
<slot name="in-title"></slot> <slot name="in-title"></slot>
</div> </div>
<main :class="{'mt-2': heading, [containerClass]: true}"> <main :class="{'mt-2': heading, [containerClass]: true}">

View File

@ -0,0 +1,24 @@
<template>
<button v-on="$listeners" class="btn btn-primary relative group">
<div :class="{hidden: !isLoading, flex: isLoading}" class="absolute items-center top-0 h-full left-0 ml-2">
<ui-spinner class="border-primary-400 w-6 h-6 group-hover:border-primary-200"></ui-spinner>
</div>
<slot></slot>
</button>
</template>
<script>
import {menuStore} from '../../stores/menuStore.js';
export default {
data: function () {
return {};
},
props: {
isLoading: {
type: Boolean,
default: false,
},
},
};
</script>

View File

@ -0,0 +1,57 @@
<template>
<div :class="`spin-${type}`">
<div v-if="type === 'ring'"></div>
<div v-if="type === 'ring'"></div>
<div v-if="type === 'ring'"></div>
<div v-if="type === 'ring'"></div>
</div>
</template>
<script>
export default {
props: {
type: {
type: String,
default: () => 'ring',
},
},
};
</script>
<style>
.spin-ring {
display: inline-block;
position: relative;
}
.spin-ring div {
box-sizing: border-box;
display: block;
position: absolute;
width: 100%;
height: 100%;
border: 3px solid #fff;
border-radius: 50%;
animation: ring 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
border-top-color: inherit;
border-right-color: transparent;
border-bottom-color: transparent;
border-left-color: transparent;
}
.spin-ring div:nth-child(1) {
animation-delay: -0.45s;
}
.spin-ring div:nth-child(2) {
animation-delay: -0.3s;
}
.spin-ring div:nth-child(3) {
animation-delay: -0.15s;
}
@keyframes ring {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>

View File

@ -16,6 +16,7 @@
<v-link href="/subscription" v-show="hasModule('bill')" menu="subscription" icon="money">Beiträge</v-link> <v-link href="/subscription" v-show="hasModule('bill')" menu="subscription" icon="money">Beiträge</v-link>
<v-link href="/contribution" menu="contribution" icon="contribution">Zuschüsse</v-link> <v-link href="/contribution" menu="contribution" icon="contribution">Zuschüsse</v-link>
<v-link href="/activity" menu="activity" icon="activity">Tätigkeiten</v-link> <v-link href="/activity" menu="activity" icon="activity">Tätigkeiten</v-link>
<v-link href="/maildispatcher" menu="maildispatcher" icon="at">Mail-Verteiler</v-link>
</div> </div>
<div class="grid gap-2"> <div class="grid gap-2">
<v-link href="/setting" menu="setting" icon="setting">Einstellungen</v-link> <v-link href="/setting" menu="setting" icon="setting">Einstellungen</v-link>

View File

@ -1,5 +1,5 @@
<template> <template>
<div id="app" class="bg-gray-900 font-sans flex flex-col grow items-center justify-center"> <div id="app" class="bg-gray-900 font-sans flex flex-col grow items-center justify-center p-6">
<v-notification class="fixed z-40 right-0 bottom-0 mb-3 mr-3"></v-notification> <v-notification class="fixed z-40 right-0 bottom-0 mb-3 mr-3"></v-notification>
<div class="bg-gray-800 rounded-xl overflow-hidden shadow-lg p-6"> <div class="bg-gray-800 rounded-xl overflow-hidden shadow-lg p-6">
<slot></slot> <slot></slot>

9
resources/js/lib/requireModules.js vendored Normal file
View File

@ -0,0 +1,9 @@
import {paramCase} from 'change-case';
export default function (context, Vue, prefix) {
for (const file in context) {
let componentName = paramCase(`${prefix}${file.replace(/^.*\/(.*?)\.vue$/g, '$1')}`);
Vue.component(componentName, typeof context[file] === 'function' ? context[file] : context[file].default);
}
}

View File

@ -9,10 +9,13 @@ export default {
}, },
computed: { computed: {
filterString() { filterString() {
return btoa(encodeURIComponent(JSON.stringify(this.inner.meta.filter))); return this.toFilterString(this.inner.meta.filter);
}, },
}, },
methods: { methods: {
toFilterString(data) {
return btoa(encodeURIComponent(JSON.stringify(data)));
},
reload(resetPage = true) { reload(resetPage = true) {
var _self = this; var _self = this;
var data = { var data = {

View File

@ -1,63 +1,57 @@
<template> <template>
<div> <div>
<div v-if="step === 0"> <div v-if="step === 0">
<page-full-heading>Willkommen im Adrema-Setup.<br /></page-full-heading>
<div class="prose prose-invert"> <div class="prose prose-invert">
<p>Willkommen im Adrema-Setup.<br /></p> <p>Bitte gib deine NaMi-Zugangsdaten ein,<br />um eine erste Synchronisation durchzuführen.</p>
<p>
Bitte gib deine NaMi-Zugangsdaten ein,<br />
um eine erste Synchronisation durchzuführen.
</p>
</div> </div>
<form @submit.prevent="check" class="grid gap-3 mt-5"> <form @submit.prevent="check" class="grid gap-3 mt-5">
<f-text v-model="values.mglnr" label="Mitgliedsnummer" name="mglnr" id="mglnr" type="tel" required></f-text> <f-text v-model="values.mglnr" label="Mitgliedsnummer" name="mglnr" id="mglnr" type="tel" required></f-text>
<f-text v-model="values.password" type="password" label="Passwort" name="password" id="password" required></f-text> <f-text v-model="values.password" type="password" label="Passwort" name="password" id="password" required></f-text>
<button type="submit" class="btn w-full btn-primary mt-6 inline-block">Weiter</button> <ui-button class="mt-6" :is-loading="loading" type="submit">Weiter</ui-button>
</form> </form>
</div> </div>
<div v-if="step === 1" class="flex flex-col" style="width: 90vw; height: 90vh"> <div v-if="step === 1" class="grid grid-cols-5 w-full gap-3">
<form @submit.prevent="storeSearch" class="border-2 border-primary-700 border-solid p-3 rounded-lg grid grid-cols-3 gap-3 flex-none"> <page-full-heading class="col-span-full !mb-0">Suchkriterien festlegen</page-full-heading>
<form @submit.prevent="storeSearch" class="border-2 border-primary-800 border-solid p-3 rounded-lg grid gap-3 col-span-2">
<div class="prose prose-invert max-w-none col-span-full"> <div class="prose prose-invert max-w-none col-span-full">
<p> <p>
Lege hier die Suchkriterien für den Abruf der Mitglieder-Daten fest. Mit diesen Suchkriterien wird im Anschluss eine Mitgliedersuche in NaMi durchgeführt. Alle Mitglieder, die Lege hier die Suchkriterien für den Abruf der Mitglieder-Daten fest. Mit diesen Suchkriterien wird im Anschluss eine Mitgliedersuche in NaMi durchgeführt. Alle Mitglieder, die
dann dort auftauchen werden in die Adrema übernommen. Dir wird hier eine Vorschau eingeblendet, damit du sicherstellen kannst, dass die Suchkriterien die richtigen sind. dann dort auftauchen werden in die Adrema übernommen. Dir wird hier eine Vorschau eingeblendet, damit du sicherstellen kannst, dass die Suchkriterien die richtigen sind.
</p> </p>
<p>
Außerdem werden diese Suchkriterien bei jedem neuen Abgleich (der automatisch täglich erfolgt) angewendet. Du kannst die Suchkriterien in den globalen Einstellungen jederzeit
ändern.
</p>
</div> </div>
<f-text <f-select
v-model="values.params.gruppierung1Id" v-model="values.params.gruppierung1Id"
label="Diözesan-Gruppierung" label="Diözesan-Gruppierung"
name="gruppierung1Id" name="gruppierung1Id"
id="gruppierung1Id" id="gruppierung1Id"
type="tel"
size="sm" size="sm"
@input="search" :options="searchLayerOptions[0]"
@input="loadSearchLayer(1, $event, search)"
hint="Gruppierungs-Nummer einer Diözese, auf die die Mitglieder passen sollen. I.d.R. ist das die Gruppierungsnummer deiner Diözese. Entspricht dem Feld '1. Ebene' in der NaMi Suche." hint="Gruppierungs-Nummer einer Diözese, auf die die Mitglieder passen sollen. I.d.R. ist das die Gruppierungsnummer deiner Diözese. Entspricht dem Feld '1. Ebene' in der NaMi Suche."
></f-text> ></f-select>
<f-text <f-select
v-model="values.params.gruppierung2Id" v-model="values.params.gruppierung2Id"
label="Bezirks-Gruppierung" label="Bezirks-Gruppierung"
name="gruppierung2Id" name="gruppierung2Id"
id="gruppierung2Id" id="gruppierung2Id"
type="tel"
hint="Gruppierungs-Nummer eines Bezirks, auf die die Mitglieder passen sollen. I.d.R. ist das die Gruppierungsnummer deines Bezirks. Entspricht dem Feld '2. Ebene' in der NaMi Suche. Fülle dieses Feld aus, um Mitglieder auf einen bestimmten Bezirk zu begrenzen." hint="Gruppierungs-Nummer eines Bezirks, auf die die Mitglieder passen sollen. I.d.R. ist das die Gruppierungsnummer deines Bezirks. Entspricht dem Feld '2. Ebene' in der NaMi Suche. Fülle dieses Feld aus, um Mitglieder auf einen bestimmten Bezirk zu begrenzen."
:disabled="!values.params.gruppierung1Id" :disabled="!values.params.gruppierung1Id"
@input="search" @input="loadSearchLayer(2, $event, search)"
size="sm" size="sm"
></f-text> :options="searchLayerOptions[1]"
<f-text ></f-select>
<f-select
v-model="values.params.gruppierung3Id" v-model="values.params.gruppierung3Id"
label="Stammes-Gruppierung" label="Stammes-Gruppierung"
name="gruppierung3Id" name="gruppierung3Id"
id="gruppierung3Id" id="gruppierung3Id"
type="tel"
size="sm" size="sm"
@input="search" @input="search"
hint="Gruppierungs-Nummer deines Stammes, auf die die Mitglieder passen sollen. I.d.R. ist das die Gruppierungsnummer deines Stammes. Entspricht dem Feld '3. Ebene' in der NaMi Suche. Fülle dieses Feld aus, um Mitglieder auf einen bestimmten Stamm zu beschränken." hint="Gruppierungs-Nummer deines Stammes, auf die die Mitglieder passen sollen. I.d.R. ist das die Gruppierungsnummer deines Stammes. Entspricht dem Feld '3. Ebene' in der NaMi Suche. Fülle dieses Feld aus, um Mitglieder auf einen bestimmten Stamm zu beschränken."
:disabled="!values.params.gruppierung1Id || !values.params.gruppierung2Id" :disabled="!values.params.gruppierung1Id || !values.params.gruppierung2Id"
></f-text> :options="searchLayerOptions[2]"
></f-select>
<f-select <f-select
v-model="values.params.mglStatusId" v-model="values.params.mglStatusId"
label="Mitglieds-Status" label="Mitglieds-Status"
@ -86,12 +80,12 @@
hint="Mitglieder finden, die direktes Mitglied in einer Untergruppe der kleinsten befüllten Gruppierung sind." hint="Mitglieder finden, die direktes Mitglied in einer Untergruppe der kleinsten befüllten Gruppierung sind."
size="sm" size="sm"
></f-switch> ></f-switch>
<div class="col-span-full"> <div class="col-span-full flex justify-center">
<button type="submit" class="btn btn-primary btn-sm">Weiter</button> <ui-button :is-loading="loading" class="px-10" type="submit">Weiter</ui-button>
</div> </div>
</form> </form>
<section class="grow border-2 border-primary-700 border-solid p-3 rounded-lg mt-4" v-if="preview !== null && preview.data.length"> <section class="col-span-3 text-sm col-span-3" v-if="preview !== null && preview.data.length">
<table cellspacing="0" cellpadding="0" border="0" class="custom-table custom-table-sm hidden md:table"> <table cellspacing="0" cellpadding="0" border="0" class="custom-table custom-table-sm hidden md:table">
<thead> <thead>
<th>GruppierungsNr</th> <th>GruppierungsNr</th>
@ -111,12 +105,13 @@
</table> </table>
<div v-if="preview !== null" class="px-6"> <div v-if="preview !== null" class="px-6">
<v-pages class="mt-4" :value="preview" @reload="reloadPage"></v-pages> <ui-pagination class="mt-4" :value="preview" @reload="reloadPage"></ui-pagination>
</div> </div>
</section> </section>
<section class="grow items-center justify-center flex text-xl text-gray-200 border-2 border-primary-700 border-solid p-3 rounded-lg mt-4" v-else>Keine Mitglieder gefunden</section> <section class="col-span-3 items-center justify-center flex text-xl text-gray-200 border-2 border-primary-800 border-solid p-3 rounded-lg mt-4" v-else>Keine Mitglieder gefunden</section>
</div> </div>
<div v-if="step === 2"> <div v-if="step === 2">
<page-full-heading>Standard-Gruppierung</page-full-heading>
<div class="prose prose-invert"> <div class="prose prose-invert">
<p>Bitte gib hier deine Standard-Gruppierungsnummer ein.</p> <p>Bitte gib hier deine Standard-Gruppierungsnummer ein.</p>
<p>Dieser Gruppierung werden Mitglieder automatisch zugeordnet,<br />falls nichts anderes angegeben wurde.</p> <p>Dieser Gruppierung werden Mitglieder automatisch zugeordnet,<br />falls nichts anderes angegeben wurde.</p>
@ -128,6 +123,7 @@
</form> </form>
</div> </div>
<div v-if="step === 3"> <div v-if="step === 3">
<page-full-heading>Einrichtung abgeschlossen</page-full-heading>
<div class="prose prose-invert"> <div class="prose prose-invert">
<p>Wir werden nun die Mitgliederdaten anhand deiner festgelegten Kriterien abrufen.</p> <p>Wir werden nun die Mitgliederdaten anhand deiner festgelegten Kriterien abrufen.</p>
<p>Per Klick auf "Abschließen" gelangst du zum Dashboard</p> <p>Per Klick auf "Abschließen" gelangst du zum Dashboard</p>
@ -150,6 +146,8 @@ export default {
data: function () { data: function () {
return { return {
searchLayerOptions: [[], [], []],
loading: false,
preview: null, preview: null,
states: [ states: [
{id: 'INAKTIV', name: 'Inaktiv'}, {id: 'INAKTIV', name: 'Inaktiv'},
@ -188,23 +186,57 @@ export default {
await this.loadSearchResult(page); await this.loadSearchResult(page);
}, },
async check() { async check() {
this.loading = true;
try { try {
await this.axios.post('/nami/login-check', this.values); await this.axios.post('/nami/login-check', this.values);
this.step = 1;
await this.loadSearchResult(1); await this.loadSearchResult(1);
await this.loadSearchLayer(0, null, () => '');
this.step = 1;
} catch (e) { } catch (e) {
this.errorsFromException(e); this.errorsFromException(e);
} finally {
this.loading = false;
} }
}, },
search: debounce(async function () { search: debounce(async function () {
await this.loadSearchResult(1); await this.loadSearchResult(1);
}, 500), }, 500),
async loadSearchLayer(parentLayer, parent, after) {
this.loading = true;
try {
var result = await this.axios.post('/nami/get-search-layer', {...this.values, layer: parentLayer, parent});
this.searchLayerOptions = this.searchLayerOptions.map((layers, index) => {
if (index < parentLayer) {
return layers;
}
var groupIndex = index + 1;
this.values.params[`gruppierung${groupIndex}Id`] = null;
if (index === parentLayer) {
return result.data;
}
return [];
});
after();
} catch (e) {
this.errorsFromException(e);
} finally {
this.loading = false;
}
},
async loadSearchResult(page) { async loadSearchResult(page) {
this.loading = true;
try { try {
var result = await this.axios.post('/nami/search', {...this.values, page: page}); var result = await this.axios.post('/nami/search', {...this.values, page: page});
this.preview = result.data; this.preview = result.data;
} catch (e) { } catch (e) {
this.errorsFromException(e); this.errorsFromException(e);
} finally {
this.loading = false;
} }
}, },
}, },

View File

@ -2,7 +2,7 @@
<form @submit.prevent="submit"> <form @submit.prevent="submit">
<div class="h-24 p-6 md:px-10 bg-primary-800 flex justify-between items-center w-full"> <div class="h-24 p-6 md:px-10 bg-primary-800 flex justify-between items-center w-full">
<span class="text-primary-500 text-xl">Login</span> <span class="text-primary-500 text-xl">Login</span>
<img src="/img/dpsg.gif" class="w-24" /> <img src="../../img/dpsg.gif" class="w-24" />
</div> </div>
<div class="p-6 md:p-10 grid gap-5"> <div class="p-6 md:p-10 grid gap-5">
<f-text id="email" label="E-Mail-Adresse" v-model="values.email"></f-text> <f-text id="email" label="E-Mail-Adresse" v-model="values.email"></f-text>
@ -13,7 +13,7 @@
</template> </template>
<script> <script>
import FullLayout from '../layouts/FullLayout'; import FullLayout from '../layouts/FullLayout.vue';
export default { export default {
layout: FullLayout, layout: FullLayout,

Some files were not shown because too many files have changed in this diff Show More