Teilnahmerechnungen erstellen
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Admin\Actions\UpdateDocumentAsset;
|
||||
|
||||
use App\Models\DocumentAsset;
|
||||
|
||||
/**
|
||||
* Legt ein Bild für die Dokumentvorlagen an oder ersetzt es.
|
||||
*
|
||||
* Gespeichert wird der base64-Payload in der Datenbank -- so überlebt das Bild ein Release (das als
|
||||
* `git checkout` alles unter `public/` überschreibt) und wandert mit jedem Dump mit.
|
||||
*/
|
||||
class UpdateDocumentAssetAction
|
||||
{
|
||||
/** Über 2 MB wird die Vorlage träge und das PDF unnötig groß. */
|
||||
private const int MAX_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
/** @var array<string, string> */
|
||||
private const array ALLOWED_MIME_TYPES = [
|
||||
'image/png' => 'png',
|
||||
'image/jpeg' => 'jpg',
|
||||
'image/gif' => 'gif',
|
||||
'image/svg+xml' => 'svg',
|
||||
];
|
||||
|
||||
public function __construct(private readonly UpdateDocumentAssetRequest $request)
|
||||
{
|
||||
}
|
||||
|
||||
public function execute(): UpdateDocumentAssetResponse
|
||||
{
|
||||
$response = new UpdateDocumentAssetResponse();
|
||||
|
||||
$name = $this->normalizeName($this->request->name);
|
||||
|
||||
if ($name === null) {
|
||||
$response->message = 'Bitte einen Namen aus Kleinbuchstaben, Ziffern, Bindestrich oder Unterstrich angeben.';
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
$asset = DocumentAsset::where('name', $name)->first();
|
||||
$file = $this->request->file;
|
||||
|
||||
if ($file === null && $asset === null) {
|
||||
$response->message = 'Für ein neues Bild wird eine Datei benötigt.';
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
$attributes = ['name' => $name, 'label' => $this->request->label];
|
||||
|
||||
if ($file !== null) {
|
||||
if (!array_key_exists($file->getMimeType(), self::ALLOWED_MIME_TYPES)) {
|
||||
$response->message = 'Nur PNG, JPEG, GIF oder SVG sind zulässig.';
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
if ($file->getSize() > self::MAX_BYTES) {
|
||||
$response->message = 'Das Bild darf höchstens 2 MB groß sein.';
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
$attributes['mime'] = $file->getMimeType();
|
||||
$attributes['data'] = base64_encode(file_get_contents($file->getRealPath()));
|
||||
}
|
||||
|
||||
$response->asset = $asset === null
|
||||
? DocumentAsset::create($attributes)
|
||||
: tap($asset)->update($attributes);
|
||||
|
||||
$response->success = true;
|
||||
$response->message = 'Das Bild wurde gespeichert.';
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/** Namen sind Slugs, damit `{asset:name}` eindeutig erkennbar bleibt. */
|
||||
private function normalizeName(string $name): ?string
|
||||
{
|
||||
$name = strtolower(trim($name));
|
||||
|
||||
return preg_match('/^[a-z0-9_-]+$/', $name) === 1 ? $name : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Admin\Actions\UpdateDocumentAsset;
|
||||
|
||||
use Illuminate\Http\UploadedFile;
|
||||
|
||||
class UpdateDocumentAssetRequest
|
||||
{
|
||||
public function __construct(
|
||||
/** Slug, unter dem das Bild in der Vorlage als `{asset:name}` referenziert wird. */
|
||||
public readonly string $name,
|
||||
public readonly ?string $label = null,
|
||||
public readonly ?UploadedFile $file = null,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Admin\Actions\UpdateDocumentAsset;
|
||||
|
||||
use App\Models\DocumentAsset;
|
||||
|
||||
class UpdateDocumentAssetResponse
|
||||
{
|
||||
public bool $success = false;
|
||||
|
||||
public ?string $message = null;
|
||||
|
||||
public ?DocumentAsset $asset = null;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Admin\Actions\UpdateDocumentTemplate;
|
||||
|
||||
use App\Models\DocumentTemplate;
|
||||
|
||||
/**
|
||||
* Speichert die Blockinhalte einer Dokumentvorlage.
|
||||
*
|
||||
* Es werden nur bereits angelegte, als editierbar markierte Blöcke geschrieben. Neue Blöcke entstehen
|
||||
* nicht über das Formular -- welche Blöcke eine Dokumentart hat, gibt die Vorlage vor (das Layout
|
||||
* verweist mit `{block:...}` auf sie).
|
||||
*/
|
||||
class UpdateDocumentTemplateAction
|
||||
{
|
||||
public function __construct(private readonly UpdateDocumentTemplateRequest $request)
|
||||
{
|
||||
}
|
||||
|
||||
public function execute(): UpdateDocumentTemplateResponse
|
||||
{
|
||||
$response = new UpdateDocumentTemplateResponse();
|
||||
|
||||
$existing = DocumentTemplate::forType($this->request->documentType);
|
||||
|
||||
// Erst prüfen, dann schreiben -- sonst bliebe bei einem Fehler ein halb gespeicherter Stand übrig.
|
||||
$rejection = $this->rejectBareAssetInStyle();
|
||||
if ($rejection !== null) {
|
||||
$response->message = $rejection;
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
foreach ($this->request->blocks as $block => $content) {
|
||||
$template = $existing->get($block);
|
||||
|
||||
if ($template === null || !$template->editable) {
|
||||
$response->skipped[] = $block;
|
||||
continue;
|
||||
}
|
||||
|
||||
$template->update(['content' => (string) $content]);
|
||||
}
|
||||
|
||||
$response->success = true;
|
||||
$response->message = $response->skipped === []
|
||||
? 'Die Vorlage wurde gespeichert.'
|
||||
: 'Die Vorlage wurde gespeichert; nicht editierbare Blöcke blieben unverändert.';
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Bild-Platzhalter, der im CSS nicht in `url(...)` steht, expandiert zu einem Data-URI, das
|
||||
* dompdf nicht auslagern kann -- der Parser läuft dann minutenlang und der FPM-Worker stirbt am
|
||||
* Zeitlimit (502). Solches CSS wird gar nicht erst gespeichert.
|
||||
*
|
||||
* @return string|null Fehlermeldung, oder null wenn nichts zu beanstanden ist.
|
||||
*/
|
||||
private function rejectBareAssetInStyle(): ?string
|
||||
{
|
||||
$style = $this->request->blocks[DocumentTemplate::BLOCK_STYLE] ?? null;
|
||||
|
||||
if ($style === null || preg_match('/(?<![("\'])\{asset:([a-z0-9_-]+)}/i', (string) $style, $match) !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return sprintf(
|
||||
'Im CSS-Block steht der Bild-Platzhalter {asset:%1$s} direkt im Text. Dort gehört er in url("{asset:%1$s}").',
|
||||
$match[1]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Admin\Actions\UpdateDocumentTemplate;
|
||||
|
||||
class UpdateDocumentTemplateRequest
|
||||
{
|
||||
/**
|
||||
* @param array<string, string> $blocks Blockinhalte, adressiert über den Block-Slug.
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $documentType,
|
||||
public readonly array $blocks,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Admin\Actions\UpdateDocumentTemplate;
|
||||
|
||||
class UpdateDocumentTemplateResponse
|
||||
{
|
||||
public bool $success = false;
|
||||
|
||||
public ?string $message = null;
|
||||
|
||||
/** @var array<int, string> Blöcke, die nicht gespeichert wurden (unbekannt oder nicht editierbar). */
|
||||
public array $skipped = [];
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace App\Domains\Admin\Actions\UpdateTenantContact;
|
||||
|
||||
use App\Support\Text;
|
||||
|
||||
class UpdateTenantContactAction
|
||||
{
|
||||
public function __construct(private UpdateTenantContactRequest $request)
|
||||
@@ -15,8 +17,14 @@ class UpdateTenantContactAction
|
||||
$this->request->tenant->update([
|
||||
'email' => $this->request->email,
|
||||
'email_finance' => $this->request->emailFinance,
|
||||
'invoice_sender_name' => Text::nullIfBlank($this->request->invoiceSenderName),
|
||||
// Leergelassene optionale Felder als NULL, damit sie im Briefkopf sauber wegfallen.
|
||||
'address_1' => Text::nullIfBlank($this->request->address1),
|
||||
'address_2' => Text::nullIfBlank($this->request->address2),
|
||||
'address_3' => Text::nullIfBlank($this->request->address3),
|
||||
'postcode' => $this->request->postcode,
|
||||
'city' => $this->request->city,
|
||||
'phone' => Text::nullIfBlank($this->request->phone),
|
||||
]);
|
||||
|
||||
$response->success = true;
|
||||
|
||||
@@ -12,6 +12,14 @@ class UpdateTenantContactRequest
|
||||
public string $emailFinance,
|
||||
public string $postcode,
|
||||
public string $city,
|
||||
/** Bezeichnung des Rechnungsstellers; leer bedeutet "Name des Mandanten verwenden". */
|
||||
public ?string $invoiceSenderName = null,
|
||||
/** Straße und Hausnummer -- Pflichtangabe auf Rechnungen nach § 14 Abs. 4 UStG. */
|
||||
public ?string $address1 = null,
|
||||
/** Optionale Zusatzzeile, z.B. "c/o ...". */
|
||||
public ?string $address2 = null,
|
||||
public ?string $address3 = null,
|
||||
public ?string $phone = null,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
namespace App\Domains\Admin\Actions\UpdateTenantTax;
|
||||
|
||||
use App\Enumerations\VatPricingMode;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Text;
|
||||
|
||||
class UpdateTenantTaxAction
|
||||
{
|
||||
@@ -14,14 +16,23 @@ class UpdateTenantTaxAction
|
||||
{
|
||||
$response = new UpdateTenantTaxResponse();
|
||||
|
||||
// Präfixe sind großgeschrieben; null heißt "nicht mitgeschickt" und damit "unverändert".
|
||||
$prefix = Text::nullIfBlank(strtoupper((string) $this->request->invoicePrefix));
|
||||
|
||||
if ($prefix !== null && $this->prefixTakenByAnotherTenant($prefix)) {
|
||||
$response->message = sprintf('Das Rechnungs-Präfix "%s" wird bereits von einem anderen Mandanten genutzt.', $prefix);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
if ($this->request->taxLiable) {
|
||||
$this->request->tenant->update([
|
||||
$this->request->tenant->update($this->withInvoiceDetails([
|
||||
'tax_liable' => true,
|
||||
'vat_rate' => $this->clampVatRate($this->request->vatRate),
|
||||
'vat_pricing_mode' => $this->request->vatPricingMode->value,
|
||||
'tax_exemption_reason' => null,
|
||||
'tax_exemption_note' => null,
|
||||
]);
|
||||
], $prefix));
|
||||
|
||||
$response->success = true;
|
||||
$response->message = 'Steuerinformationen wurden gespeichert.';
|
||||
@@ -45,13 +56,13 @@ class UpdateTenantTaxAction
|
||||
return $response;
|
||||
}
|
||||
|
||||
$this->request->tenant->update([
|
||||
$this->request->tenant->update($this->withInvoiceDetails([
|
||||
'tax_liable' => false,
|
||||
'vat_rate' => 0,
|
||||
'vat_pricing_mode' => VatPricingMode::Inclusive->value,
|
||||
'tax_exemption_reason' => $reason->slug,
|
||||
'tax_exemption_note' => $reason->requires_note ? $note : null,
|
||||
]);
|
||||
], $prefix));
|
||||
|
||||
$response->success = true;
|
||||
$response->message = 'Steuerinformationen wurden gespeichert.';
|
||||
@@ -63,4 +74,31 @@ class UpdateTenantTaxAction
|
||||
{
|
||||
return max(0, min(100, $vatRate));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ergänzt die Rechnungsstellerangaben. Sie gelten unabhängig davon, ob Steuerpflicht besteht -- eine
|
||||
* Steuernummer gehört auch auf eine steuerfreie Rechnung.
|
||||
*
|
||||
* @param array<string, mixed> $attributes
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function withInvoiceDetails(array $attributes, ?string $prefix): array
|
||||
{
|
||||
$attributes['tax_number'] = Text::nullIfBlank($this->request->taxNumber);
|
||||
$attributes['vat_id'] = Text::nullIfBlank($this->request->vatId);
|
||||
|
||||
// Nicht mitgeschickt heißt "unverändert", nicht "leeren".
|
||||
if ($prefix !== null) {
|
||||
$attributes['invoice_prefix'] = $prefix;
|
||||
}
|
||||
|
||||
return $attributes;
|
||||
}
|
||||
|
||||
private function prefixTakenByAnotherTenant(string $prefix): bool
|
||||
{
|
||||
return Tenant::where('invoice_prefix', $prefix)
|
||||
->where('id', '!=', $this->request->tenant->id)
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,14 @@ class UpdateTenantTaxRequest
|
||||
public ?TaxExemptionReason $exemptionReason,
|
||||
public ?string $exemptionNote,
|
||||
public VatPricingMode $vatPricingMode,
|
||||
/** Steuernummer bzw. USt-IdNr. -- Pflichtangabe auf Rechnungen (§ 14 Abs. 4 Nr. 2 UStG). */
|
||||
public ?string $taxNumber = null,
|
||||
public ?string $vatId = null,
|
||||
/**
|
||||
* Erster Block der Rechnungsnummer. `null` heißt "unverändert lassen" -- im Self-Service wird das
|
||||
* Feld gar nicht erst mitgeschickt, damit ein bestehender Nummernkreis nicht bricht.
|
||||
*/
|
||||
public ?string $invoicePrefix = null,
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Admin\Controllers;
|
||||
|
||||
use App\Models\DocumentAsset;
|
||||
use App\Models\DocumentTemplate;
|
||||
use App\Scopes\CommonController;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class DocumentAssetDeleteController extends CommonController
|
||||
{
|
||||
public function __invoke(string $name): JsonResponse
|
||||
{
|
||||
$asset = DocumentAsset::where('name', $name)->first();
|
||||
|
||||
// Durchweg 200 mit Status im Body -- wie die übrigen Admin-Endpunkte, damit der Grund im Frontend
|
||||
// ankommt (der Ajax-Helfer verwirft den Body bei Fehler-Statuscodes).
|
||||
if ($asset === null) {
|
||||
return response()->json(['status' => 'error', 'message' => 'Das Bild existiert nicht.']);
|
||||
}
|
||||
|
||||
// Ein noch referenziertes Bild zu löschen würde stillschweigend eine Lücke in jede künftige
|
||||
// Rechnung reißen -- der Platzhalter löst dann zu einem Leerstring auf.
|
||||
if ($this->isReferenced($asset->name)) {
|
||||
return response()->json([
|
||||
'status' => 'error',
|
||||
'message' => sprintf('Das Bild wird noch als {asset:%s} in der Vorlage verwendet.', $asset->name),
|
||||
]);
|
||||
}
|
||||
|
||||
$asset->delete();
|
||||
|
||||
return response()->json(['status' => 'success', 'message' => 'Das Bild wurde gelöscht.']);
|
||||
}
|
||||
|
||||
private function isReferenced(string $name): bool
|
||||
{
|
||||
return DocumentTemplate::query()
|
||||
->where('content', 'like', '%{asset:' . $name . '}%')
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Admin\Controllers;
|
||||
|
||||
use App\Domains\Admin\Actions\UpdateDocumentAsset\UpdateDocumentAssetAction;
|
||||
use App\Domains\Admin\Actions\UpdateDocumentAsset\UpdateDocumentAssetRequest;
|
||||
use App\Scopes\CommonController;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class DocumentAssetUpdateController extends CommonController
|
||||
{
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
{
|
||||
$action = new UpdateDocumentAssetAction(new UpdateDocumentAssetRequest(
|
||||
name: (string) $request->input('name'),
|
||||
label: $request->input('label'),
|
||||
file: $request->file('file'),
|
||||
));
|
||||
|
||||
$response = $action->execute();
|
||||
|
||||
return response()->json([
|
||||
'status' => $response->success ? 'success' : 'error',
|
||||
'message' => $response->message,
|
||||
'token' => $response->asset !== null ? '{asset:' . $response->asset->name . '}' : null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Admin\Controllers;
|
||||
|
||||
use App\Domains\ParticipantInvoice\ParticipantInvoiceTokens;
|
||||
use App\Models\DocumentAsset;
|
||||
use App\Models\DocumentTemplate;
|
||||
use App\Scopes\CommonController;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class DocumentTemplatesGetController extends CommonController
|
||||
{
|
||||
/** Blöcke, die Layout-HTML bzw. CSS enthalten und deshalb im Quelltext-Editor gepflegt werden. */
|
||||
private const array SOURCE_BLOCKS = [
|
||||
DocumentTemplate::BLOCK_LAYOUT,
|
||||
DocumentTemplate::BLOCK_STYLE,
|
||||
];
|
||||
|
||||
private const array BLOCK_LABELS = [
|
||||
DocumentTemplate::BLOCK_LAYOUT => 'Seitengerüst',
|
||||
DocumentTemplate::BLOCK_STYLE => 'Gestaltung (CSS)',
|
||||
'header_sender_return' => 'Rücksendezeile',
|
||||
'header_recipient' => 'Empfängeranschrift',
|
||||
'emblem' => 'Emblem',
|
||||
'logo' => 'Logo',
|
||||
'sender_data' => 'Absenderangaben',
|
||||
'subject' => 'Betreff und Referenzdaten',
|
||||
DocumentTemplate::BLOCK_BODY => 'Rechnungsinhalt',
|
||||
'footer' => 'Grußformel und Fußnote',
|
||||
];
|
||||
|
||||
public function __invoke(): JsonResponse
|
||||
{
|
||||
$blocks = DocumentTemplate::forType(DocumentTemplate::TYPE_PARTICIPANT_INVOICE)
|
||||
->values()
|
||||
->map(fn(DocumentTemplate $block): array => [
|
||||
'block' => $block->block,
|
||||
'label' => self::BLOCK_LABELS[$block->block] ?? $block->block,
|
||||
'content' => (string) $block->content,
|
||||
'editable' => $block->editable,
|
||||
'source' => in_array($block->block, self::SOURCE_BLOCKS, true),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'blocks' => $blocks,
|
||||
'assets' => DocumentAsset::orderBy('name')->get()->map(fn(DocumentAsset $asset): array => [
|
||||
'name' => $asset->name,
|
||||
'label' => $asset->label,
|
||||
'mime' => $asset->mime,
|
||||
'token' => '{asset:' . $asset->name . '}',
|
||||
'preview' => $asset->toDataUri(),
|
||||
]),
|
||||
'tokenGroups' => ParticipantInvoiceTokens::groups(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Admin\Controllers;
|
||||
|
||||
use App\Providers\InertiaProvider;
|
||||
use App\Scopes\CommonController;
|
||||
use Inertia\Response;
|
||||
|
||||
class DocumentTemplatesPageController extends CommonController
|
||||
{
|
||||
public function __invoke(): Response
|
||||
{
|
||||
return new InertiaProvider('Admin/DocumentTemplates', [])->render();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Admin\Controllers;
|
||||
|
||||
use App\Domains\ParticipantInvoice\ParticipantInvoiceTokens;
|
||||
use App\Models\DocumentTemplate;
|
||||
use App\Providers\DocumentTemplateRenderProvider;
|
||||
use App\Providers\PdfGenerateAndDownloadProvider;
|
||||
use App\Scopes\CommonController;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
/**
|
||||
* Rendert die Vorlage mit Beispieldaten, ohne sie zu speichern -- damit niemand blind HTML editiert.
|
||||
*/
|
||||
class DocumentTemplatesPreviewController extends CommonController
|
||||
{
|
||||
public function __invoke(Request $request): Response
|
||||
{
|
||||
$html = new DocumentTemplateRenderProvider(
|
||||
DocumentTemplate::TYPE_PARTICIPANT_INVOICE,
|
||||
(array) $request->input('blocks', []),
|
||||
)->render(ParticipantInvoiceTokens::sample());
|
||||
|
||||
return response(PdfGenerateAndDownloadProvider::fromHtml($html, 'portrait'), 200, [
|
||||
'Content-Type' => 'application/pdf',
|
||||
'Content-Disposition' => 'inline; filename="vorschau.pdf"',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Admin\Controllers;
|
||||
|
||||
use App\Domains\Admin\Actions\UpdateDocumentTemplate\UpdateDocumentTemplateAction;
|
||||
use App\Domains\Admin\Actions\UpdateDocumentTemplate\UpdateDocumentTemplateRequest;
|
||||
use App\Models\DocumentTemplate;
|
||||
use App\Scopes\CommonController;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class DocumentTemplatesUpdateController extends CommonController
|
||||
{
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
{
|
||||
$action = new UpdateDocumentTemplateAction(new UpdateDocumentTemplateRequest(
|
||||
documentType: DocumentTemplate::TYPE_PARTICIPANT_INVOICE,
|
||||
blocks: (array) $request->input('blocks', []),
|
||||
));
|
||||
|
||||
$response = $action->execute();
|
||||
|
||||
return response()->json([
|
||||
'status' => $response->success ? 'success' : 'error',
|
||||
'message' => $response->message,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,13 @@ class ManagedTenantContactGetController extends CommonController
|
||||
return response()->json([
|
||||
'email' => $tenant->email,
|
||||
'email_finance' => $tenant->email_finance,
|
||||
'invoice_sender_name' => $tenant->invoice_sender_name,
|
||||
// Als Platzhalter im Formular: so heisst der Absender, solange nichts eingetragen ist.
|
||||
'name' => $tenant->name,
|
||||
'phone' => $tenant->phone,
|
||||
'address_1' => $tenant->address_1,
|
||||
'address_2' => $tenant->address_2,
|
||||
'address_3' => $tenant->address_3,
|
||||
'postcode' => $tenant->postcode,
|
||||
'city' => $tenant->city,
|
||||
'saveEndpoint' => '/api/v1/admin/tenants/' . $slug . '/contact',
|
||||
|
||||
@@ -20,6 +20,11 @@ class ManagedTenantContactUpdateController extends CommonController
|
||||
emailFinance: $request->input('email_finance'),
|
||||
postcode: $request->input('postcode'),
|
||||
city: $request->input('city'),
|
||||
invoiceSenderName: $request->input('invoice_sender_name'),
|
||||
address1: $request->input('address_1'),
|
||||
address2: $request->input('address_2'),
|
||||
address3: $request->input('address_3'),
|
||||
phone: $request->input('phone'),
|
||||
));
|
||||
|
||||
$response = $action->execute();
|
||||
|
||||
@@ -20,6 +20,11 @@ class ManagedTenantTaxGetController extends CommonController
|
||||
'tax_exemption_reason' => $tenant->tax_exemption_reason,
|
||||
'tax_exemption_note' => $tenant->tax_exemption_note,
|
||||
'vat_pricing_mode' => $tenant->vat_pricing_mode,
|
||||
'tax_number' => $tenant->tax_number,
|
||||
'vat_id' => $tenant->vat_id,
|
||||
'invoice_prefix' => $tenant->invoice_prefix,
|
||||
// In der Mandanten-Verwaltung ist der Nummernkreis änderbar.
|
||||
'can_edit_invoice_prefix' => true,
|
||||
'exemption_reasons' => TaxExemptionReason::options(),
|
||||
'pricing_modes' => VatPricingMode::options(),
|
||||
'saveEndpoint' => '/api/v1/admin/tenants/' . $slug . '/tax',
|
||||
|
||||
@@ -23,6 +23,9 @@ class ManagedTenantTaxUpdateController extends CommonController
|
||||
exemptionReason: TaxExemptionReason::where('slug', $request->input('tax_exemption_reason'))->first(),
|
||||
exemptionNote: $request->input('tax_exemption_note'),
|
||||
vatPricingMode: VatPricingMode::tryFrom((string) $request->input('vat_pricing_mode', 'inclusive')) ?? VatPricingMode::Inclusive,
|
||||
taxNumber: $request->input('tax_number'),
|
||||
vatId: $request->input('vat_id'),
|
||||
invoicePrefix: $request->input('invoice_prefix'),
|
||||
));
|
||||
|
||||
$response = $action->execute();
|
||||
|
||||
@@ -13,6 +13,13 @@ class TenantContactGetController extends CommonController
|
||||
return response()->json([
|
||||
'email' => $this->tenant->email,
|
||||
'email_finance' => $this->tenant->email_finance,
|
||||
'invoice_sender_name' => $this->tenant->invoice_sender_name,
|
||||
// Als Platzhalter im Formular: so heisst der Absender, solange nichts eingetragen ist.
|
||||
'name' => $this->tenant->name,
|
||||
'phone' => $this->tenant->phone,
|
||||
'address_1' => $this->tenant->address_1,
|
||||
'address_2' => $this->tenant->address_2,
|
||||
'address_3' => $this->tenant->address_3,
|
||||
'postcode' => $this->tenant->postcode,
|
||||
'city' => $this->tenant->city,
|
||||
'saveEndpoint' => '/api/v1/admin/tenant/contact',
|
||||
|
||||
@@ -18,6 +18,11 @@ class TenantContactUpdateController extends CommonController
|
||||
emailFinance: $request->input('email_finance'),
|
||||
postcode: $request->input('postcode'),
|
||||
city: $request->input('city'),
|
||||
invoiceSenderName: $request->input('invoice_sender_name'),
|
||||
address1: $request->input('address_1'),
|
||||
address2: $request->input('address_2'),
|
||||
address3: $request->input('address_3'),
|
||||
phone: $request->input('phone'),
|
||||
));
|
||||
|
||||
$response = $action->execute();
|
||||
|
||||
@@ -18,6 +18,11 @@ class TenantTaxGetController extends CommonController
|
||||
'tax_exemption_reason' => $this->tenant->tax_exemption_reason,
|
||||
'tax_exemption_note' => $this->tenant->tax_exemption_note,
|
||||
'vat_pricing_mode' => $this->tenant->vat_pricing_mode,
|
||||
'tax_number' => $this->tenant->tax_number,
|
||||
'vat_id' => $this->tenant->vat_id,
|
||||
'invoice_prefix' => $this->tenant->invoice_prefix,
|
||||
// Im Self-Service nicht änderbar: das Präfix bestimmt den Nummernkreis.
|
||||
'can_edit_invoice_prefix' => false,
|
||||
'exemption_reasons' => TaxExemptionReason::options(),
|
||||
'pricing_modes' => VatPricingMode::options(),
|
||||
'saveEndpoint' => '/api/v1/admin/tenant/tax',
|
||||
|
||||
@@ -21,6 +21,10 @@ class TenantTaxUpdateController extends CommonController
|
||||
exemptionReason: TaxExemptionReason::where('slug', $request->input('tax_exemption_reason'))->first(),
|
||||
exemptionNote: $request->input('tax_exemption_note'),
|
||||
vatPricingMode: VatPricingMode::tryFrom((string) $request->input('vat_pricing_mode', 'inclusive')) ?? VatPricingMode::Inclusive,
|
||||
taxNumber: $request->input('tax_number'),
|
||||
vatId: $request->input('vat_id'),
|
||||
// invoice_prefix wird hier bewusst nicht durchgereicht -- der Nummernkreis ist nur in der
|
||||
// Mandanten-Verwaltung änderbar.
|
||||
));
|
||||
|
||||
$response = $action->execute();
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
<?php
|
||||
|
||||
use App\Domains\Admin\Controllers\DocumentAssetDeleteController;
|
||||
use App\Domains\Admin\Controllers\DocumentAssetUpdateController;
|
||||
use App\Domains\Admin\Controllers\DocumentTemplatesGetController;
|
||||
use App\Domains\Admin\Controllers\DocumentTemplatesPreviewController;
|
||||
use App\Domains\Admin\Controllers\DocumentTemplatesUpdateController;
|
||||
use App\Domains\Admin\Controllers\ManagedTenantContactGetController;
|
||||
use App\Domains\Admin\Controllers\ManagedTenantContactUpdateController;
|
||||
use App\Domains\Admin\Controllers\ManagedTenantGdprGetController;
|
||||
@@ -34,6 +39,7 @@ use App\Domains\Admin\Controllers\UserUpdateController;
|
||||
use App\Middleware\AdminRoleMiddleware;
|
||||
use App\Middleware\IdentifyTenant;
|
||||
use App\Middleware\LvOnlyMiddleware;
|
||||
use App\Middleware\MainAdminRoleMiddleware;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::middleware([IdentifyTenant::class, 'auth', AdminRoleMiddleware::class])->group(function () {
|
||||
@@ -60,6 +66,18 @@ Route::middleware([IdentifyTenant::class, 'auth', AdminRoleMiddleware::class])->
|
||||
Route::post('/{id}/reset-password', UserResetPasswordController::class);
|
||||
});
|
||||
|
||||
// Dokumentvorlagen gelten app-weit für alle Mandanten -- deshalb nur für Hauptadministrator*innen,
|
||||
// AdminRoleMiddleware allein ließe auch Gruppenleitungen durch.
|
||||
Route::middleware(MainAdminRoleMiddleware::class)->group(function () {
|
||||
Route::prefix('api/v1/admin/document-templates')->group(function () {
|
||||
Route::get('/', DocumentTemplatesGetController::class);
|
||||
Route::post('/', DocumentTemplatesUpdateController::class);
|
||||
Route::post('/preview', DocumentTemplatesPreviewController::class);
|
||||
Route::post('/assets', DocumentAssetUpdateController::class);
|
||||
Route::delete('/assets/{name}', DocumentAssetDeleteController::class);
|
||||
});
|
||||
});
|
||||
|
||||
Route::middleware(LvOnlyMiddleware::class)->group(function () {
|
||||
Route::prefix('api/v1/admin/tenants')->group(function () {
|
||||
Route::get('/list', TenantListApiController::class);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Domains\Admin\Controllers\AdminDashboardController;
|
||||
use App\Domains\Admin\Controllers\DocumentTemplatesPageController;
|
||||
use App\Domains\Admin\Controllers\TenantEditPageController;
|
||||
use App\Domains\Admin\Controllers\TenantListPageController;
|
||||
use App\Domains\Admin\Controllers\TenantPageController;
|
||||
@@ -8,6 +9,7 @@ use App\Domains\Admin\Controllers\UserListPageController;
|
||||
use App\Middleware\AdminRoleMiddleware;
|
||||
use App\Middleware\IdentifyTenant;
|
||||
use App\Middleware\LvOnlyMiddleware;
|
||||
use App\Middleware\MainAdminRoleMiddleware;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::middleware([IdentifyTenant::class, 'auth', AdminRoleMiddleware::class])->group(function () {
|
||||
@@ -16,6 +18,11 @@ Route::middleware([IdentifyTenant::class, 'auth', AdminRoleMiddleware::class])->
|
||||
Route::get('/tenant', TenantPageController::class);
|
||||
Route::get('/users', UserListPageController::class);
|
||||
|
||||
// App-weit gültige Dokumentvorlagen -- nur für Hauptadministrator*innen.
|
||||
Route::middleware(MainAdminRoleMiddleware::class)->group(function () {
|
||||
Route::get('/document-templates', DocumentTemplatesPageController::class);
|
||||
});
|
||||
|
||||
Route::middleware(LvOnlyMiddleware::class)->group(function () {
|
||||
Route::get('/tenants', TenantListPageController::class);
|
||||
Route::get('/tenants/{slug}', TenantEditPageController::class);
|
||||
|
||||
@@ -0,0 +1,618 @@
|
||||
<script setup>
|
||||
import {computed, onMounted, ref} from 'vue'
|
||||
import {toast} from 'vue3-toastify'
|
||||
import AdminAppLayout from '../../../../resources/js/layouts/AdminAppLayout.vue'
|
||||
import ShadowedBox from '../../../Views/Components/ShadowedBox.vue'
|
||||
import TextEditor from '../../../Views/Components/TextEditor.vue'
|
||||
import {useAjax} from '../../../../resources/js/components/ajaxHandler.js'
|
||||
|
||||
const {request} = useAjax()
|
||||
|
||||
const blocks = ref([])
|
||||
const assets = ref([])
|
||||
const tokenGroups = ref({})
|
||||
|
||||
const activeBlock = ref(null)
|
||||
const saving = ref(false)
|
||||
|
||||
// Die Vorschau ist ein PDF, das aus den aktuellen -- auch ungespeicherten -- Blockinhalten entsteht.
|
||||
const previewUrl = ref(null)
|
||||
const previewLoading = ref(false)
|
||||
|
||||
const newAsset = ref({name: '', label: '', file: null})
|
||||
const assetInput = ref(null)
|
||||
|
||||
const current = computed(() => blocks.value.find(b => b.block === activeBlock.value) ?? null)
|
||||
|
||||
/** Beim Blockwechsel die gemerkte Einfügemarke verwerfen -- sie gehört zum vorigen Text. */
|
||||
function selectBlock(block) {
|
||||
activeBlock.value = block
|
||||
caret.value = null
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
|
||||
async function load() {
|
||||
const data = await request('/api/v1/admin/document-templates', {method: 'GET'})
|
||||
if (!data) {
|
||||
toast.error('Die Vorlage konnte nicht geladen werden.')
|
||||
return
|
||||
}
|
||||
|
||||
blocks.value = data.blocks ?? []
|
||||
assets.value = data.assets ?? []
|
||||
tokenGroups.value = data.tokenGroups ?? {}
|
||||
activeBlock.value = blocks.value.find(b => b.editable)?.block ?? blocks.value[0]?.block ?? null
|
||||
|
||||
await refreshPreview()
|
||||
}
|
||||
|
||||
/** Nur editierbare Blöcke werden gesendet; der generierte Rechnungsinhalt bleibt unverändert. */
|
||||
function editableBlocks() {
|
||||
return Object.fromEntries(
|
||||
blocks.value.filter(b => b.editable).map(b => [b.block, b.content ?? ''])
|
||||
)
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
|
||||
try {
|
||||
const response = await request('/api/v1/admin/document-templates', {
|
||||
method: 'POST',
|
||||
body: {blocks: editableBlocks()},
|
||||
})
|
||||
|
||||
if (response?.status === 'success') {
|
||||
toast.success(response.message)
|
||||
} else {
|
||||
toast.error(response?.message ?? 'Fehler beim Speichern')
|
||||
}
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshPreview() {
|
||||
previewLoading.value = true
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/admin/document-templates/preview', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content ?? '',
|
||||
},
|
||||
body: JSON.stringify({blocks: editableBlocks()}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
toast.error('Die Vorschau konnte nicht erzeugt werden.')
|
||||
return
|
||||
}
|
||||
|
||||
if (previewUrl.value) {
|
||||
URL.revokeObjectURL(previewUrl.value)
|
||||
}
|
||||
|
||||
previewUrl.value = URL.createObjectURL(await response.blob())
|
||||
} finally {
|
||||
previewLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Platzhalter an der Einfügemarke ergänzen. Im WYSIWYG-Editor übernimmt das TinyMCE selbst, im
|
||||
* Quelltext-Editor wird an der zuletzt bekannten Cursorposition eingesetzt.
|
||||
*/
|
||||
function insertToken(token) {
|
||||
const block = current.value
|
||||
if (!block) return
|
||||
|
||||
const placeholder = wrapForBlock('{' + token + '}', token, block)
|
||||
|
||||
if (block.source) {
|
||||
insertIntoTextarea(placeholder)
|
||||
return
|
||||
}
|
||||
|
||||
const editor = window.tinymce?.activeEditor
|
||||
if (editor) {
|
||||
editor.execCommand('mceInsertContent', false, placeholder)
|
||||
} else {
|
||||
block.content = (block.content ?? '') + placeholder
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Bild-Platzhalter allein ist nirgends sinnvoll: er expandiert zum nackten Data-URI, das dann als
|
||||
* Zeichenkette im Dokument steht. Er braucht immer eine Hülle — im CSS url("..."), sonst ein <img>.
|
||||
*
|
||||
* Im CSS ist das zusätzlich eine Frage der Funktionsfähigkeit: ein Data-URI ausserhalb von url()
|
||||
* kann dompdf nicht verarbeiten und läuft ins Zeitlimit.
|
||||
*/
|
||||
function wrapForBlock(placeholder, token, block) {
|
||||
if (!token.startsWith('asset:')) {
|
||||
return placeholder
|
||||
}
|
||||
|
||||
return block.block === 'style'
|
||||
? `url("${placeholder}")`
|
||||
: `<img src="${placeholder}" alt="" />`
|
||||
}
|
||||
|
||||
const sourceArea = ref(null)
|
||||
// Zuletzt bekannte Einfügemarke im Quelltext-Feld. Ohne das würde ein Klick in der weit darunter
|
||||
// liegenden Platzhalter- oder Bilder-Liste an unvorhersehbarer Stelle einfügen.
|
||||
const caret = ref(null)
|
||||
|
||||
function rememberCaret() {
|
||||
const area = sourceArea.value
|
||||
if (!area) return
|
||||
|
||||
caret.value = {start: area.selectionStart, end: area.selectionEnd}
|
||||
}
|
||||
|
||||
function insertIntoTextarea(text) {
|
||||
const block = current.value
|
||||
if (!block) return
|
||||
|
||||
const content = block.content ?? ''
|
||||
const position = caret.value
|
||||
|
||||
if (position === null) {
|
||||
block.content = content + text
|
||||
return
|
||||
}
|
||||
|
||||
block.content = content.slice(0, position.start) + text + content.slice(position.end)
|
||||
caret.value = {start: position.start + text.length, end: position.start + text.length}
|
||||
}
|
||||
|
||||
async function uploadAsset() {
|
||||
const form = new FormData()
|
||||
form.append('name', newAsset.value.name)
|
||||
form.append('label', newAsset.value.label ?? '')
|
||||
if (newAsset.value.file) {
|
||||
form.append('file', newAsset.value.file)
|
||||
}
|
||||
|
||||
const result = await request('/api/v1/admin/document-templates/assets', {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
})
|
||||
|
||||
if (result?.status === 'success') {
|
||||
toast.success(result.message)
|
||||
newAsset.value = {name: '', label: '', file: null}
|
||||
if (assetInput.value) assetInput.value.value = ''
|
||||
await load()
|
||||
} else {
|
||||
toast.error(result?.message ?? 'Fehler beim Hochladen')
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAsset(name) {
|
||||
if (!confirm(`Das Bild „${name}" wirklich löschen?`)) return
|
||||
|
||||
const result = await request('/api/v1/admin/document-templates/assets/' + name, {method: 'DELETE'})
|
||||
|
||||
if (result?.status === 'success') {
|
||||
toast.success(result.message)
|
||||
await load()
|
||||
} else {
|
||||
// Ein noch in der Vorlage referenziertes Bild lehnt der Server ab.
|
||||
toast.error(result?.message ?? 'Das Bild konnte nicht gelöscht werden.')
|
||||
}
|
||||
}
|
||||
|
||||
function copyToken(token) {
|
||||
navigator.clipboard?.writeText(token)
|
||||
toast.success(`${token} kopiert`)
|
||||
}
|
||||
|
||||
function onFileChosen(event) {
|
||||
newAsset.value.file = event.target.files?.[0] ?? null
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminAppLayout title="Rechnungsvorlage">
|
||||
<shadowed-box style="width: 95%; margin: 20px auto; padding: 20px;">
|
||||
<p class="intro">
|
||||
Die Vorlage gilt für <strong>alle Mandanten</strong>; die eingesetzten Werte kommen je
|
||||
Rechnung aus dem jeweiligen Mandanten. Sie liegt in der Datenbank und wird von einem
|
||||
Update nicht überschrieben.
|
||||
</p>
|
||||
|
||||
<div class="layout">
|
||||
<!-- ── Blöcke und Editor ── -->
|
||||
<div class="editor-column">
|
||||
<div class="block-tabs">
|
||||
<button
|
||||
v-for="block in blocks"
|
||||
:key="block.block"
|
||||
class="block-tab"
|
||||
:class="{active: block.block === activeBlock, readonly: !block.editable}"
|
||||
@click="selectBlock(block.block)"
|
||||
>
|
||||
{{ block.label }}
|
||||
<span v-if="!block.editable" class="badge">generiert</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="current" class="editor-panel">
|
||||
<p v-if="!current.editable" class="notice">
|
||||
Dieser Block wird beim Erzeugen der Rechnung aus den Daten der Anmeldung
|
||||
zusammengesetzt und lässt sich nicht bearbeiten.
|
||||
</p>
|
||||
|
||||
<p v-else-if="current.source" class="notice">
|
||||
Enthält den Seitenaufbau bzw. die Gestaltung und wird deshalb im Quelltext
|
||||
bearbeitet. Für Adresse, Logo oder Fußnote ist das nicht nötig.
|
||||
</p>
|
||||
|
||||
<textarea
|
||||
v-if="current.source || !current.editable"
|
||||
ref="sourceArea"
|
||||
v-model="current.content"
|
||||
class="source-editor"
|
||||
:readonly="!current.editable"
|
||||
spellcheck="false"
|
||||
rows="24"
|
||||
@click="rememberCaret"
|
||||
@keyup="rememberCaret"
|
||||
@blur="rememberCaret"
|
||||
></textarea>
|
||||
|
||||
<!-- convert-urls aus: sonst schreibt TinyMCE {asset:...} in einem src um. -->
|
||||
<TextEditor v-else v-model="current.content" :convert-urls="false" />
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn-save" :disabled="saving" @click="save">
|
||||
{{ saving ? 'Wird gespeichert…' : 'Speichern' }}
|
||||
</button>
|
||||
<button class="btn-secondary" :disabled="previewLoading" @click="refreshPreview">
|
||||
{{ previewLoading ? 'Wird erzeugt…' : 'Vorschau aktualisieren' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ── Platzhalter ── -->
|
||||
<div class="tokens">
|
||||
<h3>Platzhalter</h3>
|
||||
<p class="tokens-hint">
|
||||
Klicken fügt den Platzhalter an der Einfügemarke ein — in
|
||||
<strong>{{ current?.label ?? '—' }}</strong>. Mit
|
||||
<code>{if:name}…{/if:name}</code> lässt sich ein Abschnitt weglassen, solange
|
||||
der Platzhalter leer ist.
|
||||
</p>
|
||||
|
||||
<div v-for="(group, key) in tokenGroups" :key="key" class="token-group">
|
||||
<h4>{{ group.label }}</h4>
|
||||
<button
|
||||
v-for="(token, name) in group.tokens"
|
||||
:key="name"
|
||||
class="token"
|
||||
:title="token.description"
|
||||
@click="insertToken(name)"
|
||||
>{{ '{' + name + '}' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Bilder ── -->
|
||||
<div class="assets">
|
||||
<h3>Bilder</h3>
|
||||
<p class="tokens-hint">
|
||||
Liegen in der Datenbank, damit ein Update sie nicht überschreibt. „Einfügen"
|
||||
schreibt in <strong>{{ current?.label ?? '—' }}</strong><template
|
||||
v-if="current?.block === 'style'"> — im CSS als
|
||||
<code>url("…")</code>, anders kann dompdf ein Bild dort nicht verarbeiten</template>.
|
||||
</p>
|
||||
|
||||
<table class="asset-table">
|
||||
<tr v-for="asset in assets" :key="asset.name">
|
||||
<td class="asset-preview"><img :src="asset.preview" :alt="asset.name" /></td>
|
||||
<td>
|
||||
<strong>{{ asset.name }}</strong>
|
||||
<span v-if="asset.label" class="asset-label">{{ asset.label }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<code class="token-code" @click="copyToken(asset.token)">{{ asset.token }}</code>
|
||||
</td>
|
||||
<td class="asset-actions">
|
||||
<button class="btn-link" @click="insertToken('asset:' + asset.name)">Einfügen</button>
|
||||
<button class="btn-link danger" @click="deleteAsset(asset.name)">Löschen</button>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div class="asset-upload">
|
||||
<input v-model="newAsset.name" type="text" placeholder="Name, z. B. logo" class="form-input" />
|
||||
<input v-model="newAsset.label" type="text" placeholder="Beschreibung (optional)" class="form-input" />
|
||||
<input ref="assetInput" type="file" accept="image/*" @change="onFileChosen" />
|
||||
<button class="btn-secondary" :disabled="!newAsset.name" @click="uploadAsset">
|
||||
Hochladen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Vorschau ── -->
|
||||
<div class="preview-column">
|
||||
<h3>Vorschau</h3>
|
||||
<p class="tokens-hint">Mit Beispieldaten, ohne zu speichern.</p>
|
||||
<iframe v-if="previewUrl" :src="previewUrl" class="preview-frame"></iframe>
|
||||
<p v-else class="notice">Noch keine Vorschau erzeugt.</p>
|
||||
</div>
|
||||
</div>
|
||||
</shadowed-box>
|
||||
</AdminAppLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.intro {
|
||||
margin-bottom: 20px;
|
||||
padding: 10px 12px;
|
||||
border-left: 3px solid #f5c400;
|
||||
background-color: #fffef5;
|
||||
font-size: 0.9rem;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.editor-column {
|
||||
flex: 1 1 60%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.preview-column {
|
||||
flex: 1 1 40%;
|
||||
min-width: 320px;
|
||||
position: sticky;
|
||||
top: 20px;
|
||||
}
|
||||
|
||||
.block-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.block-tab {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
background-color: #ffffff;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.block-tab.active {
|
||||
background-color: #1d4899;
|
||||
border-color: #1d4899;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.block-tab.readonly {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.badge {
|
||||
margin-left: 6px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
background-color: #e5e7eb;
|
||||
color: #4b5563;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.block-tab.active .badge {
|
||||
background-color: rgba(255, 255, 255, 0.25);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.editor-panel {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.notice {
|
||||
margin-bottom: 10px;
|
||||
font-size: 0.85rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.source-editor {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
font-family: 'DejaVu Sans Mono', Menlo, Consolas, monospace;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.5;
|
||||
box-sizing: border-box;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.source-editor[readonly] {
|
||||
background-color: #f9fafb;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.btn-save, .btn-secondary {
|
||||
padding: 8px 20px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.btn-save {
|
||||
background-color: #16a34a;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.btn-save:hover:not(:disabled) {
|
||||
background-color: #15803d;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: #e5e7eb;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background-color: #d1d5db;
|
||||
}
|
||||
|
||||
.btn-save:disabled, .btn-secondary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.tokens, .assets {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.tokens h3, .assets h3, .preview-column h3 {
|
||||
margin-bottom: 6px;
|
||||
font-size: 1rem;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.tokens-hint {
|
||||
margin-bottom: 10px;
|
||||
font-size: 0.8rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.tokens-hint code, .token-code {
|
||||
padding: 1px 4px;
|
||||
background-color: #f3f4f6;
|
||||
border-radius: 3px;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
.token-group {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.token-group h4 {
|
||||
margin-bottom: 4px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: normal;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.token {
|
||||
margin: 0 4px 4px 0;
|
||||
padding: 3px 8px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
background-color: #f9fafb;
|
||||
cursor: pointer;
|
||||
font-family: 'DejaVu Sans Mono', Menlo, Consolas, monospace;
|
||||
font-size: 0.75rem;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.token:hover {
|
||||
background-color: #eef2ff;
|
||||
border-color: #1d4899;
|
||||
}
|
||||
|
||||
.asset-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.asset-table td {
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
vertical-align: middle;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.asset-preview img {
|
||||
max-width: 60px;
|
||||
max-height: 40px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.asset-label {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.token-code {
|
||||
cursor: pointer;
|
||||
font-family: 'DejaVu Sans Mono', Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
.asset-actions {
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
padding: 2px 6px;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
color: #1d4899;
|
||||
}
|
||||
|
||||
.btn-link.danger {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.asset-upload {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
padding: 6px 10px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.preview-frame {
|
||||
width: 100%;
|
||||
height: 780px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.layout {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.preview-column {
|
||||
position: static;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -13,9 +13,17 @@ const props = defineProps({
|
||||
const { request } = useAjax()
|
||||
|
||||
const editing = ref(false)
|
||||
// Solange kein eigener Absender eingetragen ist, steht auf der Rechnung der Name des Mandanten.
|
||||
const senderFallback = props.data.name ?? ''
|
||||
|
||||
const form = ref({
|
||||
invoice_sender_name: props.data.invoice_sender_name ?? '',
|
||||
email: props.data.email ?? '',
|
||||
email_finance: props.data.email_finance ?? '',
|
||||
phone: props.data.phone ?? '',
|
||||
address_1: props.data.address_1 ?? '',
|
||||
address_2: props.data.address_2 ?? '',
|
||||
address_3: props.data.address_3 ?? '',
|
||||
postcode: props.data.postcode ?? '',
|
||||
city: props.data.city ?? '',
|
||||
})
|
||||
@@ -39,16 +47,49 @@ async function save() {
|
||||
<template>
|
||||
<div v-if="!editing">
|
||||
<table class="data-table">
|
||||
<tr>
|
||||
<th>Rechnungs-Absender:</th>
|
||||
<td>
|
||||
{{ form.invoice_sender_name || senderFallback }}
|
||||
<span v-if="!form.invoice_sender_name" class="field-hint">
|
||||
Name des Mandanten, da nichts Eigenes hinterlegt ist
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr><th>Email:</th><td>{{ form.email }}</td></tr>
|
||||
<tr><th>Email Schatzmeister*in:</th><td>{{ form.email_finance }}</td></tr>
|
||||
<tr><th>Telefon:</th><td>{{ form.phone || '—' }}</td></tr>
|
||||
<tr><th>Straße & Hausnummer:</th><td>{{ form.address_1 || '—' }}</td></tr>
|
||||
<tr><th>Adresszusatz:</th><td>{{ form.address_2 || '—' }}</td></tr>
|
||||
<tr><th>Weiterer Zusatz:</th><td>{{ form.address_3 || '—' }}</td></tr>
|
||||
<tr><th>Postleitzahl:</th><td>{{ form.postcode }}</td></tr>
|
||||
<tr><th>Ort:</th><td>{{ form.city }}</td></tr>
|
||||
</table>
|
||||
|
||||
<p v-if="!form.address_1" class="hint">
|
||||
Ohne Straße & Hausnummer ist die Anschrift auf Rechnungen unvollständig
|
||||
(§ 14 Abs. 4 UStG).
|
||||
</p>
|
||||
<button class="btn-edit" @click="editing = true">Bearbeiten</button>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<table class="data-table">
|
||||
<tr>
|
||||
<th>Rechnungs-Absender:</th>
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
v-model="form.invoice_sender_name"
|
||||
class="form-input"
|
||||
:placeholder="senderFallback"
|
||||
/>
|
||||
<span class="field-hint">
|
||||
Vollständige Bezeichnung mit Rechtsform, wie sie auf der Rechnung stehen soll.
|
||||
Leer lassen, um den Namen des Mandanten zu verwenden.
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Email:</th>
|
||||
<td><input type="email" v-model="form.email" class="form-input" /></td>
|
||||
@@ -57,6 +98,27 @@ async function save() {
|
||||
<th>Email Schatzmeister*in:</th>
|
||||
<td><input type="email" v-model="form.email_finance" class="form-input" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Telefon:</th>
|
||||
<td><input type="text" v-model="form.phone" class="form-input" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Straße & Hausnummer:</th>
|
||||
<td>
|
||||
<input type="text" v-model="form.address_1" class="form-input" />
|
||||
<span class="field-hint">Pflichtangabe für Rechnungen (§ 14 Abs. 4 UStG)</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Adresszusatz:</th>
|
||||
<td>
|
||||
<input type="text" v-model="form.address_2" class="form-input" placeholder="z. B. c/o Mustermensch" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Weiterer Zusatz:</th>
|
||||
<td><input type="text" v-model="form.address_3" class="form-input" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Postleitzahl:</th>
|
||||
<td><input type="text" v-model="form.postcode" class="form-input" /></td>
|
||||
@@ -101,6 +163,22 @@ async function save() {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 0.8rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
border-left: 3px solid #f5c400;
|
||||
background-color: #fffef5;
|
||||
font-size: 0.85rem;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.btn-edit, .btn-save, .btn-cancel {
|
||||
margin-top: 15px;
|
||||
padding: 8px 20px;
|
||||
|
||||
@@ -29,8 +29,15 @@ const form = ref({
|
||||
tax_exemption_reason: props.data.tax_exemption_reason ?? '',
|
||||
tax_exemption_note: props.data.tax_exemption_note ?? '',
|
||||
vat_pricing_mode: props.data.vat_pricing_mode ?? 'inclusive',
|
||||
tax_number: props.data.tax_number ?? '',
|
||||
vat_id: props.data.vat_id ?? '',
|
||||
invoice_prefix: props.data.invoice_prefix ?? '',
|
||||
})
|
||||
|
||||
// Das Präfix bestimmt den Nummernkreis und darf sich nachträglich nicht ändern -- sonst gehörten bereits
|
||||
// herausgegebene Rechnungen plötzlich zu einer anderen Nummer. Nur in der verwalteten Ansicht editierbar.
|
||||
const canEditPrefix = computed(() => props.data.can_edit_invoice_prefix === true)
|
||||
|
||||
const isLiable = computed(() => form.value.tax_liable === '1')
|
||||
const isCustomReason = computed(() => form.value.tax_exemption_reason === 'custom')
|
||||
|
||||
@@ -65,6 +72,9 @@ async function save() {
|
||||
tax_exemption_reason: isLiable.value ? null : form.value.tax_exemption_reason,
|
||||
tax_exemption_note: form.value.tax_exemption_note,
|
||||
vat_pricing_mode: form.value.vat_pricing_mode,
|
||||
tax_number: form.value.tax_number,
|
||||
vat_id: form.value.vat_id,
|
||||
invoice_prefix: canEditPrefix.value ? form.value.invoice_prefix : undefined,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -100,6 +110,18 @@ async function save() {
|
||||
<th>Rechnungshinweis</th>
|
||||
<td>{{ invoiceHint }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Steuernummer</th>
|
||||
<td>{{ form.tax_number || '—' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>USt-IdNr.</th>
|
||||
<td>{{ form.vat_id || '—' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Rechnungs-Präfix</th>
|
||||
<td>{{ form.invoice_prefix || '—' }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
<button class="btn-edit" @click="editing = true">Bearbeiten</button>
|
||||
</div>
|
||||
@@ -152,6 +174,42 @@ async function save() {
|
||||
<th>Rechnungshinweis</th>
|
||||
<td class="hint-preview">{{ invoiceHint }}</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th>Steuernummer</th>
|
||||
<td>
|
||||
<input type="text" v-model="form.tax_number" class="form-input" />
|
||||
<span class="field-hint">
|
||||
Pflichtangabe auf Rechnungen — alternativ die USt-IdNr. (§ 14 Abs. 4 Nr. 2 UStG)
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>USt-IdNr.</th>
|
||||
<td><input type="text" v-model="form.vat_id" class="form-input" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Rechnungs-Präfix</th>
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
v-model="form.invoice_prefix"
|
||||
class="form-input"
|
||||
:disabled="!canEditPrefix"
|
||||
/>
|
||||
<span class="field-hint">
|
||||
Erster Block der Rechnungsnummer, z. B. <code>WM</code> in
|
||||
<code>WM-V-20260701-0005</code>.
|
||||
<template v-if="canEditPrefix">
|
||||
Eine Änderung wirkt nur auf künftige Veranstaltungen; bereits angelegte behalten
|
||||
ihre Nummer.
|
||||
</template>
|
||||
<template v-else>
|
||||
Änderbar nur in der Mandanten-Verwaltung.
|
||||
</template>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div class="btn-group">
|
||||
<button class="btn-save" @click="save">Speichern</button>
|
||||
@@ -195,6 +253,25 @@ async function save() {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 0.8rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.field-hint code {
|
||||
padding: 1px 4px;
|
||||
background-color: #f3f4f6;
|
||||
border-radius: 3px;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
.form-input:disabled {
|
||||
background-color: #f3f4f6;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.btn-edit, .btn-save, .btn-cancel {
|
||||
margin-top: 15px;
|
||||
padding: 8px 20px;
|
||||
|
||||
Reference in New Issue
Block a user