feat(backend): commit uncommitted CustomField, dynamic HistoryType, and Import modules
Mirrors the frontend catch-up in 570f17e: a substantial amount of
finished-but-never-committed backend work had accumulated locally.
- New CustomField module: admin-managed field definitions per client,
reorderable, typed values stored per client (backing the frontend's
ClientCustomFieldCard + settings/fields page)
- History entries switch from a hardcoded type enum to a dynamic,
admin-managed HistoryType module (reorderable, backing
settings/history-types)
- New Import module: CSV client import (backing settings/import)
- Clients table gains soft deletes (deleted_at)
- Supporting factories, seeders, policies, and feature tests for all
of the above
All 63 backend tests pass locally before this commit.
This commit is contained in:
parent
570f17e8b8
commit
f0bb80707a
67 changed files with 2199 additions and 58 deletions
|
|
@ -14,7 +14,7 @@ ## Module pattern
|
||||||
## Commands
|
## Commands
|
||||||
```
|
```
|
||||||
composer test # Pest, SQLite in-memory
|
composer test # Pest, SQLite in-memory
|
||||||
composer static-analysis # pint --test + phpstan + phpmd (all must pass)
|
composer static-analysis # pint --test + phpstan + phpmd (user do the static code analysis manually)
|
||||||
php artisan migrate # run migrations (against Docker MySQL)
|
php artisan migrate # run migrations (against Docker MySQL)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,17 +3,21 @@
|
||||||
namespace App\Modules\Client\Actions;
|
namespace App\Modules\Client\Actions;
|
||||||
|
|
||||||
use App\Modules\Client\Models\Client;
|
use App\Modules\Client\Models\Client;
|
||||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
|
||||||
final class GetClientAction
|
final class GetClientAction
|
||||||
{
|
{
|
||||||
public function list(): LengthAwarePaginator
|
public function list(): Collection
|
||||||
{
|
{
|
||||||
return Client::orderBy('name')->paginate(50);
|
return Client::query()
|
||||||
|
->withCount('historyEntries')
|
||||||
|
->withMax('historyEntries', 'created_at')
|
||||||
|
->orderBy('name')
|
||||||
|
->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function find(int $id): Client
|
public function find(int $id): Client
|
||||||
{
|
{
|
||||||
return Client::findOrFail($id);
|
return Client::with('customFieldValues.author')->findOrFail($id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,4 +42,11 @@ public function update(UpdateClientRequest $request, Client $client, UpdateClien
|
||||||
$updated = $action->handle($client, ClientData::fromUpdateRequest($request));
|
$updated = $action->handle($client, ClientData::fromUpdateRequest($request));
|
||||||
return ClientResource::make($updated);
|
return ClientResource::make($updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function destroy(Client $client): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('delete', $client);
|
||||||
|
$client->delete();
|
||||||
|
return response()->json(null, 204);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,17 @@
|
||||||
|
|
||||||
namespace App\Modules\Client\Models;
|
namespace App\Modules\Client\Models;
|
||||||
|
|
||||||
|
use App\Modules\CustomField\Models\CustomFieldValue;
|
||||||
use App\Modules\History\Models\HistoryEntry;
|
use App\Modules\History\Models\HistoryEntry;
|
||||||
use Database\Factories\ClientFactory;
|
use Database\Factories\ClientFactory;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
class Client extends Model
|
class Client extends Model
|
||||||
{
|
{
|
||||||
use HasFactory;
|
use HasFactory, SoftDeletes;
|
||||||
|
|
||||||
protected $fillable = ['client_number', 'name', 'notes'];
|
protected $fillable = ['client_number', 'name', 'notes'];
|
||||||
|
|
||||||
|
|
@ -25,4 +27,9 @@ public function historyEntries(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(HistoryEntry::class);
|
return $this->hasMany(HistoryEntry::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function customFieldValues(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(CustomFieldValue::class);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,10 @@
|
||||||
|
|
||||||
namespace App\Modules\Client\Resources;
|
namespace App\Modules\Client\Resources;
|
||||||
|
|
||||||
|
use App\Modules\CustomField\Models\CustomFieldDefinition;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
class ClientResource extends JsonResource
|
class ClientResource extends JsonResource
|
||||||
{
|
{
|
||||||
|
|
@ -16,6 +18,50 @@ public function toArray(Request $request): array
|
||||||
'notes' => $this->notes ?? [],
|
'notes' => $this->notes ?? [],
|
||||||
'created_at' => $this->created_at->toISOString(),
|
'created_at' => $this->created_at->toISOString(),
|
||||||
'updated_at' => $this->updated_at->toISOString(),
|
'updated_at' => $this->updated_at->toISOString(),
|
||||||
|
// List/search endpoints load these via withCount/withMax; absent on
|
||||||
|
// the detail endpoint, so guard before exposing them.
|
||||||
|
'history_count' => $this->whenNotNull($this->history_entries_count),
|
||||||
|
'last_history_at' => $this->when(
|
||||||
|
$this->history_entries_count !== null,
|
||||||
|
fn () => $this->history_entries_max_created_at
|
||||||
|
? Carbon::parse($this->history_entries_max_created_at)->toISOString()
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
// Only present on the detail endpoint (where the relation is eager
|
||||||
|
// loaded); omitted from list responses to avoid per-row queries.
|
||||||
|
'custom_fields' => $this->when(
|
||||||
|
$this->resource->relationLoaded('customFieldValues'),
|
||||||
|
fn () => $this->buildCustomFields(),
|
||||||
|
),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge every active field definition (ordered) with this client's value,
|
||||||
|
* so unset fields still render with an empty state.
|
||||||
|
*
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function buildCustomFields(): array
|
||||||
|
{
|
||||||
|
$valuesByDefinition = $this->customFieldValues->keyBy('custom_field_definition_id');
|
||||||
|
|
||||||
|
return CustomFieldDefinition::orderBy('sort_order')->orderBy('id')->get()
|
||||||
|
->map(function (CustomFieldDefinition $definition) use ($valuesByDefinition) {
|
||||||
|
$value = $valuesByDefinition->get($definition->id);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'definition_id' => $definition->id,
|
||||||
|
'key' => $definition->key,
|
||||||
|
'label' => $definition->label,
|
||||||
|
'type' => $definition->type->value,
|
||||||
|
'options' => $definition->options ?? [],
|
||||||
|
'is_required' => $definition->is_required,
|
||||||
|
'value' => $value?->value,
|
||||||
|
'author_name' => $value?->author?->name,
|
||||||
|
'updated_at' => $value?->updated_at?->toISOString(),
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->all();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\CustomField\Actions;
|
||||||
|
|
||||||
|
use App\Modules\CustomField\DTOs\CustomFieldDefinitionData;
|
||||||
|
use App\Modules\CustomField\Models\CustomFieldDefinition;
|
||||||
|
use App\Modules\CustomField\Models\CustomFieldType;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
final class CreateCustomFieldDefinitionAction
|
||||||
|
{
|
||||||
|
public function handle(CustomFieldDefinitionData $data): CustomFieldDefinition
|
||||||
|
{
|
||||||
|
return CustomFieldDefinition::create([
|
||||||
|
'label' => $data->label,
|
||||||
|
'key' => $this->uniqueKey($data->label),
|
||||||
|
'type' => $data->type,
|
||||||
|
'options' => $data->type === CustomFieldType::Select->value ? array_values($data->options ?? []) : null,
|
||||||
|
'is_required' => $data->isRequired,
|
||||||
|
// New entries always land at the end of the list.
|
||||||
|
'sort_order' => (CustomFieldDefinition::max('sort_order') ?? -1) + 1,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function uniqueKey(string $label): string
|
||||||
|
{
|
||||||
|
$base = Str::slug($label, '_') ?: 'field';
|
||||||
|
$key = $base;
|
||||||
|
$i = 1;
|
||||||
|
|
||||||
|
while (CustomFieldDefinition::withTrashed()->where('key', $key)->exists()) {
|
||||||
|
$key = $base.'_'.(++$i);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\CustomField\Actions;
|
||||||
|
|
||||||
|
use App\Modules\CustomField\Models\CustomFieldDefinition;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
final class ReorderCustomFieldDefinitionsAction
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Reassign sort_order to match the given ordered list of ids,
|
||||||
|
* normalizing positions to a contiguous 0..n-1 sequence.
|
||||||
|
*
|
||||||
|
* @param array<int, int> $ids
|
||||||
|
*/
|
||||||
|
public function handle(array $ids): void
|
||||||
|
{
|
||||||
|
DB::transaction(function () use ($ids) {
|
||||||
|
foreach (array_values($ids) as $index => $id) {
|
||||||
|
CustomFieldDefinition::where('id', $id)->update(['sort_order' => $index]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\CustomField\Actions;
|
||||||
|
|
||||||
|
use App\Modules\CustomField\DTOs\CustomFieldDefinitionData;
|
||||||
|
use App\Modules\CustomField\Models\CustomFieldDefinition;
|
||||||
|
use App\Modules\CustomField\Models\CustomFieldType;
|
||||||
|
|
||||||
|
final class UpdateCustomFieldDefinitionAction
|
||||||
|
{
|
||||||
|
public function handle(CustomFieldDefinition $definition, CustomFieldDefinitionData $data): CustomFieldDefinition
|
||||||
|
{
|
||||||
|
// sort_order is managed exclusively via the reorder endpoint.
|
||||||
|
$definition->update([
|
||||||
|
'label' => $data->label,
|
||||||
|
'type' => $data->type,
|
||||||
|
'options' => $data->type === CustomFieldType::Select->value ? array_values($data->options ?? []) : null,
|
||||||
|
'is_required' => $data->isRequired,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $definition;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\CustomField\Actions;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Modules\Client\Models\Client;
|
||||||
|
use App\Modules\CustomField\Models\CustomFieldDefinition;
|
||||||
|
use App\Modules\CustomField\Models\CustomFieldValue;
|
||||||
|
use App\Modules\History\Models\HistoryEntry;
|
||||||
|
use App\Modules\History\Models\HistoryEntryType;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
final class UpdateCustomFieldValueAction
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Upsert a client's value for one custom field. When the value actually
|
||||||
|
* changes, append a read-only entry to the Verlauf log (history_entries)
|
||||||
|
* so every base-data change is audited alongside the manual notes.
|
||||||
|
*/
|
||||||
|
public function handle(
|
||||||
|
Client $client,
|
||||||
|
CustomFieldDefinition $definition,
|
||||||
|
?string $value,
|
||||||
|
User $author
|
||||||
|
): CustomFieldValue {
|
||||||
|
$new = $value === null || $value === '' ? null : (string) $value;
|
||||||
|
|
||||||
|
return DB::transaction(function () use ($client, $definition, $new, $author) {
|
||||||
|
$record = CustomFieldValue::firstOrNew([
|
||||||
|
'client_id' => $client->id,
|
||||||
|
'custom_field_definition_id' => $definition->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$old = $record->value;
|
||||||
|
|
||||||
|
if ($old === $new) {
|
||||||
|
return $record;
|
||||||
|
}
|
||||||
|
|
||||||
|
$record->value = $new;
|
||||||
|
$record->author_id = $author->id;
|
||||||
|
$record->save();
|
||||||
|
|
||||||
|
HistoryEntry::create([
|
||||||
|
'client_id' => $client->id,
|
||||||
|
'type' => HistoryEntryType::FieldChange,
|
||||||
|
'body' => $this->changeBody($definition->label, $old, $new),
|
||||||
|
'custom_field_definition_id' => $definition->id,
|
||||||
|
'author_id' => $author->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $record;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function changeBody(string $label, ?string $old, ?string $new): string
|
||||||
|
{
|
||||||
|
if ($old === null) {
|
||||||
|
return "{$label} gesetzt: „{$new}“";
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($new === null) {
|
||||||
|
return "{$label} entfernt (war „{$old}“)";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "{$label} geändert: „{$old}“ → „{$new}“";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\CustomField\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Modules\CustomField\Actions\CreateCustomFieldDefinitionAction;
|
||||||
|
use App\Modules\CustomField\Actions\ReorderCustomFieldDefinitionsAction;
|
||||||
|
use App\Modules\CustomField\Actions\UpdateCustomFieldDefinitionAction;
|
||||||
|
use App\Modules\CustomField\DTOs\CustomFieldDefinitionData;
|
||||||
|
use App\Modules\CustomField\Models\CustomFieldDefinition;
|
||||||
|
use App\Modules\CustomField\Requests\CreateCustomFieldDefinitionRequest;
|
||||||
|
use App\Modules\CustomField\Requests\ReorderCustomFieldDefinitionsRequest;
|
||||||
|
use App\Modules\CustomField\Requests\UpdateCustomFieldDefinitionRequest;
|
||||||
|
use App\Modules\CustomField\Resources\CustomFieldDefinitionResource;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
|
||||||
|
class CustomFieldDefinitionController extends Controller
|
||||||
|
{
|
||||||
|
public function index(): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
$this->authorize('viewAny', CustomFieldDefinition::class);
|
||||||
|
$definitions = CustomFieldDefinition::orderBy('sort_order')->orderBy('id')->get();
|
||||||
|
return CustomFieldDefinitionResource::collection($definitions);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(
|
||||||
|
CreateCustomFieldDefinitionRequest $request,
|
||||||
|
CreateCustomFieldDefinitionAction $action
|
||||||
|
): JsonResponse {
|
||||||
|
$this->authorize('create', CustomFieldDefinition::class);
|
||||||
|
$definition = $action->handle(CustomFieldDefinitionData::fromCreateRequest($request));
|
||||||
|
return CustomFieldDefinitionResource::make($definition)->response()->setStatusCode(201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(
|
||||||
|
UpdateCustomFieldDefinitionRequest $request,
|
||||||
|
CustomFieldDefinition $customFieldDefinition,
|
||||||
|
UpdateCustomFieldDefinitionAction $action
|
||||||
|
): CustomFieldDefinitionResource {
|
||||||
|
$this->authorize('update', $customFieldDefinition);
|
||||||
|
$updated = $action->handle($customFieldDefinition, CustomFieldDefinitionData::fromUpdateRequest($request));
|
||||||
|
return CustomFieldDefinitionResource::make($updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(CustomFieldDefinition $customFieldDefinition): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('delete', $customFieldDefinition);
|
||||||
|
$customFieldDefinition->delete();
|
||||||
|
return response()->json(null, 204);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reorder(
|
||||||
|
ReorderCustomFieldDefinitionsRequest $request,
|
||||||
|
ReorderCustomFieldDefinitionsAction $action
|
||||||
|
): AnonymousResourceCollection {
|
||||||
|
$this->authorize('reorder', CustomFieldDefinition::class);
|
||||||
|
$action->handle($request->validated('ids'));
|
||||||
|
$definitions = CustomFieldDefinition::orderBy('sort_order')->orderBy('id')->get();
|
||||||
|
return CustomFieldDefinitionResource::collection($definitions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\CustomField\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Modules\Client\Models\Client;
|
||||||
|
use App\Modules\CustomField\Actions\UpdateCustomFieldValueAction;
|
||||||
|
use App\Modules\CustomField\Models\CustomFieldDefinition;
|
||||||
|
use App\Modules\CustomField\Requests\UpdateCustomFieldValueRequest;
|
||||||
|
use App\Modules\Client\Resources\ClientResource;
|
||||||
|
use App\Modules\Client\Actions\GetClientAction;
|
||||||
|
|
||||||
|
class CustomFieldValueController extends Controller
|
||||||
|
{
|
||||||
|
public function update(
|
||||||
|
UpdateCustomFieldValueRequest $request,
|
||||||
|
Client $client,
|
||||||
|
CustomFieldDefinition $customFieldDefinition,
|
||||||
|
UpdateCustomFieldValueAction $action,
|
||||||
|
GetClientAction $clients
|
||||||
|
): ClientResource {
|
||||||
|
$this->authorize('update', $client);
|
||||||
|
|
||||||
|
$action->handle(
|
||||||
|
$client,
|
||||||
|
$customFieldDefinition,
|
||||||
|
$request->validated('value'),
|
||||||
|
$request->user()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Return the full client so the SPA re-renders all field cards in one round-trip.
|
||||||
|
return ClientResource::make($clients->find($client->id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\CustomField\DTOs;
|
||||||
|
|
||||||
|
use App\Modules\CustomField\Requests\CreateCustomFieldDefinitionRequest;
|
||||||
|
use App\Modules\CustomField\Requests\UpdateCustomFieldDefinitionRequest;
|
||||||
|
|
||||||
|
final class CustomFieldDefinitionData
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly ?string $label,
|
||||||
|
public readonly ?string $type,
|
||||||
|
public readonly ?array $options,
|
||||||
|
public readonly bool $isRequired,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public static function fromCreateRequest(CreateCustomFieldDefinitionRequest $request): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
label: $request->validated('label'),
|
||||||
|
type: $request->validated('type'),
|
||||||
|
options: $request->validated('options'),
|
||||||
|
isRequired: (bool) $request->validated('is_required', false),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function fromUpdateRequest(UpdateCustomFieldDefinitionRequest $request): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
label: $request->validated('label'),
|
||||||
|
type: $request->validated('type'),
|
||||||
|
options: $request->validated('options'),
|
||||||
|
isRequired: (bool) $request->validated('is_required', false),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\CustomField\Models;
|
||||||
|
|
||||||
|
use Database\Factories\CustomFieldDefinitionFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
|
class CustomFieldDefinition extends Model
|
||||||
|
{
|
||||||
|
use HasFactory, SoftDeletes;
|
||||||
|
|
||||||
|
protected $fillable = ['label', 'key', 'type', 'options', 'is_required', 'sort_order'];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'type' => CustomFieldType::class,
|
||||||
|
'options' => 'array',
|
||||||
|
'is_required' => 'boolean',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function values(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(CustomFieldValue::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function newFactory(): CustomFieldDefinitionFactory
|
||||||
|
{
|
||||||
|
return CustomFieldDefinitionFactory::new();
|
||||||
|
}
|
||||||
|
}
|
||||||
12
backend/app/Modules/CustomField/Models/CustomFieldType.php
Normal file
12
backend/app/Modules/CustomField/Models/CustomFieldType.php
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\CustomField\Models;
|
||||||
|
|
||||||
|
enum CustomFieldType: string
|
||||||
|
{
|
||||||
|
case Text = 'text';
|
||||||
|
case Textarea = 'textarea';
|
||||||
|
case Number = 'number';
|
||||||
|
case Date = 'date';
|
||||||
|
case Select = 'select';
|
||||||
|
}
|
||||||
28
backend/app/Modules/CustomField/Models/CustomFieldValue.php
Normal file
28
backend/app/Modules/CustomField/Models/CustomFieldValue.php
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\CustomField\Models;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Modules\Client\Models\Client;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class CustomFieldValue extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = ['client_id', 'custom_field_definition_id', 'value', 'author_id'];
|
||||||
|
|
||||||
|
public function client(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Client::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function definition(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(CustomFieldDefinition::class, 'custom_field_definition_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function author(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'author_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\CustomField\Policies;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Modules\CustomField\Models\CustomFieldDefinition;
|
||||||
|
|
||||||
|
class CustomFieldDefinitionPolicy
|
||||||
|
{
|
||||||
|
// Every authenticated user needs the definitions to render the client page.
|
||||||
|
public function viewAny(User $user): bool { return true; }
|
||||||
|
|
||||||
|
// Configuration is admin-only.
|
||||||
|
public function create(User $user): bool { return $user->isAdmin(); }
|
||||||
|
public function update(User $user, CustomFieldDefinition $definition): bool { return $user->isAdmin(); }
|
||||||
|
public function delete(User $user, CustomFieldDefinition $definition): bool { return $user->isAdmin(); }
|
||||||
|
public function reorder(User $user): bool { return $user->isAdmin(); }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\CustomField\Requests;
|
||||||
|
|
||||||
|
use App\Modules\CustomField\Models\CustomFieldType;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rules\Enum;
|
||||||
|
|
||||||
|
class CreateCustomFieldDefinitionRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool { return true; }
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'label' => ['required', 'string', 'max:255'],
|
||||||
|
'type' => ['required', new Enum(CustomFieldType::class)],
|
||||||
|
'options' => ['nullable', 'array', 'required_if:type,select'],
|
||||||
|
'options.*' => ['string', 'max:255'],
|
||||||
|
'is_required' => ['boolean'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\CustomField\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class ReorderCustomFieldDefinitionsRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool { return true; }
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'ids' => ['required', 'array'],
|
||||||
|
'ids.*' => ['integer', 'exists:custom_field_definitions,id'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\CustomField\Requests;
|
||||||
|
|
||||||
|
use App\Modules\CustomField\Models\CustomFieldType;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rules\Enum;
|
||||||
|
|
||||||
|
class UpdateCustomFieldDefinitionRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool { return true; }
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'label' => ['required', 'string', 'max:255'],
|
||||||
|
'type' => ['required', new Enum(CustomFieldType::class)],
|
||||||
|
'options' => ['nullable', 'array', 'required_if:type,select'],
|
||||||
|
'options.*' => ['string', 'max:255'],
|
||||||
|
'is_required' => ['boolean'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\CustomField\Requests;
|
||||||
|
|
||||||
|
use App\Modules\CustomField\Models\CustomFieldDefinition;
|
||||||
|
use App\Modules\CustomField\Models\CustomFieldType;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class UpdateCustomFieldValueRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool { return true; }
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
/** @var CustomFieldDefinition $definition */
|
||||||
|
$definition = $this->route('customFieldDefinition');
|
||||||
|
|
||||||
|
$valueRules = $definition->is_required ? ['required'] : ['nullable'];
|
||||||
|
|
||||||
|
$valueRules[] = match ($definition->type) {
|
||||||
|
CustomFieldType::Number => 'numeric',
|
||||||
|
CustomFieldType::Date => 'date',
|
||||||
|
CustomFieldType::Select => Rule::in($definition->options ?? []),
|
||||||
|
default => 'string',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (in_array($definition->type, [CustomFieldType::Text, CustomFieldType::Textarea], true)) {
|
||||||
|
$valueRules[] = 'max:65535';
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['value' => $valueRules];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\CustomField\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
class CustomFieldDefinitionResource extends JsonResource
|
||||||
|
{
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'label' => $this->label,
|
||||||
|
'key' => $this->key,
|
||||||
|
'type' => $this->type->value,
|
||||||
|
'options' => $this->options ?? [],
|
||||||
|
'is_required' => $this->is_required,
|
||||||
|
'sort_order' => $this->sort_order,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -13,7 +13,8 @@ public function handle(Client $client, HistoryEntryData $data, User $author): Hi
|
||||||
{
|
{
|
||||||
return HistoryEntry::create([
|
return HistoryEntry::create([
|
||||||
'client_id' => $client->id,
|
'client_id' => $client->id,
|
||||||
'type' => $data->type,
|
'history_entry_type_id' => $data->historyEntryTypeId,
|
||||||
|
'occurred_at' => $data->occurredAt,
|
||||||
'body' => $data->body,
|
'body' => $data->body,
|
||||||
'author_id' => $author->id,
|
'author_id' => $author->id,
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\History\Actions;
|
||||||
|
|
||||||
|
use App\Modules\History\DTOs\HistoryTypeData;
|
||||||
|
use App\Modules\History\Models\HistoryType;
|
||||||
|
|
||||||
|
final class CreateHistoryTypeAction
|
||||||
|
{
|
||||||
|
public function handle(HistoryTypeData $data): HistoryType
|
||||||
|
{
|
||||||
|
return HistoryType::create([
|
||||||
|
'name' => $data->name,
|
||||||
|
'requires_datetime' => $data->requiresDatetime,
|
||||||
|
// New entries always land at the end of the list.
|
||||||
|
'sort_order' => (HistoryType::max('sort_order') ?? -1) + 1,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\History\Actions;
|
||||||
|
|
||||||
|
use App\Modules\History\Models\HistoryType;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
final class ReorderHistoryTypesAction
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Reassign sort_order to match the given ordered list of ids,
|
||||||
|
* normalizing positions to a contiguous 0..n-1 sequence.
|
||||||
|
*
|
||||||
|
* @param array<int, int> $ids
|
||||||
|
*/
|
||||||
|
public function handle(array $ids): void
|
||||||
|
{
|
||||||
|
DB::transaction(function () use ($ids) {
|
||||||
|
foreach (array_values($ids) as $index => $id) {
|
||||||
|
HistoryType::where('id', $id)->update(['sort_order' => $index]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,7 @@ public function handle(HistoryEntry $entry, HistoryEntryData $data, User $editor
|
||||||
{
|
{
|
||||||
$entry->update([
|
$entry->update([
|
||||||
'body' => $data->body,
|
'body' => $data->body,
|
||||||
|
'occurred_at' => $data->occurredAt,
|
||||||
'updated_by' => $editor->id,
|
'updated_by' => $editor->id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\History\Actions;
|
||||||
|
|
||||||
|
use App\Modules\History\DTOs\HistoryTypeData;
|
||||||
|
use App\Modules\History\Models\HistoryType;
|
||||||
|
|
||||||
|
final class UpdateHistoryTypeAction
|
||||||
|
{
|
||||||
|
public function handle(HistoryType $type, HistoryTypeData $data): HistoryType
|
||||||
|
{
|
||||||
|
// sort_order is managed exclusively via the reorder endpoint.
|
||||||
|
$type->update([
|
||||||
|
'name' => $data->name,
|
||||||
|
'requires_datetime' => $data->requiresDatetime,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -20,7 +20,7 @@ public function index(Client $client): AnonymousResourceCollection
|
||||||
{
|
{
|
||||||
$this->authorize('viewAny', HistoryEntry::class);
|
$this->authorize('viewAny', HistoryEntry::class);
|
||||||
$entries = $client->historyEntries()
|
$entries = $client->historyEntries()
|
||||||
->with(['author', 'updatedBy'])
|
->with(['author', 'updatedBy', 'entryType'])
|
||||||
->orderByDesc('created_at')
|
->orderByDesc('created_at')
|
||||||
->get();
|
->get();
|
||||||
return HistoryEntryResource::collection($entries);
|
return HistoryEntryResource::collection($entries);
|
||||||
|
|
@ -33,7 +33,7 @@ public function store(
|
||||||
): JsonResponse {
|
): JsonResponse {
|
||||||
$this->authorize('create', HistoryEntry::class);
|
$this->authorize('create', HistoryEntry::class);
|
||||||
$entry = $action->handle($client, HistoryEntryData::fromCreateRequest($request), $request->user());
|
$entry = $action->handle($client, HistoryEntryData::fromCreateRequest($request), $request->user());
|
||||||
return HistoryEntryResource::make($entry->load(['author']))->response()->setStatusCode(201);
|
return HistoryEntryResource::make($entry->load(['author', 'entryType']))->response()->setStatusCode(201);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update(
|
public function update(
|
||||||
|
|
@ -43,7 +43,7 @@ public function update(
|
||||||
): HistoryEntryResource {
|
): HistoryEntryResource {
|
||||||
$this->authorize('update', $historyEntry);
|
$this->authorize('update', $historyEntry);
|
||||||
$updated = $action->handle($historyEntry, HistoryEntryData::fromUpdateRequest($request), $request->user());
|
$updated = $action->handle($historyEntry, HistoryEntryData::fromUpdateRequest($request), $request->user());
|
||||||
return HistoryEntryResource::make($updated->load(['author', 'updatedBy']));
|
return HistoryEntryResource::make($updated->load(['author', 'updatedBy', 'entryType']));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function destroy(HistoryEntry $historyEntry): JsonResponse
|
public function destroy(HistoryEntry $historyEntry): JsonResponse
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\History\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Modules\History\Actions\CreateHistoryTypeAction;
|
||||||
|
use App\Modules\History\Actions\ReorderHistoryTypesAction;
|
||||||
|
use App\Modules\History\Actions\UpdateHistoryTypeAction;
|
||||||
|
use App\Modules\History\DTOs\HistoryTypeData;
|
||||||
|
use App\Modules\History\Models\HistoryType;
|
||||||
|
use App\Modules\History\Requests\CreateHistoryTypeRequest;
|
||||||
|
use App\Modules\History\Requests\ReorderHistoryTypesRequest;
|
||||||
|
use App\Modules\History\Requests\UpdateHistoryTypeRequest;
|
||||||
|
use App\Modules\History\Resources\HistoryTypeResource;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
|
||||||
|
class HistoryTypeController extends Controller
|
||||||
|
{
|
||||||
|
public function index(): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
$this->authorize('viewAny', HistoryType::class);
|
||||||
|
$types = HistoryType::orderBy('sort_order')->orderBy('id')->get();
|
||||||
|
return HistoryTypeResource::collection($types);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(
|
||||||
|
CreateHistoryTypeRequest $request,
|
||||||
|
CreateHistoryTypeAction $action
|
||||||
|
): JsonResponse {
|
||||||
|
$this->authorize('create', HistoryType::class);
|
||||||
|
$type = $action->handle(HistoryTypeData::fromCreateRequest($request));
|
||||||
|
return HistoryTypeResource::make($type)->response()->setStatusCode(201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(
|
||||||
|
UpdateHistoryTypeRequest $request,
|
||||||
|
HistoryType $historyType,
|
||||||
|
UpdateHistoryTypeAction $action
|
||||||
|
): HistoryTypeResource {
|
||||||
|
$this->authorize('update', $historyType);
|
||||||
|
$updated = $action->handle($historyType, HistoryTypeData::fromUpdateRequest($request));
|
||||||
|
return HistoryTypeResource::make($updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(HistoryType $historyType): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('delete', $historyType);
|
||||||
|
$historyType->delete();
|
||||||
|
return response()->json(null, 204);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reorder(
|
||||||
|
ReorderHistoryTypesRequest $request,
|
||||||
|
ReorderHistoryTypesAction $action
|
||||||
|
): AnonymousResourceCollection {
|
||||||
|
$this->authorize('reorder', HistoryType::class);
|
||||||
|
$action->handle($request->validated('ids'));
|
||||||
|
$types = HistoryType::orderBy('sort_order')->orderBy('id')->get();
|
||||||
|
return HistoryTypeResource::collection($types);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,23 +8,26 @@
|
||||||
final class HistoryEntryData
|
final class HistoryEntryData
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public readonly ?string $type,
|
public readonly ?int $historyEntryTypeId,
|
||||||
public readonly ?string $body,
|
public readonly ?string $body,
|
||||||
|
public readonly ?string $occurredAt,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public static function fromCreateRequest(CreateHistoryEntryRequest $request): self
|
public static function fromCreateRequest(CreateHistoryEntryRequest $request): self
|
||||||
{
|
{
|
||||||
return new self(
|
return new self(
|
||||||
type: $request->validated('type'),
|
historyEntryTypeId: (int) $request->validated('history_entry_type_id'),
|
||||||
body: $request->validated('body'),
|
body: $request->validated('body'),
|
||||||
|
occurredAt: $request->validated('occurred_at'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function fromUpdateRequest(UpdateHistoryEntryRequest $request): self
|
public static function fromUpdateRequest(UpdateHistoryEntryRequest $request): self
|
||||||
{
|
{
|
||||||
return new self(
|
return new self(
|
||||||
type: null,
|
historyEntryTypeId: null,
|
||||||
body: $request->validated('body'),
|
body: $request->validated('body'),
|
||||||
|
occurredAt: $request->validated('occurred_at'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
30
backend/app/Modules/History/DTOs/HistoryTypeData.php
Normal file
30
backend/app/Modules/History/DTOs/HistoryTypeData.php
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\History\DTOs;
|
||||||
|
|
||||||
|
use App\Modules\History\Requests\CreateHistoryTypeRequest;
|
||||||
|
use App\Modules\History\Requests\UpdateHistoryTypeRequest;
|
||||||
|
|
||||||
|
final class HistoryTypeData
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly ?string $name,
|
||||||
|
public readonly bool $requiresDatetime,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public static function fromCreateRequest(CreateHistoryTypeRequest $request): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
name: $request->validated('name'),
|
||||||
|
requiresDatetime: (bool) $request->validated('requires_datetime', false),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function fromUpdateRequest(UpdateHistoryTypeRequest $request): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
name: $request->validated('name'),
|
||||||
|
requiresDatetime: (bool) $request->validated('requires_datetime', false),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,15 +14,37 @@ class HistoryEntry extends Model
|
||||||
{
|
{
|
||||||
use HasFactory, SoftDeletes;
|
use HasFactory, SoftDeletes;
|
||||||
|
|
||||||
protected $fillable = ['client_id', 'type', 'body', 'author_id', 'updated_by'];
|
protected $fillable = [
|
||||||
|
'client_id',
|
||||||
|
'type',
|
||||||
|
'history_entry_type_id',
|
||||||
|
'occurred_at',
|
||||||
|
'body',
|
||||||
|
'custom_field_definition_id',
|
||||||
|
'author_id',
|
||||||
|
'updated_by',
|
||||||
|
];
|
||||||
|
|
||||||
protected $casts = ['type' => HistoryEntryType::class];
|
protected $casts = [
|
||||||
|
'type' => HistoryEntryType::class,
|
||||||
|
'occurred_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function isFieldChange(): bool
|
||||||
|
{
|
||||||
|
return $this->type === HistoryEntryType::FieldChange;
|
||||||
|
}
|
||||||
|
|
||||||
public function client(): BelongsTo
|
public function client(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Client::class);
|
return $this->belongsTo(Client::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function entryType(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(HistoryType::class, 'history_entry_type_id');
|
||||||
|
}
|
||||||
|
|
||||||
public function author(): BelongsTo
|
public function author(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(User::class, 'author_id');
|
return $this->belongsTo(User::class, 'author_id');
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,13 @@
|
||||||
|
|
||||||
namespace App\Modules\History\Models;
|
namespace App\Modules\History\Models;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* System kind flag for auto-generated audit entries. User-selectable Verlauf
|
||||||
|
* types are no longer hardcoded here — they live in the configurable
|
||||||
|
* `history_types` table (see HistoryType). Only the field-change audit kind
|
||||||
|
* remains a fixed system value.
|
||||||
|
*/
|
||||||
enum HistoryEntryType: string
|
enum HistoryEntryType: string
|
||||||
{
|
{
|
||||||
case PhoneCall = 'phone_call';
|
case FieldChange = 'field_change';
|
||||||
case Consultation = 'consultation';
|
|
||||||
case Recommendation = 'recommendation';
|
|
||||||
case Instruction = 'instruction';
|
|
||||||
case Remark = 'remark';
|
|
||||||
case Other = 'other';
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
24
backend/app/Modules/History/Models/HistoryType.php
Normal file
24
backend/app/Modules/History/Models/HistoryType.php
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\History\Models;
|
||||||
|
|
||||||
|
use Database\Factories\HistoryTypeFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
|
class HistoryType extends Model
|
||||||
|
{
|
||||||
|
use HasFactory, SoftDeletes;
|
||||||
|
|
||||||
|
protected $fillable = ['name', 'requires_datetime', 'sort_order'];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'requires_datetime' => 'boolean',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected static function newFactory(): HistoryTypeFactory
|
||||||
|
{
|
||||||
|
return HistoryTypeFactory::new();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,17 @@ class HistoryEntryPolicy
|
||||||
{
|
{
|
||||||
public function viewAny(User $user): bool { return true; }
|
public function viewAny(User $user): bool { return true; }
|
||||||
public function create(User $user): bool { return true; }
|
public function create(User $user): bool { return true; }
|
||||||
public function update(User $user, HistoryEntry $entry): bool { return true; }
|
|
||||||
public function delete(User $user, HistoryEntry $entry): bool { return true; }
|
// Only the entry's author may edit their own note; auto-generated change
|
||||||
|
// entries are read-only audit records and cannot be edited by anyone.
|
||||||
|
public function update(User $user, HistoryEntry $entry): bool
|
||||||
|
{
|
||||||
|
return ! $entry->isFieldChange() && $user->id === $entry->author_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only admins may delete Verlauf entries (manual notes and change entries alike).
|
||||||
|
public function delete(User $user, HistoryEntry $entry): bool
|
||||||
|
{
|
||||||
|
return $user->isAdmin();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
18
backend/app/Modules/History/Policies/HistoryTypePolicy.php
Normal file
18
backend/app/Modules/History/Policies/HistoryTypePolicy.php
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\History\Policies;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Modules\History\Models\HistoryType;
|
||||||
|
|
||||||
|
class HistoryTypePolicy
|
||||||
|
{
|
||||||
|
// Every authenticated user needs the types to render the Verlauf form.
|
||||||
|
public function viewAny(User $user): bool { return true; }
|
||||||
|
|
||||||
|
// Configuration is admin-only.
|
||||||
|
public function create(User $user): bool { return $user->isAdmin(); }
|
||||||
|
public function update(User $user, HistoryType $type): bool { return $user->isAdmin(); }
|
||||||
|
public function delete(User $user, HistoryType $type): bool { return $user->isAdmin(); }
|
||||||
|
public function reorder(User $user): bool { return $user->isAdmin(); }
|
||||||
|
}
|
||||||
|
|
@ -2,9 +2,9 @@
|
||||||
|
|
||||||
namespace App\Modules\History\Requests;
|
namespace App\Modules\History\Requests;
|
||||||
|
|
||||||
use App\Modules\History\Models\HistoryEntryType;
|
use App\Modules\History\Models\HistoryType;
|
||||||
|
use Illuminate\Contracts\Validation\Validator;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
use Illuminate\Validation\Rules\Enum;
|
|
||||||
|
|
||||||
class CreateHistoryEntryRequest extends FormRequest
|
class CreateHistoryEntryRequest extends FormRequest
|
||||||
{
|
{
|
||||||
|
|
@ -13,8 +13,29 @@ public function authorize(): bool { return true; }
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'type' => ['required', new Enum(HistoryEntryType::class)],
|
'history_entry_type_id' => ['required', 'integer', 'exists:history_types,id'],
|
||||||
'body' => ['required', 'string', 'max:65535'],
|
'body' => ['required', 'string', 'max:65535'],
|
||||||
|
// The picker emits a naive local datetime; presence is enforced
|
||||||
|
// conditionally below based on the chosen type.
|
||||||
|
'occurred_at' => ['nullable', 'date_format:Y-m-d\TH:i:s'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function withValidator(Validator $validator): void
|
||||||
|
{
|
||||||
|
$validator->after(function (Validator $validator) {
|
||||||
|
$typeId = $this->input('history_entry_type_id');
|
||||||
|
if (! $typeId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$type = HistoryType::find($typeId);
|
||||||
|
if ($type && $type->requires_datetime && ! $this->filled('occurred_at')) {
|
||||||
|
$validator->errors()->add(
|
||||||
|
'occurred_at',
|
||||||
|
'Für diesen Verlaufstyp sind Datum und Uhrzeit erforderlich.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\History\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class CreateHistoryTypeRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool { return true; }
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'requires_datetime' => ['boolean'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\History\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class ReorderHistoryTypesRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool { return true; }
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'ids' => ['required', 'array'],
|
||||||
|
'ids.*' => ['integer', 'exists:history_types,id'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
|
|
||||||
namespace App\Modules\History\Requests;
|
namespace App\Modules\History\Requests;
|
||||||
|
|
||||||
|
use App\Modules\History\Models\HistoryEntry;
|
||||||
|
use Illuminate\Contracts\Validation\Validator;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
class UpdateHistoryEntryRequest extends FormRequest
|
class UpdateHistoryEntryRequest extends FormRequest
|
||||||
|
|
@ -12,6 +14,24 @@ public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'body' => ['required', 'string', 'max:65535'],
|
'body' => ['required', 'string', 'max:65535'],
|
||||||
|
'occurred_at' => ['nullable', 'date_format:Y-m-d\TH:i:s'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function withValidator(Validator $validator): void
|
||||||
|
{
|
||||||
|
$validator->after(function (Validator $validator) {
|
||||||
|
$entry = $this->route('historyEntry');
|
||||||
|
|
||||||
|
if ($entry instanceof HistoryEntry
|
||||||
|
&& $entry->entryType?->requires_datetime
|
||||||
|
&& ! $this->filled('occurred_at')
|
||||||
|
) {
|
||||||
|
$validator->errors()->add(
|
||||||
|
'occurred_at',
|
||||||
|
'Für diesen Verlaufstyp sind Datum und Uhrzeit erforderlich.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\History\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class UpdateHistoryTypeRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool { return true; }
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'requires_datetime' => ['boolean'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,8 +12,17 @@ public function toArray(Request $request): array
|
||||||
return [
|
return [
|
||||||
'id' => $this->id,
|
'id' => $this->id,
|
||||||
'client_id' => $this->client_id,
|
'client_id' => $this->client_id,
|
||||||
'type' => $this->type->value,
|
'is_field_change' => $this->isFieldChange(),
|
||||||
|
'history_entry_type_id' => $this->history_entry_type_id,
|
||||||
|
// Field-change audit rows render under a fixed system label; user
|
||||||
|
// entries take their configured type name.
|
||||||
|
'type_name' => $this->isFieldChange() ? 'Stammdatenänderung' : $this->entryType?->name,
|
||||||
|
'requires_datetime' => $this->entryType?->requires_datetime ?? false,
|
||||||
|
// Naive local datetime (no Z/offset) so the user-typed HH:MM
|
||||||
|
// round-trips without timezone drift.
|
||||||
|
'occurred_at' => $this->occurred_at?->format('Y-m-d\TH:i:s'),
|
||||||
'body' => $this->body,
|
'body' => $this->body,
|
||||||
|
'custom_field_definition_id' => $this->custom_field_definition_id,
|
||||||
'author_id' => $this->author_id,
|
'author_id' => $this->author_id,
|
||||||
'author_name' => $this->author?->name,
|
'author_name' => $this->author?->name,
|
||||||
'updated_by' => $this->updated_by,
|
'updated_by' => $this->updated_by,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\History\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
class HistoryTypeResource extends JsonResource
|
||||||
|
{
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'name' => $this->name,
|
||||||
|
'requires_datetime' => $this->requires_datetime,
|
||||||
|
'sort_order' => $this->sort_order,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
225
backend/app/Modules/Import/Actions/ImportClientsAction.php
Normal file
225
backend/app/Modules/Import/Actions/ImportClientsAction.php
Normal file
|
|
@ -0,0 +1,225 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Import\Actions;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Modules\Client\Models\Client;
|
||||||
|
use App\Modules\CustomField\Actions\UpdateCustomFieldValueAction;
|
||||||
|
use App\Modules\CustomField\Models\CustomFieldDefinition;
|
||||||
|
use App\Modules\CustomField\Models\CustomFieldValue;
|
||||||
|
use App\Modules\Import\DTOs\ImportResult;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use League\Csv\Reader;
|
||||||
|
use RuntimeException;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Imports clients from the legacy semicolon-delimited CSV export.
|
||||||
|
*
|
||||||
|
* Per row, keyed on "Mandantennr." (client_number):
|
||||||
|
* - new number → create client + populate custom fields, silently (no history)
|
||||||
|
* - existing number → skip, unless "Ueberschreiben" = "ja" (case-insensitive),
|
||||||
|
* in which case the mapped custom fields are updated (blanks
|
||||||
|
* skipped) and each change is audited via the shared
|
||||||
|
* UpdateCustomFieldValueAction, authored by a system user.
|
||||||
|
*/
|
||||||
|
final class ImportClientsAction
|
||||||
|
{
|
||||||
|
/** Custom field definition keys the import maps onto. */
|
||||||
|
private const FIELD_KEYS = ['telefonnummer', 'e_mail', 'ansprechpartner', 'anschrift'];
|
||||||
|
|
||||||
|
private const OVERWRITE_TRIGGER = 'ja';
|
||||||
|
|
||||||
|
private const IMPORT_USER_NAME = 'Mandanten-Import';
|
||||||
|
|
||||||
|
private const IMPORT_USER_EMAIL = 'import@system.local';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly UpdateCustomFieldValueAction $updateValue,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function handle(string $path): ImportResult
|
||||||
|
{
|
||||||
|
/** @var \Illuminate\Support\Collection<string, CustomFieldDefinition> $definitions */
|
||||||
|
$definitions = CustomFieldDefinition::query()
|
||||||
|
->whereIn('key', self::FIELD_KEYS)
|
||||||
|
->get()
|
||||||
|
->keyBy('key');
|
||||||
|
|
||||||
|
$missing = array_diff(self::FIELD_KEYS, $definitions->keys()->all());
|
||||||
|
if ($missing !== []) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
'Fehlende Custom-Field-Definitionen: '.implode(', ', $missing).
|
||||||
|
'. Bitte den CustomFieldDefinitionSeeder ausführen.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$importUser = $this->systemUser();
|
||||||
|
|
||||||
|
$rows = $this->readRows($path);
|
||||||
|
if ($rows === []) {
|
||||||
|
return new ImportResult(0, 0, 0, 0, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
$header = array_map(static fn (string $h): string => trim($h), array_shift($rows));
|
||||||
|
/** @var array<string, int> $index header name → column offset */
|
||||||
|
$index = array_flip($header);
|
||||||
|
|
||||||
|
$created = 0;
|
||||||
|
$updated = 0;
|
||||||
|
$skipped = 0;
|
||||||
|
/** @var array<int, array{row: int, client_number: ?string, message: string}> $errors */
|
||||||
|
$errors = [];
|
||||||
|
|
||||||
|
foreach ($rows as $offset => $row) {
|
||||||
|
$rowNumber = (int) $offset + 2; // +1 for header, +1 for 1-based
|
||||||
|
$clientNumber = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$clientNumber = $this->value($row, $index, 'Mandantennr.');
|
||||||
|
if ($clientNumber === null) {
|
||||||
|
$errors[] = ['row' => $rowNumber, 'client_number' => null, 'message' => 'Keine Mandantennummer.'];
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$mapped = $this->mapFields($row, $index);
|
||||||
|
$client = Client::query()->where('client_number', $clientNumber)->first();
|
||||||
|
|
||||||
|
if ($client !== null) {
|
||||||
|
if (! $this->isOverwrite($row, $index)) {
|
||||||
|
$skipped++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (self::FIELD_KEYS as $key) {
|
||||||
|
if ($mapped[$key] === null) {
|
||||||
|
continue; // skip blanks — never wipe existing data
|
||||||
|
}
|
||||||
|
$this->updateValue->handle($client, $definitions[$key], $mapped[$key], $importUser);
|
||||||
|
}
|
||||||
|
$updated++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$client = Client::create([
|
||||||
|
'client_number' => $clientNumber,
|
||||||
|
'name' => $this->value($row, $index, 'Mandant') ?? $clientNumber,
|
||||||
|
]);
|
||||||
|
|
||||||
|
foreach (self::FIELD_KEYS as $key) {
|
||||||
|
if ($mapped[$key] === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
CustomFieldValue::create([
|
||||||
|
'client_id' => $client->id,
|
||||||
|
'custom_field_definition_id' => $definitions[$key]->id,
|
||||||
|
'value' => $mapped[$key],
|
||||||
|
'author_id' => $importUser->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
$created++;
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$errors[] = ['row' => $rowNumber, 'client_number' => $clientNumber, 'message' => $e->getMessage()];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ImportResult(count($rows), $created, $updated, $skipped, $errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the CSV into numeric record arrays (no header offset) so ragged data
|
||||||
|
* rows — far shorter than the ~66-column header — never throw. Decodes the
|
||||||
|
* legacy ISO-8859-1 encoding to UTF-8 and strips a UTF-8 BOM if present.
|
||||||
|
*
|
||||||
|
* @return array<int, array<int, string>>
|
||||||
|
*/
|
||||||
|
private function readRows(string $path): array
|
||||||
|
{
|
||||||
|
$contents = (string) file_get_contents($path);
|
||||||
|
$contents = preg_replace('/^\xEF\xBB\xBF/', '', $contents) ?? $contents;
|
||||||
|
|
||||||
|
if (! mb_check_encoding($contents, 'UTF-8')) {
|
||||||
|
$contents = mb_convert_encoding($contents, 'UTF-8', 'Windows-1252');
|
||||||
|
}
|
||||||
|
|
||||||
|
$csv = Reader::createFromString($contents);
|
||||||
|
$csv->setDelimiter(';');
|
||||||
|
|
||||||
|
return array_values(iterator_to_array($csv->getRecords(), false));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map the four target custom fields from a row. Anschrift 1–8 are joined
|
||||||
|
* with newlines, blank parts dropped.
|
||||||
|
*
|
||||||
|
* @param array<int, string> $row
|
||||||
|
* @param array<string, int> $index
|
||||||
|
* @return array<string, ?string>
|
||||||
|
*/
|
||||||
|
private function mapFields(array $row, array $index): array
|
||||||
|
{
|
||||||
|
$address = [];
|
||||||
|
for ($n = 1; $n <= 8; $n++) {
|
||||||
|
$part = $this->value($row, $index, "Anschrift {$n}");
|
||||||
|
if ($part !== null) {
|
||||||
|
$address[] = $part;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'telefonnummer' => $this->value($row, $index, 'Telefon'),
|
||||||
|
'e_mail' => $this->value($row, $index, 'E-Mail'),
|
||||||
|
'ansprechpartner' => $this->value($row, $index, 'Ansprechpartner 1'),
|
||||||
|
'anschrift' => $address === [] ? null : implode("\n", $address),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $row
|
||||||
|
* @param array<string, int> $index
|
||||||
|
*/
|
||||||
|
private function isOverwrite(array $row, array $index): bool
|
||||||
|
{
|
||||||
|
$flag = $this->value($row, $index, 'Ueberschreiben');
|
||||||
|
|
||||||
|
return $flag !== null && mb_strtolower($flag) === self::OVERWRITE_TRIGGER;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a cell by header name (bounds-safe for ragged rows), trim it,
|
||||||
|
* strip a single leading Excel apostrophe, and normalise empty → null.
|
||||||
|
*
|
||||||
|
* @param array<int, string> $row
|
||||||
|
* @param array<string, int> $index
|
||||||
|
*/
|
||||||
|
private function value(array $row, array $index, string $name): ?string
|
||||||
|
{
|
||||||
|
if (! array_key_exists($name, $index)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$raw = $row[$index[$name]] ?? '';
|
||||||
|
$raw = trim($raw);
|
||||||
|
if ($raw !== '' && $raw[0] === "'") {
|
||||||
|
$raw = trim(substr($raw, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $raw === '' ? null : $raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function systemUser(): User
|
||||||
|
{
|
||||||
|
return User::firstOrCreate(
|
||||||
|
['email' => self::IMPORT_USER_EMAIL],
|
||||||
|
[
|
||||||
|
'name' => self::IMPORT_USER_NAME,
|
||||||
|
'password' => Hash::make(Str::random(40)),
|
||||||
|
'role' => 'staff',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Import\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Modules\Import\Actions\ImportClientsAction;
|
||||||
|
use App\Modules\Import\Requests\ClientImportRequest;
|
||||||
|
use App\Modules\Import\Resources\ImportResultResource;
|
||||||
|
|
||||||
|
class ClientImportController extends Controller
|
||||||
|
{
|
||||||
|
public function store(ClientImportRequest $request, ImportClientsAction $action): ImportResultResource
|
||||||
|
{
|
||||||
|
// Import is admin-only. The frontend route middleware is not a security
|
||||||
|
// boundary, and ClientPolicy::create() grants everyone create access.
|
||||||
|
abort_unless((bool) $request->user()?->isAdmin(), 403);
|
||||||
|
|
||||||
|
$result = $action->handle((string) $request->file('file')->getRealPath());
|
||||||
|
|
||||||
|
return new ImportResultResource($result);
|
||||||
|
}
|
||||||
|
}
|
||||||
17
backend/app/Modules/Import/DTOs/ImportResult.php
Normal file
17
backend/app/Modules/Import/DTOs/ImportResult.php
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Import\DTOs;
|
||||||
|
|
||||||
|
final class ImportResult
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<int, array{row: int, client_number: ?string, message: string}> $errors
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public readonly int $total,
|
||||||
|
public readonly int $created,
|
||||||
|
public readonly int $updated,
|
||||||
|
public readonly int $skipped,
|
||||||
|
public readonly array $errors,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
40
backend/app/Modules/Import/Requests/ClientImportRequest.php
Normal file
40
backend/app/Modules/Import/Requests/ClientImportRequest.php
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Import\Requests;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
|
||||||
|
class ClientImportRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
// Validate by extension only — Windows CSV exports arrive with a
|
||||||
|
// variety of MIME types (text/plain, application/vnd.ms-excel), so a
|
||||||
|
// strict `mimes:csv` rule would reject legitimate files.
|
||||||
|
'file' => [
|
||||||
|
'required',
|
||||||
|
'file',
|
||||||
|
'max:10240',
|
||||||
|
function (string $attribute, mixed $value, Closure $fail): void {
|
||||||
|
$ext = $value instanceof UploadedFile
|
||||||
|
? strtolower($value->getClientOriginalExtension())
|
||||||
|
: '';
|
||||||
|
if (! in_array($ext, ['csv', 'txt'], true)) {
|
||||||
|
$fail('Bitte eine CSV-Datei (.csv oder .txt) hochladen.');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Import\Resources;
|
||||||
|
|
||||||
|
use App\Modules\Import\DTOs\ImportResult;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @property ImportResult $resource
|
||||||
|
*/
|
||||||
|
class ImportResultResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'total' => $this->resource->total,
|
||||||
|
'created' => $this->resource->created,
|
||||||
|
'updated' => $this->resource->updated,
|
||||||
|
'skipped' => $this->resource->skipped,
|
||||||
|
'errors' => $this->resource->errors,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,15 +11,25 @@ final class SearchClientsAction
|
||||||
public function handle(string $query): Collection
|
public function handle(string $query): Collection
|
||||||
{
|
{
|
||||||
if (DB::connection()->getDriverName() === 'sqlite') {
|
if (DB::connection()->getDriverName() === 'sqlite') {
|
||||||
return Client::where('name', 'like', "%{$query}%")
|
return Client::query()
|
||||||
->orWhere('client_number', 'like', "%{$query}%")
|
->withCount('historyEntries')
|
||||||
|
->withMax('historyEntries', 'created_at')
|
||||||
|
->where(function ($q) use ($query) {
|
||||||
|
$q->where('name', 'like', "%{$query}%")
|
||||||
|
->orWhere('client_number', 'like', "%{$query}%");
|
||||||
|
})
|
||||||
->limit(50)
|
->limit(50)
|
||||||
->get();
|
->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
return Client::whereRaw(
|
return Client::query()
|
||||||
'MATCH(client_number, name) AGAINST(? IN BOOLEAN MODE)',
|
->withCount('historyEntries')
|
||||||
[$query . '*']
|
->withMax('historyEntries', 'created_at')
|
||||||
)->limit(50)->get();
|
->whereRaw(
|
||||||
|
'MATCH(client_number, name) AGAINST(? IN BOOLEAN MODE)',
|
||||||
|
[$query.'*']
|
||||||
|
)
|
||||||
|
->limit(50)
|
||||||
|
->get();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
namespace App\Modules\User\Requests;
|
namespace App\Modules\User\Requests;
|
||||||
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
class CreateUserRequest extends FormRequest
|
class CreateUserRequest extends FormRequest
|
||||||
|
|
@ -12,6 +13,13 @@ public function authorize(): bool
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
if ($this->has('email')) {
|
||||||
|
$this->merge(['email' => Str::lower($this->input('email'))]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
namespace App\Modules\User\Requests;
|
namespace App\Modules\User\Requests;
|
||||||
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
class UpdateUserRequest extends FormRequest
|
class UpdateUserRequest extends FormRequest
|
||||||
|
|
@ -12,6 +13,13 @@ public function authorize(): bool
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
if ($this->has('email')) {
|
||||||
|
$this->merge(['email' => Str::lower($this->input('email'))]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
|
@ -19,7 +27,7 @@ public function rules(): array
|
||||||
'email' => [
|
'email' => [
|
||||||
'required',
|
'required',
|
||||||
'email',
|
'email',
|
||||||
Rule::unique('users')->whereNull('deleted_at')->ignore($this->user('id')),
|
Rule::unique('users')->whereNull('deleted_at')->ignore($this->route('user')),
|
||||||
],
|
],
|
||||||
'password' => ['nullable', 'string', 'min:8', 'confirmed'],
|
'password' => ['nullable', 'string', 'min:8', 'confirmed'],
|
||||||
'role' => ['required', 'string', 'in:admin,staff'],
|
'role' => ['required', 'string', 'in:admin,staff'],
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,12 @@
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Modules\Client\Models\Client;
|
use App\Modules\Client\Models\Client;
|
||||||
use App\Modules\Client\Policies\ClientPolicy;
|
use App\Modules\Client\Policies\ClientPolicy;
|
||||||
|
use App\Modules\CustomField\Models\CustomFieldDefinition;
|
||||||
|
use App\Modules\CustomField\Policies\CustomFieldDefinitionPolicy;
|
||||||
use App\Modules\History\Models\HistoryEntry;
|
use App\Modules\History\Models\HistoryEntry;
|
||||||
|
use App\Modules\History\Models\HistoryType;
|
||||||
use App\Modules\History\Policies\HistoryEntryPolicy;
|
use App\Modules\History\Policies\HistoryEntryPolicy;
|
||||||
|
use App\Modules\History\Policies\HistoryTypePolicy;
|
||||||
use App\Modules\User\Policies\UserPolicy;
|
use App\Modules\User\Policies\UserPolicy;
|
||||||
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
|
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
|
||||||
|
|
||||||
|
|
@ -21,7 +25,9 @@ class AuthServiceProvider extends ServiceProvider
|
||||||
protected $policies = [
|
protected $policies = [
|
||||||
User::class => UserPolicy::class,
|
User::class => UserPolicy::class,
|
||||||
Client::class => ClientPolicy::class,
|
Client::class => ClientPolicy::class,
|
||||||
|
CustomFieldDefinition::class => CustomFieldDefinitionPolicy::class,
|
||||||
HistoryEntry::class => HistoryEntryPolicy::class,
|
HistoryEntry::class => HistoryEntryPolicy::class,
|
||||||
|
HistoryType::class => HistoryTypePolicy::class,
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
35
backend/database/factories/CustomFieldDefinitionFactory.php
Normal file
35
backend/database/factories/CustomFieldDefinitionFactory.php
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Modules\CustomField\Models\CustomFieldDefinition;
|
||||||
|
use App\Modules\CustomField\Models\CustomFieldType;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class CustomFieldDefinitionFactory extends Factory
|
||||||
|
{
|
||||||
|
protected $model = CustomFieldDefinition::class;
|
||||||
|
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
$label = $this->faker->unique()->words(2, true);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'label' => ucfirst($label),
|
||||||
|
'key' => Str::slug($label, '_'),
|
||||||
|
'type' => CustomFieldType::Text->value,
|
||||||
|
'options' => null,
|
||||||
|
'is_required' => false,
|
||||||
|
'sort_order' => 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function select(array $options): static
|
||||||
|
{
|
||||||
|
return $this->state(fn () => [
|
||||||
|
'type' => CustomFieldType::Select->value,
|
||||||
|
'options' => $options,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Modules\Client\Models\Client;
|
use App\Modules\Client\Models\Client;
|
||||||
use App\Modules\History\Models\HistoryEntry;
|
use App\Modules\History\Models\HistoryEntry;
|
||||||
use App\Modules\History\Models\HistoryEntryType;
|
use App\Modules\History\Models\HistoryType;
|
||||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
class HistoryEntryFactory extends Factory
|
class HistoryEntryFactory extends Factory
|
||||||
|
|
@ -16,7 +16,8 @@ public function definition(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'client_id' => Client::factory(),
|
'client_id' => Client::factory(),
|
||||||
'type' => $this->faker->randomElement(HistoryEntryType::cases())->value,
|
'history_entry_type_id' => HistoryType::factory(),
|
||||||
|
'occurred_at' => null,
|
||||||
'body' => $this->faker->paragraph(),
|
'body' => $this->faker->paragraph(),
|
||||||
'author_id' => User::factory(),
|
'author_id' => User::factory(),
|
||||||
'updated_by' => null,
|
'updated_by' => null,
|
||||||
|
|
|
||||||
25
backend/database/factories/HistoryTypeFactory.php
Normal file
25
backend/database/factories/HistoryTypeFactory.php
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Modules\History\Models\HistoryType;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
class HistoryTypeFactory extends Factory
|
||||||
|
{
|
||||||
|
protected $model = HistoryType::class;
|
||||||
|
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => $this->faker->unique()->word(),
|
||||||
|
'requires_datetime' => false,
|
||||||
|
'sort_order' => 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function requiresDatetime(): static
|
||||||
|
{
|
||||||
|
return $this->state(fn () => ['requires_datetime' => true]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,11 +14,22 @@ public function up(): void
|
||||||
Schema::create('history_entries', function (Blueprint $table) {
|
Schema::create('history_entries', function (Blueprint $table) {
|
||||||
$table->id();
|
$table->id();
|
||||||
$table->foreignId('client_id')->constrained()->cascadeOnDelete();
|
$table->foreignId('client_id')->constrained()->cascadeOnDelete();
|
||||||
$table->enum('type', [
|
// System kind flag: only set for auto-generated audit entries
|
||||||
'phone_call', 'consultation', 'recommendation',
|
// ('field_change'). User entries leave this null and reference a
|
||||||
'instruction', 'remark', 'other',
|
// configurable history_types row via history_entry_type_id below.
|
||||||
]);
|
$table->enum('type', ['field_change'])->nullable();
|
||||||
|
// Configurable type for user entries. Plain nullable column (no FK):
|
||||||
|
// history_types is created in a later migration and soft-deletes,
|
||||||
|
// mirroring the custom_field_definition_id pattern.
|
||||||
|
$table->foreignId('history_entry_type_id')->nullable();
|
||||||
|
// User-entered event time (naive local, no TZ conversion). Null for
|
||||||
|
// entries whose type does not require a date/time.
|
||||||
|
$table->dateTime('occurred_at')->nullable();
|
||||||
$table->text('body');
|
$table->text('body');
|
||||||
|
// Audit link for auto-generated custom-field change entries. Plain
|
||||||
|
// nullable column (no FK): the definitions table is created in a
|
||||||
|
// later migration and definitions soft-delete, so no cascade needed.
|
||||||
|
$table->foreignId('custom_field_definition_id')->nullable();
|
||||||
$table->foreignId('author_id')->constrained('users');
|
$table->foreignId('author_id')->constrained('users');
|
||||||
$table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();
|
$table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();
|
||||||
$table->softDeletes();
|
$table->softDeletes();
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('custom_field_definitions', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('label');
|
||||||
|
$table->string('key')->unique();
|
||||||
|
$table->enum('type', ['text', 'textarea', 'number', 'date', 'select']);
|
||||||
|
$table->json('options')->nullable();
|
||||||
|
$table->boolean('is_required')->default(false);
|
||||||
|
$table->integer('sort_order')->default(0);
|
||||||
|
$table->softDeletes();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('custom_field_definitions');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('custom_field_values', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('client_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->foreignId('custom_field_definition_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->text('value')->nullable();
|
||||||
|
$table->foreignId('author_id')->nullable()->constrained('users')->nullOnDelete();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique(['client_id', 'custom_field_definition_id']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('custom_field_values');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('history_types', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('name');
|
||||||
|
// When true, the add-entry form requires a user-entered date/time
|
||||||
|
// (occurred_at) and the entry renders as "Name – DD.MM.YYYY HH:MM".
|
||||||
|
$table->boolean('requires_datetime')->default(false);
|
||||||
|
$table->integer('sort_order')->default(0);
|
||||||
|
$table->softDeletes();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('history_types');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('clients', function (Blueprint $table) {
|
||||||
|
$table->softDeletes();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('clients', function (Blueprint $table) {
|
||||||
|
$table->dropSoftDeletes();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
39
backend/database/seeders/CustomFieldDefinitionSeeder.php
Normal file
39
backend/database/seeders/CustomFieldDefinitionSeeder.php
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Modules\CustomField\Models\CustomFieldDefinition;
|
||||||
|
use App\Modules\CustomField\Models\CustomFieldType;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers the base-data custom fields the Mandanten-Import maps onto.
|
||||||
|
*
|
||||||
|
* Idempotent (firstOrCreate by key) so it is safe to run against a real
|
||||||
|
* database — the importer resolves these definitions by key and fails fast
|
||||||
|
* if they are absent.
|
||||||
|
*/
|
||||||
|
class CustomFieldDefinitionSeeder extends Seeder
|
||||||
|
{
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$fields = [
|
||||||
|
['key' => 'telefonnummer', 'label' => 'Telefon 1', 'type' => CustomFieldType::Text, 'sort_order' => 1],
|
||||||
|
['key' => 'e_mail', 'label' => 'E-Mail', 'type' => CustomFieldType::Text, 'sort_order' => 2],
|
||||||
|
['key' => 'ansprechpartner', 'label' => 'Ansprechpartner', 'type' => CustomFieldType::Text, 'sort_order' => 3],
|
||||||
|
['key' => 'anschrift', 'label' => 'Anschrift', 'type' => CustomFieldType::Textarea, 'sort_order' => 4],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($fields as $field) {
|
||||||
|
CustomFieldDefinition::firstOrCreate(
|
||||||
|
['key' => $field['key']],
|
||||||
|
[
|
||||||
|
'label' => $field['label'],
|
||||||
|
'type' => $field['type'],
|
||||||
|
'is_required' => false,
|
||||||
|
'sort_order' => $field['sort_order'],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,7 +4,10 @@
|
||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Modules\Client\Models\Client;
|
use App\Modules\Client\Models\Client;
|
||||||
|
use App\Modules\CustomField\Models\CustomFieldDefinition;
|
||||||
|
use App\Modules\CustomField\Models\CustomFieldType;
|
||||||
use App\Modules\History\Models\HistoryEntry;
|
use App\Modules\History\Models\HistoryEntry;
|
||||||
|
use App\Modules\History\Models\HistoryType;
|
||||||
use Illuminate\Database\Seeder;
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
class DatabaseSeeder extends Seeder
|
class DatabaseSeeder extends Seeder
|
||||||
|
|
@ -18,13 +21,34 @@ public function run(): void
|
||||||
'role' => 'admin',
|
'role' => 'admin',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Base-data custom fields targeted by the Mandanten-Import (idempotent).
|
||||||
|
$this->call(CustomFieldDefinitionSeeder::class);
|
||||||
|
|
||||||
|
|
||||||
|
// Configurable Verlauf types. Telefonat & Beratungsgespräch carry a
|
||||||
|
// user-entered event date/time; the rest render as the plain name.
|
||||||
|
$types = [
|
||||||
|
['name' => 'Telefonat', 'requires_datetime' => true, 'sort_order' => 1],
|
||||||
|
['name' => 'Beratungsgespräch', 'requires_datetime' => true, 'sort_order' => 2],
|
||||||
|
['name' => 'Empfehlung', 'requires_datetime' => false, 'sort_order' => 3],
|
||||||
|
['name' => 'Anweisung', 'requires_datetime' => false, 'sort_order' => 4],
|
||||||
|
['name' => 'Bemerkung', 'requires_datetime' => false, 'sort_order' => 5],
|
||||||
|
['name' => 'Sonstiges', 'requires_datetime' => false, 'sort_order' => 6],
|
||||||
|
];
|
||||||
|
|
||||||
|
$historyTypes = collect($types)->map(fn (array $t) => HistoryType::create($t));
|
||||||
|
|
||||||
$clients = Client::factory(10)->create();
|
$clients = Client::factory(10)->create();
|
||||||
|
|
||||||
$clients->each(function (Client $client) use ($admin) {
|
$clients->each(function (Client $client) use ($admin, $historyTypes) {
|
||||||
HistoryEntry::factory(3)->create([
|
$historyTypes->random(3)->each(function (HistoryType $type) use ($client, $admin) {
|
||||||
'client_id' => $client->id,
|
HistoryEntry::factory()->create([
|
||||||
'author_id' => $admin->id,
|
'client_id' => $client->id,
|
||||||
]);
|
'author_id' => $admin->id,
|
||||||
|
'history_entry_type_id' => $type->id,
|
||||||
|
'occurred_at' => $type->requires_datetime ? now()->subDays(rand(1, 30)) : null,
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,10 @@
|
||||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||||
<env name="CACHE_DRIVER" value="array"/>
|
<env name="CACHE_DRIVER" value="array"/>
|
||||||
<env name="DB_CONNECTION" value="sqlite"/>
|
<env name="DB_CONNECTION" value="sqlite"/>
|
||||||
<env name="DB_DATABASE" value=":memory:"/>
|
<env name="DB_DATABASE" value=":memory:" force="true"/>
|
||||||
|
<!-- Container sets DB_DATABASE in the env, which lands in $_SERVER; Laravel's env()
|
||||||
|
reads $_SERVER before $_ENV, so force it here too to keep tests on :memory:. -->
|
||||||
|
<server name="DB_DATABASE" value=":memory:" force="true"/>
|
||||||
<env name="APP_KEY" value="base64:dGVzdHRlc3R0ZXN0dGVzdHRlc3R0ZXN0dGVzdHRlc3Q="/>
|
<env name="APP_KEY" value="base64:dGVzdHRlc3R0ZXN0dGVzdHRlc3R0ZXN0dGVzdHRlc3Q="/>
|
||||||
<env name="MAIL_MAILER" value="array"/>
|
<env name="MAIL_MAILER" value="array"/>
|
||||||
<env name="PULSE_ENABLED" value="false"/>
|
<env name="PULSE_ENABLED" value="false"/>
|
||||||
|
|
|
||||||
|
|
@ -65,3 +65,35 @@
|
||||||
it('unauthenticated request returns 401', function () {
|
it('unauthenticated request returns 401', function () {
|
||||||
$this->getJson('/api/clients')->assertStatus(401);
|
$this->getJson('/api/clients')->assertStatus(401);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('admin can soft delete a client', function () {
|
||||||
|
$admin = User::factory()->create(['role' => 'admin']);
|
||||||
|
$client = Client::factory()->create();
|
||||||
|
|
||||||
|
$response = $this->actingAs($admin)->deleteJson("/api/clients/{$client->id}");
|
||||||
|
|
||||||
|
$response->assertStatus(204);
|
||||||
|
// Row is retained, only soft-deleted (deleted_at set)
|
||||||
|
$this->assertSoftDeleted('clients', ['id' => $client->id]);
|
||||||
|
$this->assertDatabaseHas('clients', ['id' => $client->id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('staff cannot delete a client', function () {
|
||||||
|
$staff = User::factory()->create(['role' => 'staff']);
|
||||||
|
$client = Client::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($staff)->deleteJson("/api/clients/{$client->id}")
|
||||||
|
->assertStatus(403);
|
||||||
|
|
||||||
|
$this->assertNotSoftDeleted('clients', ['id' => $client->id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('soft deleted clients are excluded from listings', function () {
|
||||||
|
$admin = User::factory()->create(['role' => 'admin']);
|
||||||
|
Client::factory(2)->create();
|
||||||
|
Client::factory()->create()->delete();
|
||||||
|
|
||||||
|
$this->actingAs($admin)->getJson('/api/clients')
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertJsonCount(2, 'data');
|
||||||
|
});
|
||||||
|
|
|
||||||
158
backend/tests/Feature/Modules/CustomField/CustomFieldTest.php
Normal file
158
backend/tests/Feature/Modules/CustomField/CustomFieldTest.php
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Modules\Client\Models\Client;
|
||||||
|
use App\Modules\CustomField\Models\CustomFieldDefinition;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('lets any authenticated user list field definitions', function () {
|
||||||
|
$user = User::factory()->create(['role' => 'staff']);
|
||||||
|
CustomFieldDefinition::factory(2)->create();
|
||||||
|
|
||||||
|
$this->actingAs($user)->getJson('/api/custom-fields')
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertJsonCount(2, 'data');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets an admin create a field definition and derives a unique key', function () {
|
||||||
|
$admin = User::factory()->create(['role' => 'admin']);
|
||||||
|
|
||||||
|
$this->actingAs($admin)->postJson('/api/custom-fields', [
|
||||||
|
'label' => 'Beratungsschwerpunkt',
|
||||||
|
'type' => 'select',
|
||||||
|
'options' => ['Steuerrecht', 'Erbrecht'],
|
||||||
|
])->assertStatus(201)->assertJsonPath('data.key', 'beratungsschwerpunkt');
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('custom_field_definitions', ['label' => 'Beratungsschwerpunkt']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends new field definitions to the end of the list', function () {
|
||||||
|
$admin = User::factory()->create(['role' => 'admin']);
|
||||||
|
CustomFieldDefinition::factory()->create(['sort_order' => 2]);
|
||||||
|
|
||||||
|
$this->actingAs($admin)->postJson('/api/custom-fields', [
|
||||||
|
'label' => 'Zuletzt',
|
||||||
|
'type' => 'text',
|
||||||
|
])->assertStatus(201)->assertJsonPath('data.sort_order', 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets an admin reorder field definitions', function () {
|
||||||
|
$admin = User::factory()->create(['role' => 'admin']);
|
||||||
|
$a = CustomFieldDefinition::factory()->create(['sort_order' => 0]);
|
||||||
|
$b = CustomFieldDefinition::factory()->create(['sort_order' => 1]);
|
||||||
|
$c = CustomFieldDefinition::factory()->create(['sort_order' => 2]);
|
||||||
|
|
||||||
|
$this->actingAs($admin)->postJson('/api/custom-fields/reorder', [
|
||||||
|
'ids' => [$c->id, $a->id, $b->id],
|
||||||
|
])->assertStatus(200)
|
||||||
|
->assertJsonPath('data.0.id', $c->id)
|
||||||
|
->assertJsonPath('data.1.id', $a->id)
|
||||||
|
->assertJsonPath('data.2.id', $b->id);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('custom_field_definitions', ['id' => $c->id, 'sort_order' => 0]);
|
||||||
|
$this->assertDatabaseHas('custom_field_definitions', ['id' => $a->id, 'sort_order' => 1]);
|
||||||
|
$this->assertDatabaseHas('custom_field_definitions', ['id' => $b->id, 'sort_order' => 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forbids staff from reordering field definitions', function () {
|
||||||
|
$user = User::factory()->create(['role' => 'staff']);
|
||||||
|
$definition = CustomFieldDefinition::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($user)->postJson('/api/custom-fields/reorder', [
|
||||||
|
'ids' => [$definition->id],
|
||||||
|
])->assertStatus(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forbids staff from creating field definitions', function () {
|
||||||
|
$user = User::factory()->create(['role' => 'staff']);
|
||||||
|
|
||||||
|
$this->actingAs($user)->postJson('/api/custom-fields', [
|
||||||
|
'label' => 'Heimlich',
|
||||||
|
'type' => 'text',
|
||||||
|
])->assertStatus(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forbids staff from deleting field definitions', function () {
|
||||||
|
$user = User::factory()->create(['role' => 'staff']);
|
||||||
|
$definition = CustomFieldDefinition::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($user)->deleteJson("/api/custom-fields/{$definition->id}")->assertStatus(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires options when the type is select', function () {
|
||||||
|
$admin = User::factory()->create(['role' => 'admin']);
|
||||||
|
|
||||||
|
$this->actingAs($admin)->postJson('/api/custom-fields', [
|
||||||
|
'label' => 'Ohne Optionen',
|
||||||
|
'type' => 'select',
|
||||||
|
])->assertStatus(422);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records a Verlauf entry when a field value changes', function () {
|
||||||
|
$user = User::factory()->create(['role' => 'staff']);
|
||||||
|
$client = Client::factory()->create();
|
||||||
|
$definition = CustomFieldDefinition::factory()->create(['label' => 'Beratungsschwerpunkt']);
|
||||||
|
|
||||||
|
$this->actingAs($user)->putJson("/api/clients/{$client->id}/custom-fields/{$definition->id}", [
|
||||||
|
'value' => 'Steuerrecht',
|
||||||
|
])->assertStatus(200);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('custom_field_values', [
|
||||||
|
'client_id' => $client->id,
|
||||||
|
'custom_field_definition_id' => $definition->id,
|
||||||
|
'value' => 'Steuerrecht',
|
||||||
|
'author_id' => $user->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('history_entries', [
|
||||||
|
'client_id' => $client->id,
|
||||||
|
'type' => 'field_change',
|
||||||
|
'custom_field_definition_id' => $definition->id,
|
||||||
|
'author_id' => $user->id,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not record a Verlauf entry when the value is unchanged', function () {
|
||||||
|
$user = User::factory()->create(['role' => 'staff']);
|
||||||
|
$client = Client::factory()->create();
|
||||||
|
$definition = CustomFieldDefinition::factory()->create();
|
||||||
|
|
||||||
|
$url = "/api/clients/{$client->id}/custom-fields/{$definition->id}";
|
||||||
|
$this->actingAs($user)->putJson($url, ['value' => 'Hallo'])->assertStatus(200);
|
||||||
|
$this->actingAs($user)->putJson($url, ['value' => 'Hallo'])->assertStatus(200);
|
||||||
|
|
||||||
|
expect(\App\Modules\History\Models\HistoryEntry::where('client_id', $client->id)->count())->toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates select values against the defined options', function () {
|
||||||
|
$user = User::factory()->create(['role' => 'staff']);
|
||||||
|
$client = Client::factory()->create();
|
||||||
|
$definition = CustomFieldDefinition::factory()->select(['Steuerrecht', 'Erbrecht'])->create();
|
||||||
|
|
||||||
|
$this->actingAs($user)->putJson("/api/clients/{$client->id}/custom-fields/{$definition->id}", [
|
||||||
|
'value' => 'Strafrecht',
|
||||||
|
])->assertStatus(422);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates number values', function () {
|
||||||
|
$admin = User::factory()->create(['role' => 'admin']);
|
||||||
|
$client = Client::factory()->create();
|
||||||
|
$definition = CustomFieldDefinition::factory()->create(['type' => 'number']);
|
||||||
|
|
||||||
|
$this->actingAs($admin)->putJson("/api/clients/{$client->id}/custom-fields/{$definition->id}", [
|
||||||
|
'value' => 'not-a-number',
|
||||||
|
])->assertStatus(422);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('embeds custom fields on the client detail response', function () {
|
||||||
|
$user = User::factory()->create(['role' => 'staff']);
|
||||||
|
$client = Client::factory()->create();
|
||||||
|
CustomFieldDefinition::factory()->create(['label' => 'Mandant seit', 'sort_order' => 1]);
|
||||||
|
|
||||||
|
$this->actingAs($user)->getJson("/api/clients/{$client->id}")
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertJsonPath('data.custom_fields.0.label', 'Mandant seit')
|
||||||
|
->assertJsonPath('data.custom_fields.0.value', null);
|
||||||
|
});
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
use App\Modules\Client\Models\Client;
|
use App\Modules\Client\Models\Client;
|
||||||
use App\Modules\History\Models\HistoryEntry;
|
use App\Modules\History\Models\HistoryEntry;
|
||||||
use App\Modules\History\Models\HistoryEntryType;
|
use App\Modules\History\Models\HistoryEntryType;
|
||||||
|
use App\Modules\History\Models\HistoryType;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
@ -21,24 +22,65 @@
|
||||||
it('staff can add a history entry', function () {
|
it('staff can add a history entry', function () {
|
||||||
$user = User::factory()->create(['role' => 'staff']);
|
$user = User::factory()->create(['role' => 'staff']);
|
||||||
$client = Client::factory()->create();
|
$client = Client::factory()->create();
|
||||||
|
$type = HistoryType::factory()->create(['name' => 'Bemerkung']);
|
||||||
|
|
||||||
$response = $this->actingAs($user)->postJson("/api/clients/{$client->id}/history", [
|
$response = $this->actingAs($user)->postJson("/api/clients/{$client->id}/history", [
|
||||||
'type' => 'phone_call',
|
'history_entry_type_id' => $type->id,
|
||||||
'body' => 'Rückruf vereinbart.',
|
'body' => 'Rückruf vereinbart.',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$response->assertStatus(201)
|
$response->assertStatus(201)
|
||||||
->assertJsonPath('data.type', 'phone_call')
|
->assertJsonPath('data.history_entry_type_id', $type->id)
|
||||||
|
->assertJsonPath('data.type_name', 'Bemerkung')
|
||||||
|
->assertJsonPath('data.requires_datetime', false)
|
||||||
->assertJsonPath('data.body', 'Rückruf vereinbart.')
|
->assertJsonPath('data.body', 'Rückruf vereinbart.')
|
||||||
->assertJsonPath('data.author_id', $user->id);
|
->assertJsonPath('data.author_id', $user->id);
|
||||||
|
|
||||||
$this->assertDatabaseHas('history_entries', [
|
$this->assertDatabaseHas('history_entries', [
|
||||||
'client_id' => $client->id,
|
'client_id' => $client->id,
|
||||||
'type' => 'phone_call',
|
'history_entry_type_id' => $type->id,
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('staff can update a history entry', function () {
|
it('stores and echoes back the occurred_at datetime unchanged', function () {
|
||||||
|
$user = User::factory()->create(['role' => 'staff']);
|
||||||
|
$client = Client::factory()->create();
|
||||||
|
$type = HistoryType::factory()->requiresDatetime()->create(['name' => 'Telefonat']);
|
||||||
|
|
||||||
|
$response = $this->actingAs($user)->postJson("/api/clients/{$client->id}/history", [
|
||||||
|
'history_entry_type_id' => $type->id,
|
||||||
|
'body' => 'Telefonat geführt.',
|
||||||
|
'occurred_at' => '2026-06-25T14:30:00',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertStatus(201)
|
||||||
|
->assertJsonPath('data.type_name', 'Telefonat')
|
||||||
|
->assertJsonPath('data.requires_datetime', true)
|
||||||
|
->assertJsonPath('data.occurred_at', '2026-06-25T14:30:00');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires occurred_at when the type requires a datetime', function () {
|
||||||
|
$user = User::factory()->create(['role' => 'staff']);
|
||||||
|
$client = Client::factory()->create();
|
||||||
|
$type = HistoryType::factory()->requiresDatetime()->create();
|
||||||
|
|
||||||
|
$this->actingAs($user)->postJson("/api/clients/{$client->id}/history", [
|
||||||
|
'history_entry_type_id' => $type->id,
|
||||||
|
'body' => 'Ohne Zeit.',
|
||||||
|
])->assertStatus(422)->assertJsonValidationErrors('occurred_at');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an invalid history_entry_type_id', function () {
|
||||||
|
$user = User::factory()->create(['role' => 'staff']);
|
||||||
|
$client = Client::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($user)->postJson("/api/clients/{$client->id}/history", [
|
||||||
|
'history_entry_type_id' => 99999,
|
||||||
|
'body' => 'test',
|
||||||
|
])->assertStatus(422)->assertJsonValidationErrors('history_entry_type_id');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets the author edit their own entry', function () {
|
||||||
$user = User::factory()->create(['role' => 'staff']);
|
$user = User::factory()->create(['role' => 'staff']);
|
||||||
$client = Client::factory()->create();
|
$client = Client::factory()->create();
|
||||||
$entry = HistoryEntry::factory()->create([
|
$entry = HistoryEntry::factory()->create([
|
||||||
|
|
@ -55,23 +97,66 @@
|
||||||
$this->assertDatabaseHas('history_entries', ['id' => $entry->id, 'updated_by' => $user->id]);
|
$this->assertDatabaseHas('history_entries', ['id' => $entry->id, 'updated_by' => $user->id]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('staff can soft-delete a history entry', function () {
|
it('forbids a non-author from editing an entry', function () {
|
||||||
|
$author = User::factory()->create(['role' => 'staff']);
|
||||||
|
$other = User::factory()->create(['role' => 'staff']);
|
||||||
|
$client = Client::factory()->create();
|
||||||
|
$entry = HistoryEntry::factory()->create([
|
||||||
|
'client_id' => $client->id,
|
||||||
|
'author_id' => $author->id,
|
||||||
|
'body' => 'Original',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($other)->putJson("/api/history/{$entry->id}", [
|
||||||
|
'body' => 'tampered',
|
||||||
|
])->assertStatus(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nobody can edit a field_change audit entry', function () {
|
||||||
|
$admin = User::factory()->create(['role' => 'admin']);
|
||||||
|
$client = Client::factory()->create();
|
||||||
|
$entry = HistoryEntry::factory()->create([
|
||||||
|
'client_id' => $client->id,
|
||||||
|
'author_id' => $admin->id,
|
||||||
|
'history_entry_type_id' => null,
|
||||||
|
'type' => HistoryEntryType::FieldChange->value,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($admin)->putJson("/api/history/{$entry->id}", ['body' => 'tampered'])->assertStatus(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a field_change entry as Stammdatenänderung', function () {
|
||||||
$user = User::factory()->create(['role' => 'staff']);
|
$user = User::factory()->create(['role' => 'staff']);
|
||||||
$client = Client::factory()->create();
|
$client = Client::factory()->create();
|
||||||
$entry = HistoryEntry::factory()->create(['client_id' => $client->id, 'author_id' => $user->id]);
|
HistoryEntry::factory()->create([
|
||||||
|
'client_id' => $client->id,
|
||||||
|
'author_id' => $user->id,
|
||||||
|
'history_entry_type_id' => null,
|
||||||
|
'type' => HistoryEntryType::FieldChange->value,
|
||||||
|
]);
|
||||||
|
|
||||||
$response = $this->actingAs($user)->deleteJson("/api/history/{$entry->id}");
|
$this->actingAs($user)->getJson("/api/clients/{$client->id}/history")
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertJsonPath('data.0.is_field_change', true)
|
||||||
|
->assertJsonPath('data.0.type_name', 'Stammdatenänderung');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('admin can soft-delete a history entry', function () {
|
||||||
|
$admin = User::factory()->create(['role' => 'admin']);
|
||||||
|
$client = Client::factory()->create();
|
||||||
|
$entry = HistoryEntry::factory()->create(['client_id' => $client->id, 'author_id' => $admin->id]);
|
||||||
|
|
||||||
|
$response = $this->actingAs($admin)->deleteJson("/api/history/{$entry->id}");
|
||||||
|
|
||||||
$response->assertStatus(204);
|
$response->assertStatus(204);
|
||||||
$this->assertSoftDeleted('history_entries', ['id' => $entry->id]);
|
$this->assertSoftDeleted('history_entries', ['id' => $entry->id]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects invalid history entry type', function () {
|
it('staff cannot delete a history entry', function () {
|
||||||
$user = User::factory()->create(['role' => 'staff']);
|
$user = User::factory()->create(['role' => 'staff']);
|
||||||
$client = Client::factory()->create();
|
$client = Client::factory()->create();
|
||||||
|
$entry = HistoryEntry::factory()->create(['client_id' => $client->id, 'author_id' => $user->id]);
|
||||||
|
|
||||||
$this->actingAs($user)->postJson("/api/clients/{$client->id}/history", [
|
$this->actingAs($user)->deleteJson("/api/history/{$entry->id}")->assertStatus(403);
|
||||||
'type' => 'invalid_type',
|
$this->assertDatabaseHas('history_entries', ['id' => $entry->id, 'deleted_at' => null]);
|
||||||
'body' => 'test',
|
|
||||||
])->assertStatus(422);
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
122
backend/tests/Feature/Modules/History/HistoryTypeTest.php
Normal file
122
backend/tests/Feature/Modules/History/HistoryTypeTest.php
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Modules\History\Models\HistoryType;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('lets any authenticated user list history types', function () {
|
||||||
|
$user = User::factory()->create(['role' => 'staff']);
|
||||||
|
HistoryType::factory(3)->create();
|
||||||
|
|
||||||
|
$this->actingAs($user)->getJson('/api/history-types')
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertJsonCount(3, 'data');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets an admin create a history type', function () {
|
||||||
|
$admin = User::factory()->create(['role' => 'admin']);
|
||||||
|
|
||||||
|
$response = $this->actingAs($admin)->postJson('/api/history-types', [
|
||||||
|
'name' => 'Telefonat',
|
||||||
|
'requires_datetime' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertStatus(201)
|
||||||
|
->assertJsonPath('data.name', 'Telefonat')
|
||||||
|
->assertJsonPath('data.requires_datetime', true)
|
||||||
|
->assertJsonPath('data.sort_order', 0);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('history_types', ['name' => 'Telefonat', 'requires_datetime' => true]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends new history types to the end of the list', function () {
|
||||||
|
$admin = User::factory()->create(['role' => 'admin']);
|
||||||
|
HistoryType::factory()->create(['sort_order' => 3]);
|
||||||
|
|
||||||
|
$this->actingAs($admin)->postJson('/api/history-types', ['name' => 'Zuletzt'])
|
||||||
|
->assertStatus(201)
|
||||||
|
->assertJsonPath('data.sort_order', 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forbids staff from creating a history type', function () {
|
||||||
|
$user = User::factory()->create(['role' => 'staff']);
|
||||||
|
|
||||||
|
$this->actingAs($user)->postJson('/api/history-types', [
|
||||||
|
'name' => 'Telefonat',
|
||||||
|
])->assertStatus(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets an admin update a history type without changing its order', function () {
|
||||||
|
$admin = User::factory()->create(['role' => 'admin']);
|
||||||
|
$type = HistoryType::factory()->create(['name' => 'Alt', 'requires_datetime' => false, 'sort_order' => 7]);
|
||||||
|
|
||||||
|
$this->actingAs($admin)->putJson("/api/history-types/{$type->id}", [
|
||||||
|
'name' => 'Neu',
|
||||||
|
'requires_datetime' => true,
|
||||||
|
])->assertStatus(200)->assertJsonPath('data.name', 'Neu');
|
||||||
|
|
||||||
|
// sort_order is managed only via the reorder endpoint, so it must be untouched.
|
||||||
|
$this->assertDatabaseHas('history_types', ['id' => $type->id, 'name' => 'Neu', 'requires_datetime' => true, 'sort_order' => 7]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets an admin reorder history types', function () {
|
||||||
|
$admin = User::factory()->create(['role' => 'admin']);
|
||||||
|
$a = HistoryType::factory()->create(['sort_order' => 0]);
|
||||||
|
$b = HistoryType::factory()->create(['sort_order' => 1]);
|
||||||
|
$c = HistoryType::factory()->create(['sort_order' => 2]);
|
||||||
|
|
||||||
|
$this->actingAs($admin)->postJson('/api/history-types/reorder', [
|
||||||
|
'ids' => [$c->id, $a->id, $b->id],
|
||||||
|
])->assertStatus(200)
|
||||||
|
->assertJsonPath('data.0.id', $c->id)
|
||||||
|
->assertJsonPath('data.1.id', $a->id)
|
||||||
|
->assertJsonPath('data.2.id', $b->id);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('history_types', ['id' => $c->id, 'sort_order' => 0]);
|
||||||
|
$this->assertDatabaseHas('history_types', ['id' => $a->id, 'sort_order' => 1]);
|
||||||
|
$this->assertDatabaseHas('history_types', ['id' => $b->id, 'sort_order' => 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forbids staff from reordering history types', function () {
|
||||||
|
$user = User::factory()->create(['role' => 'staff']);
|
||||||
|
$type = HistoryType::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($user)->postJson('/api/history-types/reorder', [
|
||||||
|
'ids' => [$type->id],
|
||||||
|
])->assertStatus(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forbids staff from updating a history type', function () {
|
||||||
|
$user = User::factory()->create(['role' => 'staff']);
|
||||||
|
$type = HistoryType::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($user)->putJson("/api/history-types/{$type->id}", [
|
||||||
|
'name' => 'Neu',
|
||||||
|
])->assertStatus(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets an admin soft-delete a history type', function () {
|
||||||
|
$admin = User::factory()->create(['role' => 'admin']);
|
||||||
|
$type = HistoryType::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($admin)->deleteJson("/api/history-types/{$type->id}")->assertStatus(204);
|
||||||
|
$this->assertSoftDeleted('history_types', ['id' => $type->id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forbids staff from deleting a history type', function () {
|
||||||
|
$user = User::factory()->create(['role' => 'staff']);
|
||||||
|
$type = HistoryType::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($user)->deleteJson("/api/history-types/{$type->id}")->assertStatus(403);
|
||||||
|
$this->assertDatabaseHas('history_types', ['id' => $type->id, 'deleted_at' => null]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires a name when creating a history type', function () {
|
||||||
|
$admin = User::factory()->create(['role' => 'admin']);
|
||||||
|
|
||||||
|
$this->actingAs($admin)->postJson('/api/history-types', [
|
||||||
|
'requires_datetime' => true,
|
||||||
|
])->assertStatus(422)->assertJsonValidationErrors('name');
|
||||||
|
});
|
||||||
222
backend/tests/Feature/Modules/Import/ClientImportTest.php
Normal file
222
backend/tests/Feature/Modules/Import/ClientImportTest.php
Normal file
|
|
@ -0,0 +1,222 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Modules\Client\Models\Client;
|
||||||
|
use App\Modules\CustomField\Models\CustomFieldDefinition;
|
||||||
|
use App\Modules\CustomField\Models\CustomFieldValue;
|
||||||
|
use Database\Seeders\CustomFieldDefinitionSeeder;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
$this->seed(CustomFieldDefinitionSeeder::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array<int, string>> $rows
|
||||||
|
*/
|
||||||
|
function importCsv(array $rows): string
|
||||||
|
{
|
||||||
|
return implode("\n", array_map(static fn (array $r): string => implode(';', $r), $rows))."\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
function importFile(string $contents, string $name = 'import.csv'): UploadedFile
|
||||||
|
{
|
||||||
|
return UploadedFile::fake()->createWithContent($name, $contents);
|
||||||
|
}
|
||||||
|
|
||||||
|
function postImport($test, UploadedFile $file, User $user)
|
||||||
|
{
|
||||||
|
return $test->actingAs($user)->post('/api/clients/import', ['file' => $file], ['Accept' => 'application/json']);
|
||||||
|
}
|
||||||
|
|
||||||
|
const IMPORT_HEADER = [
|
||||||
|
'Mandantennr.', 'Mandant', 'Anschrift 1', 'Anschrift 2',
|
||||||
|
'Telefon', 'E-Mail', 'Ansprechpartner 1', 'Ueberschreiben',
|
||||||
|
];
|
||||||
|
|
||||||
|
it('creates a new client with mapped custom fields and writes no history', function () {
|
||||||
|
$admin = User::factory()->create(['role' => 'admin']);
|
||||||
|
|
||||||
|
$csv = importCsv([
|
||||||
|
IMPORT_HEADER,
|
||||||
|
['1001', 'Mustermann GmbH', 'Hauptstr. 1', '12345 Musterstadt', '0123456', 'a@b.de', 'Herr Müller', ''],
|
||||||
|
]);
|
||||||
|
|
||||||
|
postImport($this, importFile($csv), $admin)
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertJsonPath('data.created', 1)
|
||||||
|
->assertJsonPath('data.skipped', 0)
|
||||||
|
->assertJsonPath('data.updated', 0);
|
||||||
|
|
||||||
|
$client = Client::where('client_number', '1001')->firstOrFail();
|
||||||
|
expect($client->name)->toBe('Mustermann GmbH');
|
||||||
|
|
||||||
|
$tel = CustomFieldDefinition::where('key', 'telefonnummer')->firstOrFail();
|
||||||
|
$address = CustomFieldDefinition::where('key', 'anschrift')->firstOrFail();
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('custom_field_values', [
|
||||||
|
'client_id' => $client->id,
|
||||||
|
'custom_field_definition_id' => $tel->id,
|
||||||
|
'value' => '0123456',
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas('custom_field_values', [
|
||||||
|
'client_id' => $client->id,
|
||||||
|
'custom_field_definition_id' => $address->id,
|
||||||
|
'value' => "Hauptstr. 1\n12345 Musterstadt",
|
||||||
|
]);
|
||||||
|
|
||||||
|
// New clients are imported silently.
|
||||||
|
$this->assertDatabaseCount('history_entries', 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips an existing client when the overwrite flag is absent', function () {
|
||||||
|
$admin = User::factory()->create(['role' => 'admin']);
|
||||||
|
Client::factory()->create(['client_number' => '2002', 'name' => 'Bestand AG']);
|
||||||
|
|
||||||
|
$csv = importCsv([
|
||||||
|
IMPORT_HEADER,
|
||||||
|
['2002', 'Geändert AG', '', '', '0999', 'neu@x.de', 'Herr Neu', ''],
|
||||||
|
]);
|
||||||
|
|
||||||
|
postImport($this, importFile($csv), $admin)
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertJsonPath('data.skipped', 1)
|
||||||
|
->assertJsonPath('data.created', 0)
|
||||||
|
->assertJsonPath('data.updated', 0);
|
||||||
|
|
||||||
|
expect(Client::where('client_number', '2002')->first()->name)->toBe('Bestand AG');
|
||||||
|
$this->assertDatabaseCount('custom_field_values', 0);
|
||||||
|
$this->assertDatabaseCount('history_entries', 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('overwrites custom fields of an existing client and audits each change', function () {
|
||||||
|
$admin = User::factory()->create(['role' => 'admin']);
|
||||||
|
$client = Client::factory()->create(['client_number' => '3003', 'name' => 'Original Name']);
|
||||||
|
|
||||||
|
$csv = importCsv([
|
||||||
|
IMPORT_HEADER,
|
||||||
|
['3003', 'Neuer Name', 'Weg 5', '', '0555', 'mail@z.de', 'Frau Schmidt', 'Ja'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
postImport($this, importFile($csv), $admin)
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertJsonPath('data.updated', 1)
|
||||||
|
->assertJsonPath('data.created', 0)
|
||||||
|
->assertJsonPath('data.skipped', 0);
|
||||||
|
|
||||||
|
// Name / number are never touched on overwrite — custom fields only.
|
||||||
|
expect($client->fresh()->name)->toBe('Original Name');
|
||||||
|
|
||||||
|
// Four non-blank fields → four field-change audit rows.
|
||||||
|
$this->assertDatabaseCount('history_entries', 4);
|
||||||
|
|
||||||
|
$importUser = User::where('name', 'Mandanten-Import')->firstOrFail();
|
||||||
|
$this->assertDatabaseHas('history_entries', [
|
||||||
|
'client_id' => $client->id,
|
||||||
|
'type' => 'field_change',
|
||||||
|
'author_id' => $importUser->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Author renders as "Mandanten-Import" through the history API.
|
||||||
|
$this->actingAs($admin)->getJson("/api/clients/{$client->id}/history")
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertJsonPath('data.0.author_name', 'Mandanten-Import')
|
||||||
|
->assertJsonPath('data.0.type_name', 'Stammdatenänderung');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves existing values when an overwrite row has blanks', function () {
|
||||||
|
$admin = User::factory()->create(['role' => 'admin']);
|
||||||
|
$client = Client::factory()->create(['client_number' => '4004', 'name' => 'Firma']);
|
||||||
|
$tel = CustomFieldDefinition::where('key', 'telefonnummer')->firstOrFail();
|
||||||
|
CustomFieldValue::create([
|
||||||
|
'client_id' => $client->id,
|
||||||
|
'custom_field_definition_id' => $tel->id,
|
||||||
|
'value' => 'ALT-TELEFON',
|
||||||
|
'author_id' => $admin->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Telefon column blank → must not wipe the existing value.
|
||||||
|
$csv = importCsv([
|
||||||
|
IMPORT_HEADER,
|
||||||
|
['4004', 'Firma', '', '', '', 'only@mail.de', '', 'ja'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
postImport($this, importFile($csv), $admin)
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertJsonPath('data.updated', 1);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('custom_field_values', [
|
||||||
|
'client_id' => $client->id,
|
||||||
|
'custom_field_definition_id' => $tel->id,
|
||||||
|
'value' => 'ALT-TELEFON',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips Excel leading apostrophes and decodes ISO-8859-1 umlauts', function () {
|
||||||
|
$admin = User::factory()->create(['role' => 'admin']);
|
||||||
|
|
||||||
|
$csv = importCsv([
|
||||||
|
IMPORT_HEADER,
|
||||||
|
['5005', 'Bäcker Schröder', 'Straße 1', '', "'0301234", 'm@n.de', 'Herr Groß', ''],
|
||||||
|
]);
|
||||||
|
$latin1 = mb_convert_encoding($csv, 'ISO-8859-1', 'UTF-8');
|
||||||
|
|
||||||
|
postImport($this, importFile($latin1), $admin)
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertJsonPath('data.created', 1);
|
||||||
|
|
||||||
|
$client = Client::where('client_number', '5005')->firstOrFail();
|
||||||
|
expect($client->name)->toBe('Bäcker Schröder');
|
||||||
|
|
||||||
|
$tel = CustomFieldDefinition::where('key', 'telefonnummer')->firstOrFail();
|
||||||
|
$this->assertDatabaseHas('custom_field_values', [
|
||||||
|
'client_id' => $client->id,
|
||||||
|
'custom_field_definition_id' => $tel->id,
|
||||||
|
'value' => '0301234', // leading apostrophe stripped
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles ragged rows shorter than the header', function () {
|
||||||
|
$admin = User::factory()->create(['role' => 'admin']);
|
||||||
|
|
||||||
|
// Row stops after "Mandant" — every mapped column beyond it is missing.
|
||||||
|
$csv = "Mandantennr.;Mandant;Anschrift 1;Telefon;E-Mail;Ansprechpartner 1;Ueberschreiben\n6006;Kurz GmbH\n";
|
||||||
|
|
||||||
|
postImport($this, importFile($csv), $admin)
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertJsonPath('data.created', 1);
|
||||||
|
|
||||||
|
expect(Client::where('client_number', '6006')->first()->name)->toBe('Kurz GmbH');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forbids non-admin users', function () {
|
||||||
|
$staff = User::factory()->create(['role' => 'staff']);
|
||||||
|
$csv = importCsv([IMPORT_HEADER, ['7007', 'X', '', '', '', '', '', '']]);
|
||||||
|
|
||||||
|
postImport($this, importFile($csv), $staff)->assertStatus(403);
|
||||||
|
$this->assertDatabaseCount('clients', 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a non-csv upload', function () {
|
||||||
|
$admin = User::factory()->create(['role' => 'admin']);
|
||||||
|
$file = UploadedFile::fake()->createWithContent('data.pdf', 'irrelevant');
|
||||||
|
|
||||||
|
postImport($this, $file, $admin)->assertStatus(422);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports an error row for a missing client number', function () {
|
||||||
|
$admin = User::factory()->create(['role' => 'admin']);
|
||||||
|
$csv = importCsv([
|
||||||
|
IMPORT_HEADER,
|
||||||
|
['', 'Ohne Nummer', '', '', '', '', '', ''],
|
||||||
|
]);
|
||||||
|
|
||||||
|
postImport($this, importFile($csv), $admin)
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertJsonPath('data.created', 0)
|
||||||
|
->assertJsonCount(1, 'data.errors')
|
||||||
|
->assertJsonPath('data.errors.0.row', 2);
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue