22-05-2026 Optimierung der User und Admin Panels
This commit is contained in:
parent
d2ba22c0cf
commit
e8c47b7553
73 changed files with 10282 additions and 1546 deletions
1
.devcontainer/Untitled
Normal file
1
.devcontainer/Untitled
Normal file
|
|
@ -0,0 +1 @@
|
|||
.devcontainer
|
||||
108
app/Console/Commands/PublishScheduledPressReleases.php
Normal file
108
app/Console/Commands/PublishScheduledPressReleases.php
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Models\PressRelease;
|
||||
use App\Services\PressRelease\BlacklistViolationException;
|
||||
use App\Services\PressRelease\PressReleaseService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Str;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Veröffentlicht Pressemitteilungen mit Status `review` und einem
|
||||
* `scheduled_at`-Zeitpunkt, der erreicht/überschritten wurde.
|
||||
*
|
||||
* Läuft regelmäßig per Scheduler (siehe routes/console.php). Idempotent:
|
||||
* berührt nur PRs in Review-Status — bereits publishte werden ignoriert.
|
||||
*
|
||||
* Blacklist-Treffer landen wie beim manuellen Publish im Reject-Status mit
|
||||
* Mail-Benachrichtigung des Autors.
|
||||
*/
|
||||
class PublishScheduledPressReleases extends Command
|
||||
{
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'press-releases:publish-scheduled
|
||||
{--dry-run : Nur anzeigen, was publiziert würde, ohne DB zu ändern}
|
||||
{--limit=200 : Maximale Anzahl pro Lauf}';
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Veröffentlicht fällige geplante Pressemitteilungen (Status review + scheduled_at <= now).';
|
||||
|
||||
public function handle(PressReleaseService $service): int
|
||||
{
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$limit = max(1, (int) $this->option('limit'));
|
||||
|
||||
$now = now();
|
||||
|
||||
$candidates = PressRelease::withoutGlobalScopes()
|
||||
->where('status', PressReleaseStatus::Review->value)
|
||||
->whereNotNull('scheduled_at')
|
||||
->where('scheduled_at', '<=', $now)
|
||||
->orderBy('scheduled_at')
|
||||
->limit($limit)
|
||||
->get();
|
||||
|
||||
if ($candidates->isEmpty()) {
|
||||
$this->info('Keine fälligen geplanten Pressemitteilungen gefunden.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'%d fällige Pressemitteilung(en) gefunden.%s',
|
||||
$candidates->count(),
|
||||
$dryRun ? ' (Dry-Run)' : '',
|
||||
));
|
||||
|
||||
$published = 0;
|
||||
$rejected = 0;
|
||||
$failed = 0;
|
||||
|
||||
foreach ($candidates as $pressRelease) {
|
||||
$line = sprintf(
|
||||
' #%d scheduled_at=%s title="%s"',
|
||||
$pressRelease->id,
|
||||
$pressRelease->scheduled_at?->format('Y-m-d H:i') ?? '-',
|
||||
Str::limit($pressRelease->title, 60),
|
||||
);
|
||||
|
||||
if ($dryRun) {
|
||||
$this->line($line.' [DRY]');
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$service->publish($pressRelease, source: 'scheduler');
|
||||
$published++;
|
||||
$this->line($line.' [OK]');
|
||||
} catch (BlacklistViolationException $e) {
|
||||
$rejected++;
|
||||
$this->warn($line.' [REJECT: '.$e->word.']');
|
||||
} catch (Throwable $e) {
|
||||
$failed++;
|
||||
$this->error($line.' [FAIL: '.$e->getMessage().']');
|
||||
report($e);
|
||||
}
|
||||
}
|
||||
|
||||
if (! $dryRun) {
|
||||
$this->newLine();
|
||||
$this->info(sprintf(
|
||||
'Fertig: %d veröffentlicht, %d wegen Blacklist abgelehnt, %d fehlgeschlagen.',
|
||||
$published,
|
||||
$rejected,
|
||||
$failed,
|
||||
));
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,14 @@ use Symfony\Component\HttpFoundation\Response;
|
|||
|
||||
class LegacyInvoicePdfRenderer
|
||||
{
|
||||
private const PAGE_WIDTH = 595;
|
||||
|
||||
private const PAGE_HEIGHT = 842;
|
||||
|
||||
private const LEFT = 91;
|
||||
|
||||
private const RIGHT = 524;
|
||||
|
||||
public function inlineResponse(LegacyInvoice $invoice): Response
|
||||
{
|
||||
$pdf = $this->render($invoice);
|
||||
|
|
@ -38,14 +46,13 @@ class LegacyInvoicePdfRenderer
|
|||
|
||||
public function render(LegacyInvoice $invoice): string
|
||||
{
|
||||
$lines = $this->lines($invoice);
|
||||
$content = "BT\n/F1 11 Tf\n50 790 Td\n14 TL\n";
|
||||
$data = $this->data($invoice);
|
||||
$content = '';
|
||||
$isMedia = $data['is_media'];
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$content .= '('.$this->escapePdfText($line).") Tj\nT*\n";
|
||||
}
|
||||
|
||||
$content .= "ET\n";
|
||||
$this->drawHeader($content, $isMedia);
|
||||
$this->drawInvoiceBody($content, $data);
|
||||
$this->drawFooter($content, $isMedia);
|
||||
|
||||
return $this->buildPdf($content);
|
||||
}
|
||||
|
|
@ -61,16 +68,30 @@ class LegacyInvoicePdfRenderer
|
|||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
* @return array{
|
||||
* invoice: LegacyInvoice,
|
||||
* billing_address: array<string, mixed>,
|
||||
* invoice_data: array<string, mixed>,
|
||||
* is_media: bool,
|
||||
* is_netto: bool,
|
||||
* tax_percent: int,
|
||||
* amount: float,
|
||||
* net_amount: float,
|
||||
* tax_amount: float,
|
||||
* service_name: string,
|
||||
* service_period_begin: string,
|
||||
* service_period_end: string
|
||||
* }
|
||||
*/
|
||||
private function lines(LegacyInvoice $invoice): array
|
||||
private function data(LegacyInvoice $invoice): array
|
||||
{
|
||||
$payload = $invoice->pdf_payload ?? [];
|
||||
$billingAddress = data_get($payload, 'billing_address', []);
|
||||
$billingAddress = data_get($payload, 'billing_address') ?: [];
|
||||
$invoiceData = data_get($payload, 'invoice', $invoice->raw_snapshot ?? []);
|
||||
$invoice->loadMissing('user.profile');
|
||||
|
||||
$isNetto = (bool) data_get($invoiceData, 'is_netto', false);
|
||||
$isMedia = (bool) data_get($invoiceData, 'is_media', true);
|
||||
$taxPercent = $this->taxPercent($invoice);
|
||||
$amount = $invoice->total_cents / 100;
|
||||
$netAmount = $isNetto ? $amount : $amount / (1 + ($taxPercent / 100));
|
||||
|
|
@ -81,50 +102,211 @@ class LegacyInvoicePdfRenderer
|
|||
?? data_get($payload, 'payment_option.article_number')
|
||||
?? 'Legacy-Leistung';
|
||||
|
||||
return array_values(array_filter([
|
||||
$invoice->legacy_portal->label(),
|
||||
'adametz.media, Kevin Adametz, In der Lake 4, 33739 Bielefeld',
|
||||
'www.businessportal24.com',
|
||||
str_repeat('-', 68),
|
||||
'',
|
||||
'Legacy-Rechnung',
|
||||
'Rechnungsdatum: '.$invoice->invoice_date?->format('d.m.Y'),
|
||||
'',
|
||||
'Rechnungsadresse',
|
||||
data_get($billingAddress, 'name'),
|
||||
data_get($billingAddress, 'title'),
|
||||
data_get($billingAddress, 'address'),
|
||||
trim((string) data_get($billingAddress, 'postal_code').' '.(string) data_get($billingAddress, 'city')),
|
||||
data_get($billingAddress, 'country_name'),
|
||||
return [
|
||||
'invoice' => $invoice,
|
||||
'billing_address' => $billingAddress,
|
||||
'invoice_data' => $invoiceData,
|
||||
'is_media' => $isMedia,
|
||||
'is_netto' => $isNetto,
|
||||
'tax_percent' => $taxPercent,
|
||||
'amount' => $amount,
|
||||
'net_amount' => $netAmount,
|
||||
'tax_amount' => $taxAmount,
|
||||
'service_name' => (string) $serviceName,
|
||||
'service_period_begin' => $servicePeriodBegin,
|
||||
'service_period_end' => $servicePeriodEnd,
|
||||
];
|
||||
}
|
||||
|
||||
private function drawHeader(string &$content, bool $isMedia): void
|
||||
{
|
||||
if ($isMedia) {
|
||||
$content .= $this->text(0, 790, 'adametz.media', 21, 'F2', align: 'center');
|
||||
$content .= $this->line(180, 778, 415, 778, 0.7);
|
||||
$content .= $this->text(0, 762, 'adametz.media, Kevin Adametz, In der Lake 4, 33739 Bielefeld', 9, align: 'center');
|
||||
$content .= $this->text(0, 748, 'www.businessportal24.com', 10, 'F2', align: 'center');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$content .= $this->text(self::LEFT, 790, 'Stern Consulting GmbH', 19, 'F2');
|
||||
$content .= $this->line(self::LEFT, 778, self::RIGHT, 778, 0.7);
|
||||
$content .= $this->text(self::LEFT, 760, 'Stern Consulting GmbH * Emser Straße 3 * 10719 Berlin', 10, 'F2');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
private function drawInvoiceBody(string &$content, array $data): void
|
||||
{
|
||||
/** @var LegacyInvoice $invoice */
|
||||
$invoice = $data['invoice'];
|
||||
$billingAddress = $data['billing_address'];
|
||||
$invoiceDate = $invoice->invoice_date?->format('d.m.Y') ?? 'n/a';
|
||||
$invoiceNumber = $invoice->number ?? '#'.$invoice->legacy_id;
|
||||
$y = 700.0;
|
||||
|
||||
$content .= $this->text(self::LEFT, $y, 'Rechnungsdatum: '.$invoiceDate, 9);
|
||||
$y -= 34;
|
||||
|
||||
foreach ($this->addressLines($invoice, $billingAddress) as $line) {
|
||||
$content .= $this->text(self::LEFT, $y, $line, 9);
|
||||
$y -= 13;
|
||||
}
|
||||
|
||||
$y -= 26;
|
||||
$content .= $this->text(self::LEFT, $y, 'Leistung:', 9);
|
||||
$y -= 15;
|
||||
$content .= $this->text(self::LEFT, $y, $data['service_name'].' auf '.$invoice->legacy_portal->label(), 10, 'F2');
|
||||
$y = $this->wrappedText($content, self::LEFT, $y - 16, $this->pressReleaseTitle($data), 9, 360);
|
||||
$y -= 6;
|
||||
|
||||
$periodLabel = $data['service_period_begin'] === $data['service_period_end']
|
||||
? 'Leistungsdatum: '.$data['service_period_begin']
|
||||
: 'Leistungszeitraum: '.$data['service_period_begin'].' - '.$data['service_period_end'];
|
||||
$content .= $this->text(self::LEFT, $y, $periodLabel, 9);
|
||||
$y -= 17;
|
||||
$content .= $this->text(self::LEFT, $y, 'Rechnungsnummer: '.$invoiceNumber, 10, 'F2');
|
||||
$y -= 34;
|
||||
|
||||
$y = $this->invoiceTable($content, self::LEFT, $y, [
|
||||
['Rechnungsstellung:', $invoiceDate, false],
|
||||
['Netto:', $this->formatEuro($data['net_amount']), false],
|
||||
$data['is_netto'] ? null : ['MwSt. '.$data['tax_percent'].'%:', $this->formatEuro($data['tax_amount']), false],
|
||||
['Rechnungsbetrag:', $this->formatEuro($data['amount']), true],
|
||||
]);
|
||||
|
||||
$y -= 22;
|
||||
foreach ($this->paymentLines($data) as $line) {
|
||||
$content .= $this->text(self::LEFT, $y, $line, 9);
|
||||
$y -= $line === '' ? 9 : 12;
|
||||
}
|
||||
}
|
||||
|
||||
private function drawFooter(string &$content, bool $isMedia): void
|
||||
{
|
||||
$content .= $this->line(self::LEFT, 76, self::RIGHT, 76, 0.5);
|
||||
|
||||
if ($isMedia) {
|
||||
$columns = [
|
||||
["adametz.media\nKevin Adametz\nIn der Lake 4\n33739 Bielefeld", 91, 62],
|
||||
["Tel: +49 5206 7076721\nMail: info@businessportal24.com\nSite: www.businessportal24.com", 165, 62],
|
||||
["Bankverbindung\nSparkasse Bielefeld\nIBAN: DE96 4805 0161 0065 0356 02\nBIC: SPBIDE3BXXX", 292, 62],
|
||||
["Steuernummer:\n349 / 5001 / 4350\nUSt-ID: DE298729654", 445, 62],
|
||||
];
|
||||
} else {
|
||||
$columns = [
|
||||
["Stern Consulting GmbH\nEmser Straße 3\n10719 Berlin\nGF: Thomas Stern", 91, 62],
|
||||
["Tel: +49 (0)30 / 700 9410 0\nFax: +49 (0)30 / 700 9410 44\nMail: info@stern-consulting.de\nSite: www.stern-consulting.de", 185, 62],
|
||||
["Bankverbindung\nHypo Vereinsbank\nBLZ: 10020890\nK-Nr: 22865552", 325, 62],
|
||||
["Registergericht:\nAmtsgericht Charlottenburg\nHRB 134586 B\nUSt-Id-Nr.: DE277693156", 430, 62],
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($columns as [$text, $x, $y]) {
|
||||
$this->wrappedText($content, $x, $y, $text, 6, 105, 8);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $billingAddress
|
||||
* @return list<string>
|
||||
*/
|
||||
private function addressLines(LegacyInvoice $invoice, array $billingAddress): array
|
||||
{
|
||||
$salutation = trim((string) data_get($billingAddress, 'salutation'));
|
||||
$name = trim($salutation.' '.(string) data_get($billingAddress, 'name'));
|
||||
$city = trim((string) data_get($billingAddress, 'postal_code').' '.(string) data_get($billingAddress, 'city'));
|
||||
$lines = [
|
||||
$name,
|
||||
(string) data_get($billingAddress, 'title'),
|
||||
...$this->splitLines((string) data_get($billingAddress, 'address')),
|
||||
$city,
|
||||
(string) data_get($billingAddress, 'country_name'),
|
||||
$invoice->user?->profile?->tax_id_number ? 'UID-Nr.: '.$invoice->user->profile->tax_id_number : null,
|
||||
];
|
||||
|
||||
return array_values(array_filter($lines, fn (?string $line): bool => filled($line)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
private function pressReleaseTitle(array $data): string
|
||||
{
|
||||
return (string) (
|
||||
data_get($data, 'invoice_data.press_release_title')
|
||||
?? data_get($data, 'invoice_data.press_release.name')
|
||||
?? ''
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{0: string, 1: string, 2: bool}|null> $rows
|
||||
*/
|
||||
private function invoiceTable(string &$content, float $x, float $y, array $rows): float
|
||||
{
|
||||
$width = 275.0;
|
||||
$rowHeight = 22.0;
|
||||
$valueX = $x + 160;
|
||||
|
||||
$content .= $this->line($x, $y, $x + $width, $y, 0.5);
|
||||
foreach (array_values(array_filter($rows)) as [$label, $value, $bold]) {
|
||||
$y -= $rowHeight;
|
||||
$content .= $this->text($x, $y + 7, $label, 9, $bold ? 'F2' : 'F1');
|
||||
$content .= $this->text($valueX, $y + 7, $value, 9, $bold ? 'F2' : 'F1');
|
||||
$content .= $this->line($x, $y, $x + $width, $y, 0.5);
|
||||
}
|
||||
|
||||
return $y;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @return list<string>
|
||||
*/
|
||||
private function paymentLines(array $data): array
|
||||
{
|
||||
/** @var LegacyInvoice $invoice */
|
||||
$invoice = $data['invoice'];
|
||||
$invoiceNumber = $invoice->number ?? '#'.$invoice->legacy_id;
|
||||
|
||||
if (! $data['is_media']) {
|
||||
$lines = [
|
||||
'Bitte überweisen Sie den Rechnungsbetrag innerhalb von 7 Werktagen auf unser Geschäftskonto:',
|
||||
'',
|
||||
'Leistung: '.$serviceName.' auf '.$invoice->legacy_portal->label(),
|
||||
$servicePeriodBegin === $servicePeriodEnd
|
||||
? 'Leistungsdatum: '.$servicePeriodBegin
|
||||
: 'Leistungszeitraum: '.$servicePeriodBegin.' - '.$servicePeriodEnd,
|
||||
'Rechnungsnummer: '.($invoice->number ?? '#'.$invoice->legacy_id),
|
||||
'Kontoinhaber: Stern Consulting GmbH',
|
||||
'IBAN: DE23100208900022865552',
|
||||
'BIC: HYVEDEMM488',
|
||||
'Bank: Hypo Vereinsbank',
|
||||
'',
|
||||
str_repeat('-', 68),
|
||||
'Rechnungsstellung: '.$invoice->invoice_date?->format('d.m.Y'),
|
||||
str_repeat('-', 68),
|
||||
'Netto: '.$this->formatEuro($netAmount),
|
||||
$isNetto ? null : 'MwSt. '.$taxPercent.'%: '.$this->formatEuro($taxAmount),
|
||||
str_repeat('-', 68),
|
||||
'Betrag: '.$this->formatEuro($amount),
|
||||
str_repeat('-', 68),
|
||||
'Als Verwendungszweck geben Sie bitte "Rechungsnummer '.$invoiceNumber.'" an!',
|
||||
'',
|
||||
'Zahlart: '.($invoice->payment_method ?? 'n/a'),
|
||||
'Status: '.($invoice->status ?? 'unknown'),
|
||||
$invoice->paid_at ? 'Bezahlt am: '.$invoice->paid_at->format('d.m.Y') : 'Faellig am: '.$invoice->due_date?->format('d.m.Y'),
|
||||
'Bitte ignorieren Sie diese Zahlungsaufforderung, falls der Betrag bereits entrichtet wurde.',
|
||||
];
|
||||
} else {
|
||||
$lines = [
|
||||
'Bitte überweisen Sie den Rechnungsbetrag innerhalb von 7 Werktagen auf unser Geschäftskonto:',
|
||||
'',
|
||||
'Bitte ueberweisen Sie den Rechnungsbetrag unter Angabe der Rechnungsnummer '.($invoice->number ?? '#'.$invoice->legacy_id).'.',
|
||||
'Bankverbindung: Sparkasse Bielefeld, IBAN DE96 4805 0161 0065 0356 02, BIC SPBIDE3BXXX',
|
||||
$isNetto ? 'Reverse Charge: Steuerschuldnerschaft des Leistungsempfaengers.' : null,
|
||||
'Wichtig! Ab Januar 2024 gilt die neue Bankverbindung:',
|
||||
'',
|
||||
str_repeat('-', 68),
|
||||
'adametz.media | Tel: +49 5206 7076721 | Mail: info@businessportal24.com',
|
||||
'Steuernummer: 349 / 5001 / 4350 | USt-ID: DE298729654',
|
||||
], fn (mixed $line): bool => $line !== null && $line !== ''));
|
||||
'Kontoinhaber: adametz.media, Kevin Adametz',
|
||||
'IBAN: DE96 4805 0161 0065 0356 02',
|
||||
'BIC: SPBIDE3BXXX',
|
||||
'Bank: Sparkasse Bielefeld',
|
||||
'',
|
||||
'Als Verwendungszweck geben Sie bitte "Rechungsnummer '.$invoiceNumber.'" an!',
|
||||
'',
|
||||
'Bitte ignorieren Sie diese Zahlungsaufforderung, falls der Betrag bereits entrichtet wurde.',
|
||||
];
|
||||
}
|
||||
|
||||
if ($data['is_netto']) {
|
||||
$lines[] = '';
|
||||
$lines[] = 'Leistungen im Reverse-Charge Verfahren - Steuerschuldnerschaft des Leistungsempfängers';
|
||||
}
|
||||
|
||||
return $lines;
|
||||
}
|
||||
|
||||
private function taxPercent(LegacyInvoice $invoice): int
|
||||
|
|
@ -149,7 +331,7 @@ class LegacyInvoicePdfRenderer
|
|||
|
||||
private function formatEuro(float $amount): string
|
||||
{
|
||||
return number_format($amount, 2, ',', '.').' EUR';
|
||||
return number_format($amount, 2, ',', '.').' €';
|
||||
}
|
||||
|
||||
private function buildPdf(string $content): string
|
||||
|
|
@ -157,9 +339,10 @@ class LegacyInvoicePdfRenderer
|
|||
$objects = [
|
||||
"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n",
|
||||
"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n",
|
||||
"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>\nendobj\n",
|
||||
"4 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n",
|
||||
"5 0 obj\n<< /Length ".strlen($content)." >>\nstream\n{$content}endstream\nendobj\n",
|
||||
"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ".self::PAGE_WIDTH.' '.self::PAGE_HEIGHT."] /Resources << /Font << /F1 4 0 R /F2 5 0 R >> >> /Contents 6 0 R >>\nendobj\n",
|
||||
"4 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>\nendobj\n",
|
||||
"5 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>\nendobj\n",
|
||||
"6 0 obj\n<< /Length ".strlen($content)." >>\nstream\n{$content}endstream\nendobj\n",
|
||||
];
|
||||
|
||||
$pdf = "%PDF-1.4\n";
|
||||
|
|
@ -190,4 +373,95 @@ class LegacyInvoicePdfRenderer
|
|||
|
||||
return str_replace(['\\', '(', ')'], ['\\\\', '\(', '\)'], $encoded ?: Str::ascii($text));
|
||||
}
|
||||
|
||||
private function text(float $x, float $y, string $text, int $size = 9, string $font = 'F1', string $align = 'left'): string
|
||||
{
|
||||
if ($align === 'center') {
|
||||
$x = (self::PAGE_WIDTH - $this->textWidth($text, $size)) / 2;
|
||||
}
|
||||
|
||||
return sprintf(
|
||||
"BT /%s %d Tf %.2F %.2F Td (%s) Tj ET\n",
|
||||
$font,
|
||||
$size,
|
||||
$x,
|
||||
$y,
|
||||
$this->escapePdfText($text),
|
||||
);
|
||||
}
|
||||
|
||||
private function line(float $x1, float $y1, float $x2, float $y2, float $width = 0.5): string
|
||||
{
|
||||
return sprintf("%.2F w %.2F %.2F m %.2F %.2F l S\n", $width, $x1, $y1, $x2, $y2);
|
||||
}
|
||||
|
||||
private function wrappedText(
|
||||
string &$content,
|
||||
float $x,
|
||||
float $y,
|
||||
string $text,
|
||||
int $size,
|
||||
float $width,
|
||||
float $lineHeight = 12,
|
||||
string $font = 'F1',
|
||||
): float {
|
||||
foreach ($this->wrap($text, $width, $size) as $line) {
|
||||
if ($line !== '') {
|
||||
$content .= $this->text($x, $y, $line, $size, $font);
|
||||
}
|
||||
|
||||
$y -= $lineHeight;
|
||||
}
|
||||
|
||||
return $y;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function wrap(string $text, float $width, int $size): array
|
||||
{
|
||||
$lines = [];
|
||||
$maxCharacters = max(8, (int) floor($width / ($size * 0.48)));
|
||||
|
||||
foreach ($this->splitLines($text) as $paragraph) {
|
||||
if ($paragraph === '') {
|
||||
$lines[] = '';
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$line = '';
|
||||
foreach (preg_split('/\s+/', $paragraph) ?: [] as $word) {
|
||||
$candidate = trim($line.' '.$word);
|
||||
if ($line !== '' && Str::length($candidate) > $maxCharacters) {
|
||||
$lines[] = $line;
|
||||
$line = $word;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$line = $candidate;
|
||||
}
|
||||
|
||||
if ($line !== '') {
|
||||
$lines[] = $line;
|
||||
}
|
||||
}
|
||||
|
||||
return $lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function splitLines(string $text): array
|
||||
{
|
||||
return explode("\n", str_replace(["\r\n", "\r"], "\n", $text));
|
||||
}
|
||||
|
||||
private function textWidth(string $text, int $size): float
|
||||
{
|
||||
return Str::length($text) * $size * 0.48;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ use App\Models\Company;
|
|||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Session;
|
||||
|
||||
class CustomerCompanyContext
|
||||
{
|
||||
|
|
@ -60,7 +62,54 @@ class CustomerCompanyContext
|
|||
return null;
|
||||
}
|
||||
|
||||
return $this->companiesFor($user)->firstWhere('id', $companyId);
|
||||
return $this->findFor($user, $companyId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Company>
|
||||
*/
|
||||
public function latestCompaniesFor(User $user, int $limit = 10): Collection
|
||||
{
|
||||
return $this->accessibleCompanyQuery($user)
|
||||
->select(['companies.id', 'companies.name', 'companies.portal', 'companies.owner_user_id', 'companies.created_at'])
|
||||
->latest('companies.created_at')
|
||||
->latest('companies.id')
|
||||
->limit($limit)
|
||||
->get();
|
||||
}
|
||||
|
||||
public function companyCountFor(User $user): int
|
||||
{
|
||||
return $this->accessibleCompanyQuery($user)->count();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Company>
|
||||
*/
|
||||
public function switcherCompaniesFor(User $user, ?int $selectedCompanyId = null, int $limit = 50): Collection
|
||||
{
|
||||
$companies = $this->switcherCompanyQuery($user)
|
||||
->latest('companies.created_at')
|
||||
->latest('companies.id')
|
||||
->limit($limit)
|
||||
->get();
|
||||
|
||||
if ($selectedCompanyId === null || $companies->contains('id', $selectedCompanyId)) {
|
||||
return $companies;
|
||||
}
|
||||
|
||||
$selectedCompany = $this->switcherCompanyQuery($user)
|
||||
->whereKey($selectedCompanyId)
|
||||
->first();
|
||||
|
||||
if (! $selectedCompany) {
|
||||
return $companies;
|
||||
}
|
||||
|
||||
return $companies
|
||||
->prepend($selectedCompany)
|
||||
->unique('id')
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -100,14 +149,14 @@ class CustomerCompanyContext
|
|||
|
||||
public function clear(): void
|
||||
{
|
||||
session()->forget(self::SessionKey);
|
||||
Session::forget(self::SessionKey);
|
||||
}
|
||||
|
||||
public function roleLabelFor(Company $company, User $user): string
|
||||
{
|
||||
$role = $company->owner_user_id === $user->id
|
||||
? 'owner'
|
||||
: ($company->pivot?->role ?? 'member');
|
||||
: ($company->getAttribute('current_user_role') ?? $company->pivot?->role ?? 'member');
|
||||
|
||||
return match ($role) {
|
||||
'owner' => __('Owner'),
|
||||
|
|
@ -116,6 +165,22 @@ class CustomerCompanyContext
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Builder<Company>
|
||||
*/
|
||||
private function switcherCompanyQuery(User $user): Builder
|
||||
{
|
||||
return $this->accessibleCompanyQuery($user)
|
||||
->select(['companies.id', 'companies.name', 'companies.portal', 'companies.owner_user_id', 'companies.created_at'])
|
||||
->addSelect([
|
||||
'current_user_role' => DB::table('company_user')
|
||||
->select('role')
|
||||
->whereColumn('company_user.company_id', 'companies.id')
|
||||
->where('company_user.user_id', $user->id)
|
||||
->limit(1),
|
||||
]);
|
||||
}
|
||||
|
||||
private function userCanAccessCompany(User $user, int $companyId): bool
|
||||
{
|
||||
return $this->accessibleCompanyQuery($user)
|
||||
|
|
|
|||
113
app/Services/PressRelease/PressReleaseAttachmentStorage.php
Normal file
113
app/Services/PressRelease/PressReleaseAttachmentStorage.php
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\PressRelease;
|
||||
|
||||
use Illuminate\Contracts\Filesystem\Filesystem;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Storage for press release attachments (PDFs and other documents).
|
||||
*
|
||||
* Files are stored on the `public` disk under
|
||||
* press-releases/{id}/attachments/{uuid}-{slug}.{ext}
|
||||
*
|
||||
* Path-obscurity is sufficient for MVP: anonymous downloads are intentional
|
||||
* for published press releases, and embargo logic lives at the PR level.
|
||||
*/
|
||||
class PressReleaseAttachmentStorage
|
||||
{
|
||||
public const MAX_BYTES = 25 * 1024 * 1024;
|
||||
|
||||
/** @var list<string> */
|
||||
public const ALLOWED_EXTENSIONS = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'zip', 'txt'];
|
||||
|
||||
/** @var list<string> */
|
||||
public const ALLOWED_MIMES = [
|
||||
'application/pdf',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.ms-powerpoint',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
'application/zip',
|
||||
'application/x-zip-compressed',
|
||||
'text/plain',
|
||||
];
|
||||
|
||||
/**
|
||||
* Store the uploaded attachment for the given press release.
|
||||
*
|
||||
* @return array{disk:string,path:string,original_name:string,mime:string,size:int}
|
||||
*/
|
||||
public function store(UploadedFile $upload, int $pressReleaseId): array
|
||||
{
|
||||
$this->assertValidUpload($upload);
|
||||
|
||||
$extension = strtolower($upload->getClientOriginalExtension() ?: $upload->extension());
|
||||
$originalName = $upload->getClientOriginalName();
|
||||
$baseSlug = Str::slug(pathinfo($originalName, PATHINFO_FILENAME)) ?: 'attachment';
|
||||
$baseSlug = Str::limit($baseSlug, 60, '');
|
||||
|
||||
$directory = sprintf('press-releases/%d/attachments', $pressReleaseId);
|
||||
$filename = Str::uuid()->toString().'-'.$baseSlug.'.'.$extension;
|
||||
$relativePath = $directory.'/'.$filename;
|
||||
|
||||
$disk = $this->disk();
|
||||
$stream = fopen($upload->getRealPath(), 'r');
|
||||
|
||||
if ($stream === false) {
|
||||
throw new RuntimeException('Could not open uploaded file stream.');
|
||||
}
|
||||
|
||||
try {
|
||||
$disk->put($relativePath, $stream, 'public');
|
||||
} finally {
|
||||
if (is_resource($stream)) {
|
||||
fclose($stream);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'disk' => 'public',
|
||||
'path' => $relativePath,
|
||||
'original_name' => $originalName,
|
||||
'mime' => $upload->getMimeType() ?: 'application/octet-stream',
|
||||
'size' => $upload->getSize() ?: 0,
|
||||
];
|
||||
}
|
||||
|
||||
public function delete(string $disk, string $path): void
|
||||
{
|
||||
if (blank($path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Storage::disk($disk)->delete($path);
|
||||
} catch (\Throwable) {
|
||||
// Swallow — deletion is best-effort; a missing file is acceptable.
|
||||
}
|
||||
}
|
||||
|
||||
private function assertValidUpload(UploadedFile $upload): void
|
||||
{
|
||||
$extension = strtolower($upload->getClientOriginalExtension() ?: $upload->extension());
|
||||
|
||||
if (! in_array($extension, self::ALLOWED_EXTENSIONS, true)) {
|
||||
throw new RuntimeException('Unsupported attachment extension: '.$extension);
|
||||
}
|
||||
|
||||
if ($upload->getSize() > self::MAX_BYTES) {
|
||||
throw new RuntimeException('Attachment exceeds maximum size.');
|
||||
}
|
||||
}
|
||||
|
||||
private function disk(): Filesystem
|
||||
{
|
||||
return Storage::disk('public');
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ use App\Models\AdminPreset;
|
|||
use App\Models\PressRelease;
|
||||
use App\Models\PressReleaseStatusLog;
|
||||
use App\Services\Admin\AdminPerformanceCache;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
|
@ -44,7 +45,7 @@ class PressReleaseService
|
|||
$this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Review, null, 'customer');
|
||||
}
|
||||
|
||||
public function publish(PressRelease $pressRelease): void
|
||||
public function publish(PressRelease $pressRelease, string $source = 'admin'): void
|
||||
{
|
||||
$this->assertStatus($pressRelease, [PressReleaseStatus::Review]);
|
||||
|
||||
|
|
@ -62,13 +63,42 @@ class PressReleaseService
|
|||
|
||||
$pressRelease->update([
|
||||
'status' => PressReleaseStatus::Published->value,
|
||||
'published_at' => $pressRelease->published_at ?? now(),
|
||||
'published_at' => $this->resolvePublishedAt($pressRelease),
|
||||
]);
|
||||
|
||||
$this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Published, null, 'admin');
|
||||
$this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Published, null, $source);
|
||||
$this->notifyAuthor($pressRelease, 'published');
|
||||
}
|
||||
|
||||
/**
|
||||
* Bestimmt das wirksame `published_at` einer PM.
|
||||
*
|
||||
* Reihenfolge:
|
||||
* 1. Bereits gesetztes `published_at` bleibt erhalten (z.B. Re-Publish)
|
||||
* 2. `scheduled_at` (geplanter Veröffentlichungstermin) hat Vorrang vor "jetzt"
|
||||
* 3. `embargo_at` (Sperrfrist) verschiebt zusätzlich nach hinten — egal ob
|
||||
* Scheduled vorhanden ist oder nicht
|
||||
* 4. Fallback: now()
|
||||
*
|
||||
* Damit wirken sowohl Scheduling als auch Embargo automatisch über den
|
||||
* vorhandenen Sichtbarkeits-Filter `where(published_at <= now())` im
|
||||
* öffentlichen Listing.
|
||||
*/
|
||||
private function resolvePublishedAt(PressRelease $pressRelease): Carbon
|
||||
{
|
||||
if ($pressRelease->published_at) {
|
||||
return $pressRelease->published_at;
|
||||
}
|
||||
|
||||
$base = $pressRelease->scheduled_at ?: now();
|
||||
|
||||
if ($pressRelease->embargo_at && $pressRelease->embargo_at->greaterThan($base)) {
|
||||
return $pressRelease->embargo_at;
|
||||
}
|
||||
|
||||
return $base;
|
||||
}
|
||||
|
||||
public function reject(PressRelease $pressRelease, ?string $reason = null): void
|
||||
{
|
||||
$this->assertStatus($pressRelease, [PressReleaseStatus::Review]);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,73 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('companies', function (Blueprint $table): void {
|
||||
if (! $this->hasIndex('companies', 'companies_owner_name_id_idx')) {
|
||||
$table->index(['owner_user_id', 'name', 'id'], 'companies_owner_name_id_idx');
|
||||
}
|
||||
|
||||
if (! $this->hasIndex('companies', 'companies_owner_active_name_id_idx')) {
|
||||
$table->index(['owner_user_id', 'is_active', 'name', 'id'], 'companies_owner_active_name_id_idx');
|
||||
}
|
||||
});
|
||||
|
||||
Schema::table('press_releases', function (Blueprint $table): void {
|
||||
if (! $this->hasIndex('press_releases', 'press_releases_company_published_idx')) {
|
||||
$table->index(['company_id', 'published_at'], 'press_releases_company_published_idx');
|
||||
}
|
||||
|
||||
if (! $this->hasIndex('press_releases', 'press_releases_user_created_id_idx')) {
|
||||
$table->index(['user_id', 'created_at', 'id'], 'press_releases_user_created_id_idx');
|
||||
}
|
||||
|
||||
if (! $this->hasIndex('press_releases', 'press_releases_user_status_created_idx')) {
|
||||
$table->index(['user_id', 'status', 'created_at'], 'press_releases_user_status_created_idx');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('press_releases', function (Blueprint $table): void {
|
||||
if ($this->hasIndex('press_releases', 'press_releases_user_status_created_idx')) {
|
||||
$table->dropIndex('press_releases_user_status_created_idx');
|
||||
}
|
||||
|
||||
if ($this->hasIndex('press_releases', 'press_releases_user_created_id_idx')) {
|
||||
$table->dropIndex('press_releases_user_created_id_idx');
|
||||
}
|
||||
|
||||
if ($this->hasIndex('press_releases', 'press_releases_company_published_idx')) {
|
||||
$table->dropIndex('press_releases_company_published_idx');
|
||||
}
|
||||
});
|
||||
|
||||
Schema::table('companies', function (Blueprint $table): void {
|
||||
if ($this->hasIndex('companies', 'companies_owner_active_name_id_idx')) {
|
||||
$table->dropIndex('companies_owner_active_name_id_idx');
|
||||
}
|
||||
|
||||
if ($this->hasIndex('companies', 'companies_owner_name_id_idx')) {
|
||||
$table->dropIndex('companies_owner_name_id_idx');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function hasIndex(string $table, string $indexName): bool
|
||||
{
|
||||
return in_array($indexName, array_column(Schema::getIndexes($table), 'name'), true);
|
||||
}
|
||||
};
|
||||
|
|
@ -5,7 +5,7 @@
|
|||
> Bekommt deshalb eine eigene Phase außerhalb der bisherigen
|
||||
> `hub-flux`-Roadmap (Phase 0–6 sind dort abgeschlossen).
|
||||
|
||||
**Status**: 🟡 in Planung · **Aufwand**: 2–3 Tage · **Risiko**: mittel
|
||||
**Status**: ✅ abgeschlossen · **Aufwand**: 2–3 Tage · **Risiko**: mittel
|
||||
(Datenmodell-Erweiterung, Editor-Format-Migration, Composer-Dependency)
|
||||
|
||||
---
|
||||
|
|
@ -187,25 +187,46 @@ Untertitel, Editor, Medien, Anhänge, Boilerplate.
|
|||
- [ ] Reorder-Test
|
||||
- [ ] Datei-Type-Validation grün
|
||||
|
||||
### 7F — Scheduling + Embargo (Schema vorhanden, UI aktivieren)
|
||||
### 7F — Scheduling + Embargo ✅
|
||||
|
||||
**Scope:**
|
||||
- UI-Elemente aus 7C (Veröffentlichung-RadioGroup,
|
||||
Embargo-Checkbox + Date-Picker) aktivieren
|
||||
- `bald`-Badges entfernen
|
||||
- Service-Logik in `PressReleaseService`:
|
||||
- bei `submitForReview` mit `scheduled_at` → Status bleibt
|
||||
`review`, beim `publish` durch Admin/Job wird `published_at`
|
||||
auf `scheduled_at` gesetzt
|
||||
- bei `embargo_at` → öffentliche Anzeige erst ab `embargo_at`
|
||||
- Background-Job (`PublishScheduledPressReleases`) für die
|
||||
Veröffentlichung um `scheduled_at`
|
||||
- Test: Geplante PM wird nicht vor Termin öffentlich
|
||||
- Test: Embargo-Filter im Portal-Index
|
||||
**Was umgesetzt wurde:**
|
||||
- **UI**: Customer Create + Edit haben eine zweite Radio-Option
|
||||
„Geplanter Termin" mit `<flux:input type="datetime-local">` und
|
||||
einen separaten Embargo-Switch + Date-Picker.
|
||||
`bald`-Badge im Veröffentlichungs-Block entfernt.
|
||||
- **Validation**: `scheduled_at` muss min. 5 Min in der Zukunft
|
||||
liegen (passend zum Scheduler-Intervall), `embargo_at` muss in
|
||||
der Zukunft liegen. Beide Felder werden nur validiert, wenn der
|
||||
jeweilige Toggle aktiv ist.
|
||||
- **Save**: `scheduled_at` + `embargo_at` werden in den Forms bei
|
||||
Create + Update persistiert (Customer Variante; Admin bleibt
|
||||
vorerst ohne UI).
|
||||
- **Service**: `PressReleaseService::publish()` nutzt jetzt
|
||||
`resolvePublishedAt()`:
|
||||
1. bereits gesetztes `published_at` bleibt unangetastet
|
||||
2. sonst `scheduled_at` (falls vorhanden)
|
||||
3. `embargo_at` verschiebt zusätzlich nach hinten
|
||||
4. Fallback `now()`
|
||||
Damit greift der vorhandene Sichtbarkeits-Filter
|
||||
`where('published_at', '<=', now())` in `routes/web.php`
|
||||
automatisch — keine zusätzlichen Embargo-Filter nötig.
|
||||
- **Background-Command** `press-releases:publish-scheduled`
|
||||
(`App\Console\Commands\PublishScheduledPressReleases`) findet
|
||||
alle Review-PRs mit `scheduled_at <= now`, publisht sie via
|
||||
Service (`source: 'scheduler'` im Status-Log) und respektiert
|
||||
weiterhin Blacklist + Mail-Benachrichtigung. Optionen:
|
||||
`--dry-run`, `--limit=N`.
|
||||
- **Scheduler-Eintrag** in `routes/console.php`: alle 5 Min,
|
||||
`withoutOverlapping()`, `runInBackground()`.
|
||||
|
||||
**Hinweis:** 7F ist optional und kann nach 7C/D/E in eine eigene
|
||||
kleine Sub-Phase ausgelagert werden, weil es einen Background-Job
|
||||
einführt und der Test-Umfang separat sauber bleibt.
|
||||
**Tests:**
|
||||
- `tests/Feature/PressReleaseSchedulingTest.php` — 11 Tests für
|
||||
Service + Command (resolvePublishedAt-Matrix, Source-Log,
|
||||
Command-Dry-Run, Command-Limit).
|
||||
- `tests/Feature/CustomerPressReleaseSchedulingFormTest.php` —
|
||||
5 Tests für die Form (Persistierung, Past-Date-Validation für
|
||||
beide Felder, Now-Mode setzt scheduled_at zurück,
|
||||
Edit-Hydration).
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
866
dev/frontend/tailwind_v3/User Firmen presseportale.html
Normal file
866
dev/frontend/tailwind_v3/User Firmen presseportale.html
Normal file
|
|
@ -0,0 +1,866 @@
|
|||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>presseportale.com — Meine Firmen</title>
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter+Tight:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
|
||||
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
bg:"#F6F4EF","bg-elev":"#FBFAF6","bg-rule":"#E2DDD0","bg-rule-2":"#EDE7D7",
|
||||
"bg-card":"#FFFFFF","bg-card-warm":"#EFEADC",
|
||||
hub:"#1A2540","hub-2":"#243152","hub-3":"#2E3D66",
|
||||
"hub-soft":"#E5E9F1","hub-soft-2":"#CFD6E4","hub-line":"#7B8FBF",
|
||||
accent:"#B07A3A","accent-deep":"#8A5E27","accent-soft":"#F1E6D3",
|
||||
ink:"#1A1F1C","ink-2":"#3A413D","ink-3":"#5A6360","ink-4":"#8A918D",
|
||||
"ink-on-dark":"#F6F4EF","ink-on-dark-2":"#B2B9C7","ink-on-dark-3":"#7B8FBF",
|
||||
ok:"#2E8540","ok-soft":"#E2F1E5",
|
||||
warn:"#A87A1F","warn-soft":"#F6EAC8",
|
||||
err:"#A8331F","err-soft":"#F4DAD2",
|
||||
},
|
||||
fontFamily:{
|
||||
sans:['"Inter Tight"','Inter','system-ui','sans-serif'],
|
||||
mono:['"JetBrains Mono"','"SF Mono"','ui-monospace','monospace'],
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
html,body{margin:0;padding:0;}
|
||||
body{background:#E8E4DA;font-family:"Inter Tight",system-ui,sans-serif;}
|
||||
|
||||
.eyebrow{font-size:10.5px;font-weight:700;letter-spacing:.20em;text-transform:uppercase;color:#1A2540;}
|
||||
.eyebrow.muted{color:#5A6360;letter-spacing:.16em;font-weight:600;font-size:10px;}
|
||||
.eyebrow.accent{color:#8A5E27;}
|
||||
.eyebrow.on-dark{color:#7B8FBF;}
|
||||
|
||||
.section-eyebrow{display:inline-flex;align-items:center;gap:10px;font-size:10.5px;font-weight:700;letter-spacing:.22em;text-transform:uppercase;color:#1A2540;}
|
||||
.section-eyebrow::after{content:"";display:block;width:30px;height:1px;background:#1A2540;opacity:.45;}
|
||||
|
||||
.rule{height:1px;background:#E2DDD0;border:0;margin:0;}
|
||||
|
||||
/* Sidebar */
|
||||
.nav-item{display:flex;align-items:center;gap:11px;padding:8px 12px;border-radius:4px;font-size:13px;font-weight:500;color:#3A413D;transition:background .12s,color .12s;position:relative;}
|
||||
.nav-item:hover{background:#F6F4EF;color:#1A2540;}
|
||||
.nav-item.active{background:#E5E9F1;color:#1A2540;font-weight:600;}
|
||||
.nav-item.active::before{content:"";position:absolute;left:-1px;top:6px;bottom:6px;width:2px;background:#1A2540;border-radius:0 2px 2px 0;}
|
||||
.nav-item.disabled{color:#8A918D;cursor:default;}
|
||||
.nav-item.disabled:hover{background:transparent;color:#8A918D;}
|
||||
.nav-item .ico{width:16px;height:16px;flex-shrink:0;color:currentColor;opacity:.8;}
|
||||
.nav-item.active .ico{opacity:1;}
|
||||
.nav-section{font-size:10px;font-weight:700;letter-spacing:.18em;text-transform:uppercase;color:#8A918D;padding:0 12px 6px;}
|
||||
|
||||
/* Panels */
|
||||
.panel{background:#FFFFFF;border:1px solid #E2DDD0;border-radius:6px;}
|
||||
.panel-warm{background:#FBFAF6;border:1px solid #E2DDD0;border-radius:6px;}
|
||||
|
||||
/* Buttons */
|
||||
.btn-primary{display:inline-flex;align-items:center;gap:8px;justify-content:center;padding:9px 16px;background:#1A2540;color:#fff;border-radius:4px;font-size:13px;font-weight:600;transition:background .15s;}
|
||||
.btn-primary:hover{background:#243152;}
|
||||
.btn-secondary{display:inline-flex;align-items:center;gap:8px;justify-content:center;padding:8px 14px;background:#fff;color:#1A2540;border:1px solid #CFD6E4;border-radius:4px;font-size:12.5px;font-weight:600;transition:border-color .15s,background .15s;}
|
||||
.btn-secondary:hover{border-color:#1A2540;background:#F6F4EF;}
|
||||
.btn-ghost{display:inline-flex;align-items:center;gap:6px;padding:6px 10px;color:#3A413D;border-radius:4px;font-size:12px;font-weight:500;transition:background .12s,color .12s;}
|
||||
.btn-ghost:hover{background:#F6F4EF;color:#1A2540;}
|
||||
|
||||
/* Badges */
|
||||
.badge{display:inline-flex;align-items:center;gap:6px;padding:3px 9px;border-radius:99px;font-size:10.5px;font-weight:700;letter-spacing:.10em;text-transform:uppercase;}
|
||||
.badge.warn{background:#F6EAC8;color:#8A5E27;}
|
||||
.badge.ok{background:#E2F1E5;color:#1F5E2E;}
|
||||
.badge.err{background:#F4DAD2;color:#8E2A19;}
|
||||
.badge.hub{background:#E5E9F1;color:#1A2540;}
|
||||
.badge.muted{background:#EFEADC;color:#5A6360;}
|
||||
.badge.dot::before{content:"";width:6px;height:6px;border-radius:99px;background:currentColor;}
|
||||
|
||||
/* Filter chips */
|
||||
.filter-chip{display:inline-flex;align-items:center;gap:6px;padding:6px 12px 6px 14px;background:#fff;border:1px solid #CFD6E4;border-radius:4px;font-size:12.5px;color:#1A2540;font-weight:500;transition:border-color .15s,background .15s;white-space:nowrap;}
|
||||
.filter-chip:hover{border-color:#1A2540;background:#F6F4EF;}
|
||||
.filter-chip.is-active{background:#1A2540;color:#fff;border-color:#1A2540;}
|
||||
.filter-chip.is-active:hover{background:#243152;}
|
||||
.filter-chip .caret{opacity:.55;}
|
||||
|
||||
.active-chip{display:inline-flex;align-items:center;gap:7px;padding:4px 6px 4px 11px;background:#FBFAF6;border:1px solid #E2DDD0;border-radius:99px;font-size:11.5px;color:#3A413D;font-weight:500;}
|
||||
.active-chip strong{color:#1A2540;font-weight:600;}
|
||||
.active-chip .x{width:16px;height:16px;border-radius:99px;display:inline-flex;align-items:center;justify-content:center;color:#5A6360;background:#EDE7D7;transition:background .12s,color .12s;}
|
||||
.active-chip .x:hover{background:#1A2540;color:#fff;}
|
||||
|
||||
/* Search */
|
||||
.search-wrap{position:relative;flex:1;min-width:220px;}
|
||||
.search-wrap input{
|
||||
width:100%;padding:8px 12px 8px 34px;background:#fff;border:1px solid #CFD6E4;border-radius:4px;
|
||||
font-size:12.5px;color:#1A1F1C;transition:border-color .15s;
|
||||
}
|
||||
.search-wrap input:focus{outline:none;border-color:#1A2540;}
|
||||
.search-wrap input::placeholder{color:#8A918D;}
|
||||
.search-wrap .ico{position:absolute;left:11px;top:50%;transform:translateY(-50%);color:#5A6360;}
|
||||
.search-wrap kbd{position:absolute;right:8px;top:50%;transform:translateY(-50%);font-family:"JetBrains Mono",ui-monospace,monospace;font-size:10px;color:#8A918D;border:1px solid #E2DDD0;border-radius:3px;padding:1px 4px;background:#FBFAF6;}
|
||||
|
||||
/* Saved view tabs */
|
||||
.view-tab{display:inline-flex;align-items:center;gap:8px;padding:7px 12px 9px;font-size:12.5px;font-weight:500;color:#5A6360;border-bottom:2px solid transparent;margin-bottom:-1px;transition:color .12s,border-color .12s;}
|
||||
.view-tab:hover{color:#1A2540;}
|
||||
.view-tab.is-active{color:#1A2540;font-weight:600;border-bottom-color:#1A2540;}
|
||||
.view-tab .cnt{font-family:"JetBrains Mono",ui-monospace,monospace;font-size:10.5px;color:#8A918D;background:#EDE7D7;padding:1px 6px;border-radius:99px;font-weight:600;letter-spacing:.04em;}
|
||||
.view-tab.is-active .cnt{background:#1A2540;color:#fff;}
|
||||
|
||||
/* Counter strip */
|
||||
.counter-strip{display:inline-flex;align-items:center;gap:14px;padding:6px 0;}
|
||||
.counter-strip .seg{display:inline-flex;align-items:baseline;gap:6px;font-size:12.5px;color:#3A413D;}
|
||||
.counter-strip .seg b{font-family:"JetBrains Mono",ui-monospace,monospace;font-variant-numeric:tabular-nums;font-size:13.5px;font-weight:600;color:#1A1F1C;letter-spacing:-.2px;}
|
||||
.counter-strip .sep{width:1px;height:11px;background:#CFD6E4;}
|
||||
|
||||
/* View-mode toggle (Karten/Liste) */
|
||||
.seg-toggle{display:inline-flex;background:#fff;border:1px solid #CFD6E4;border-radius:4px;padding:2px;gap:0;}
|
||||
.seg-toggle button{display:inline-flex;align-items:center;gap:6px;padding:5px 11px;font-size:12px;font-weight:600;color:#5A6360;border-radius:3px;transition:background .12s,color .12s;}
|
||||
.seg-toggle button:hover{color:#1A2540;}
|
||||
.seg-toggle button.is-active{background:#1A2540;color:#fff;}
|
||||
.seg-toggle button.is-active svg{opacity:1;}
|
||||
.seg-toggle button svg{opacity:.7;}
|
||||
|
||||
/* Bridge ribbon */
|
||||
.bridge-row{display:inline-flex;align-items:center;gap:6px;font-family:"JetBrains Mono",ui-monospace,monospace;font-size:10.5px;letter-spacing:.10em;text-transform:uppercase;color:#5A6360;}
|
||||
.dot-pe{width:6px;height:6px;border-radius:99px;background:#1F4D3A;}
|
||||
.dot-bp{width:6px;height:6px;border-radius:99px;background:#C84A1E;}
|
||||
|
||||
/* Portal pills */
|
||||
.portal-pill{display:inline-flex;align-items:center;gap:5px;padding:3px 8px;border-radius:99px;font-size:10.5px;font-weight:600;letter-spacing:.06em;background:#FBFAF6;border:1px solid #E2DDD0;color:#3A413D;white-space:nowrap;}
|
||||
.portal-pill .pdot{width:6px;height:6px;border-radius:99px;}
|
||||
.portal-pill.pe .pdot{background:#1F4D3A;}
|
||||
.portal-pill.bp .pdot{background:#C84A1E;}
|
||||
|
||||
/* ============ FIRMA-KARTEN ============ */
|
||||
.firm-card{background:#fff;border:1px solid #E2DDD0;border-radius:6px;padding:18px;transition:border-color .15s,box-shadow .15s;display:flex;flex-direction:column;gap:14px;min-height:266px;}
|
||||
.firm-card:hover{border-color:#7B8FBF;box-shadow:0 1px 0 rgba(26,37,64,.04),0 8px 24px -16px rgba(26,37,64,.18);}
|
||||
.firm-card.is-self{border-color:#1A2540;box-shadow:inset 0 0 0 1px #1A2540;}
|
||||
.firm-card .logo{width:56px;height:56px;border-radius:6px;display:flex;align-items:center;justify-content:center;font-family:"JetBrains Mono",ui-monospace,monospace;font-weight:700;font-size:18px;letter-spacing:-.5px;flex-shrink:0;}
|
||||
.firm-card .name{font-size:16px;font-weight:700;letter-spacing:-.3px;color:#1A1F1C;line-height:1.2;margin:0;}
|
||||
.firm-card .meta-line{font-size:11.5px;color:#5A6360;margin-top:3px;line-height:1.4;}
|
||||
.firm-card .kpis{display:grid;grid-template-columns:repeat(3,1fr);gap:0;border-top:1px solid #EDE7D7;padding-top:11px;margin-top:auto;}
|
||||
.firm-card .kpi{display:flex;flex-direction:column;gap:2px;padding:0 4px;border-right:1px solid #EDE7D7;}
|
||||
.firm-card .kpi:last-child{border-right:0;}
|
||||
.firm-card .kpi .k{font-family:"JetBrains Mono",ui-monospace,monospace;font-size:15.5px;font-weight:600;color:#1A1F1C;font-variant-numeric:tabular-nums;line-height:1.1;letter-spacing:-.3px;}
|
||||
.firm-card .kpi .l{font-size:10px;font-weight:700;letter-spacing:.14em;text-transform:uppercase;color:#8A918D;}
|
||||
.firm-card .role-pill{display:inline-flex;align-items:center;gap:5px;padding:2px 8px 2px 7px;background:#FBFAF6;border:1px dashed #CFD6E4;border-radius:99px;font-size:10.5px;color:#5A6360;font-weight:600;letter-spacing:.04em;}
|
||||
.firm-card .role-pill::before{content:"";width:5px;height:5px;border-radius:99px;background:#B07A3A;}
|
||||
.firm-card .role-pill.admin::before{background:#1A2540;}
|
||||
.firm-card .role-pill.admin{color:#1A2540;}
|
||||
|
||||
/* Logo color tokens */
|
||||
.lg-brew{background:linear-gradient(135deg,#3A4D2F 0%,#1F2E1A 100%);color:#F1E6D3;}
|
||||
.lg-mv{background:linear-gradient(135deg,#1A2540 0%,#243152 100%);color:#fff;}
|
||||
.lg-soft{background:#F1E6D3;color:#8A5E27;border:1px solid #E1C883;}
|
||||
.lg-warm{background:linear-gradient(135deg,#B07A3A 0%,#8A5E27 100%);color:#fff;}
|
||||
.lg-blank{background:repeating-linear-gradient(135deg,#FBFAF6 0 6px,#F1ECDD 6px 12px);color:#8A918D;border:1px dashed #CFD6E4;}
|
||||
|
||||
/* Table */
|
||||
table.list{width:100%;border-collapse:separate;border-spacing:0;font-size:13px;}
|
||||
table.list thead th{
|
||||
text-align:left;font-weight:700;font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:#5A6360;
|
||||
padding:11px 14px;background:#FBFAF6;border-bottom:1px solid #E2DDD0;white-space:nowrap;
|
||||
}
|
||||
table.list thead th:first-child{padding-left:18px;}
|
||||
table.list thead th:last-child{padding-right:18px;}
|
||||
table.list tbody td{padding:14px;border-bottom:1px solid #EDE7D7;vertical-align:middle;}
|
||||
table.list tbody td:first-child{padding-left:18px;}
|
||||
table.list tbody td:last-child{padding-right:18px;text-align:right;}
|
||||
table.list tbody tr:last-child td{border-bottom:0;}
|
||||
table.list tbody tr{transition:background .1s;}
|
||||
table.list tbody tr:hover{background:#FBFAF6;}
|
||||
.row-title{font-weight:600;color:#1A1F1C;font-size:13.5px;line-height:1.35;letter-spacing:-.1px;display:inline-flex;align-items:center;gap:9px;}
|
||||
.row-title:hover{color:#1A2540;}
|
||||
.row-sub{font-size:11.5px;color:#5A6360;margin-top:3px;line-height:1.4;}
|
||||
.row-num{font-family:"JetBrains Mono",ui-monospace,monospace;font-variant-numeric:tabular-nums;font-size:13px;color:#1A1F1C;font-weight:600;}
|
||||
.row-num .sub{font-family:"Inter Tight",sans-serif;font-weight:400;font-size:11px;color:#8A918D;margin-left:4px;letter-spacing:.02em;}
|
||||
|
||||
/* mini-logo for table rows */
|
||||
.mini-logo{width:30px;height:30px;border-radius:4px;display:inline-flex;align-items:center;justify-content:center;font-family:"JetBrains Mono",ui-monospace,monospace;font-size:11px;font-weight:700;letter-spacing:-.3px;flex-shrink:0;}
|
||||
|
||||
/* Menu trigger */
|
||||
.menu-trigger{display:inline-flex;align-items:center;justify-content:center;width:30px;height:30px;border-radius:4px;border:1px solid transparent;color:#5A6360;transition:background .12s,border-color .12s,color .12s;}
|
||||
.menu-trigger:hover{background:#F6F4EF;border-color:#CFD6E4;color:#1A2540;}
|
||||
|
||||
/* Card-style action button on the card itself */
|
||||
.card-action{display:inline-flex;align-items:center;gap:6px;padding:6px 12px;border:1px solid #CFD6E4;background:#fff;color:#1A2540;border-radius:4px;font-size:12px;font-weight:600;transition:border-color .12s,background .12s;}
|
||||
.card-action:hover{border-color:#1A2540;background:#F6F4EF;}
|
||||
.card-action.primary{background:#1A2540;color:#fff;border-color:#1A2540;}
|
||||
.card-action.primary:hover{background:#243152;}
|
||||
|
||||
/* Empty state */
|
||||
.empty-stage{padding:80px 24px;text-align:center;display:flex;flex-direction:column;align-items:center;}
|
||||
.empty-ico{width:64px;height:64px;border-radius:6px;background:#E5E9F1;border:1px solid #CFD6E4;color:#1A2540;display:flex;align-items:center;justify-content:center;margin-bottom:20px;}
|
||||
.empty-ico.warm{background:#F1E6D3;border-color:#E1C883;color:#8A5E27;}
|
||||
.empty-title{font-size:17px;font-weight:600;color:#1A1F1C;margin:0;letter-spacing:-.2px;}
|
||||
.empty-sub{font-size:13px;color:#5A6360;line-height:1.55;margin:8px 0 0;max-width:440px;}
|
||||
|
||||
/* Pagination */
|
||||
.page-btn{display:inline-flex;align-items:center;justify-content:center;min-width:30px;height:30px;padding:0 9px;border-radius:4px;border:1px solid #CFD6E4;background:#fff;font-size:12px;font-weight:600;color:#3A413D;font-family:"JetBrains Mono",ui-monospace,monospace;font-variant-numeric:tabular-nums;transition:border-color .12s,background .12s,color .12s;}
|
||||
.page-btn:hover{border-color:#1A2540;color:#1A2540;}
|
||||
.page-btn.is-current{background:#1A2540;border-color:#1A2540;color:#fff;}
|
||||
.page-btn.is-disabled{color:#B5BCB9;border-color:#EDE7D7;background:#FBFAF6;cursor:default;}
|
||||
.page-btn.is-disabled:hover{color:#B5BCB9;border-color:#EDE7D7;}
|
||||
|
||||
/* "Neue Firma anlegen" Karte (Add tile) */
|
||||
.add-tile{border:1.5px dashed #CFD6E4;background:#FBFAF6;border-radius:6px;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:24px;min-height:266px;transition:border-color .15s,background .15s,color .15s;cursor:pointer;color:#3A413D;}
|
||||
.add-tile:hover{border-color:#1A2540;border-style:solid;background:#fff;color:#1A2540;}
|
||||
.add-tile .ico{width:48px;height:48px;border-radius:6px;background:#E5E9F1;color:#1A2540;display:flex;align-items:center;justify-content:center;margin-bottom:14px;}
|
||||
.add-tile:hover .ico{background:#1A2540;color:#fff;}
|
||||
.add-tile .lbl{font-size:14px;font-weight:600;}
|
||||
.add-tile .sub{font-size:11.5px;color:#5A6360;margin-top:6px;line-height:1.5;max-width:200px;}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body class="bg-bg text-ink font-sans antialiased">
|
||||
|
||||
<!-- ============== ARTBOARD ============== -->
|
||||
<div class="mx-auto bg-bg" style="width:1440px;">
|
||||
|
||||
<div class="flex" style="min-height:1100px;">
|
||||
|
||||
<!-- ============== SIDEBAR ============== -->
|
||||
<aside class="bg-bg-elev border-r border-bg-rule flex flex-col" style="width:260px;">
|
||||
<div class="px-5 pt-6 pb-5">
|
||||
<a href="Hub Landing presseportale.html" class="flex items-baseline gap-2">
|
||||
<span class="text-[19px] font-bold tracking-[-0.4px] text-hub leading-none">presseportale<span class="text-accent">.com</span></span>
|
||||
</a>
|
||||
<div class="eyebrow muted mt-2">Publisher · Hub</div>
|
||||
|
||||
<button class="mt-4 w-full grid items-center gap-2.5 px-3 py-2.5 bg-white border border-bg-rule rounded-[4px] hover:border-hub/40 text-left" style="grid-template-columns:auto 1fr auto;">
|
||||
<span class="w-7 h-7 rounded-[3px] bg-hub-soft border border-hub-soft-2 flex items-center justify-center text-hub text-[11px] font-bold">TU</span>
|
||||
<span class="min-w-0">
|
||||
<span class="block text-[12.5px] font-semibold text-ink leading-tight truncate">Test User</span>
|
||||
<span class="block text-[10.5px] text-ink-3 leading-tight mt-0.5 truncate">Tegernseer Brauerei AG +1</span>
|
||||
</span>
|
||||
<svg width="11" height="11" viewBox="0 0 12 12" fill="none" class="text-ink-3">
|
||||
<path d="M3 4.5l3 3 3-3" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M3 7.5l3-3 3 3" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" opacity="0.4"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav class="px-3 flex-1">
|
||||
<div class="nav-section">Mein Bereich</div>
|
||||
<div class="space-y-0.5 mb-5">
|
||||
<a class="nav-item" href="User Dashboard presseportale.html">
|
||||
<svg class="ico" viewBox="0 0 16 16" fill="none"><path d="M2 7l6-5 6 5v7H2z" stroke="currentColor" stroke-width="1.4"/><path d="M6 14V9h4v5" stroke="currentColor" stroke-width="1.4"/></svg>
|
||||
Übersicht
|
||||
</a>
|
||||
<a class="nav-item" href="User Pressemitteilungen presseportale.html">
|
||||
<svg class="ico" viewBox="0 0 16 16" fill="none"><rect x="2.5" y="2.5" width="9" height="11" stroke="currentColor" stroke-width="1.4"/><path d="M11.5 5h2v8.5H4" stroke="currentColor" stroke-width="1.4"/><path d="M5 5.5h4M5 8h4M5 10.5h2.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>
|
||||
Meine Pressemitteilungen
|
||||
<span class="badge hub ml-auto" style="font-size:9.5px;padding:1px 6px;letter-spacing:0.08em;">24</span>
|
||||
</a>
|
||||
<a class="nav-item active" href="#">
|
||||
<svg class="ico" viewBox="0 0 16 16" fill="none"><rect x="2.5" y="3.5" width="11" height="10" stroke="currentColor" stroke-width="1.4"/><path d="M2.5 6h11" stroke="currentColor" stroke-width="1.4"/><path d="M6 9h1M9 9h1M6 11.5h1M9 11.5h1" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>
|
||||
Firmen
|
||||
<span class="badge hub ml-auto" style="font-size:9.5px;padding:1px 6px;letter-spacing:0.08em;">2</span>
|
||||
</a>
|
||||
<a class="nav-item" href="#">
|
||||
<svg class="ico" viewBox="0 0 16 16" fill="none"><path d="M3 5h10l-1 9H4z" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/><path d="M6 5V3.5a2 2 0 014 0V5" stroke="currentColor" stroke-width="1.4"/></svg>
|
||||
Buchungen & Add-ons
|
||||
</a>
|
||||
<span class="nav-item disabled">
|
||||
<svg class="ico" viewBox="0 0 16 16" fill="none"><path d="M3 13V8M7 13V5M11 13V9" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>
|
||||
Statistiken
|
||||
<span class="ml-auto text-[9.5px] tracking-[0.14em] uppercase font-semibold text-ink-4">bald</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="nav-section">Finanzen</div>
|
||||
<div class="space-y-0.5 mb-5">
|
||||
<span class="nav-item disabled">
|
||||
<svg class="ico" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="8" r="5" stroke="currentColor" stroke-width="1.4"/><path d="M8 5.5v5M6 8h4" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>
|
||||
Credits & Tarif
|
||||
<span class="ml-auto text-[9.5px] tracking-[0.14em] uppercase font-semibold text-ink-4">bald</span>
|
||||
</span>
|
||||
<a class="nav-item" href="#">
|
||||
<svg class="ico" viewBox="0 0 16 16" fill="none"><path d="M3 2.5h7l3 3v8H3z" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/><path d="M10 2.5V5.5h3" stroke="currentColor" stroke-width="1.4"/><path d="M5.5 8h5M5.5 10.5h5M5.5 6h2" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>
|
||||
Rechnungen
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="nav-section">Konto</div>
|
||||
<div class="space-y-0.5 mb-5">
|
||||
<a class="nav-item" href="#">
|
||||
<svg class="ico" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="6" r="2.5" stroke="currentColor" stroke-width="1.4"/><path d="M3 13.5c.7-2.4 2.7-4 5-4s4.3 1.6 5 4" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>
|
||||
Profil
|
||||
</a>
|
||||
<a class="nav-item" href="#">
|
||||
<svg class="ico" viewBox="0 0 16 16" fill="none"><path d="M8 2l5 2v4c0 3-2 5-5 6-3-1-5-3-5-6V4z" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/><path d="M6 8l1.5 1.5L10.5 6" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
Sicherheit
|
||||
</a>
|
||||
<a class="nav-item" href="#">
|
||||
<svg class="ico" viewBox="0 0 16 16" fill="none"><circle cx="6" cy="8" r="2.5" stroke="currentColor" stroke-width="1.4"/><path d="M8.5 8h5M11 8v2.5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>
|
||||
API & Integrationen
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="px-4 pb-4">
|
||||
<div class="bg-hub text-ink-on-dark rounded-[5px] p-4 relative overflow-hidden">
|
||||
<div class="absolute -top-6 -right-6 w-16 h-16 rounded-full bg-hub-3 opacity-50"></div>
|
||||
<div class="absolute -bottom-8 -left-8 w-20 h-20 rounded-full bg-hub-3 opacity-30"></div>
|
||||
<div class="relative">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-accent animate-pulse"></span>
|
||||
<span class="eyebrow on-dark" style="color:#F4D89C;">Testmodus aktiv</span>
|
||||
</div>
|
||||
<div class="text-[12px] leading-[1.5] text-ink-on-dark-2">
|
||||
Angemeldet als <strong class="text-white font-semibold">Test User</strong>.<br/>
|
||||
Admin: <strong class="text-white font-semibold">Portal Admin</strong>
|
||||
</div>
|
||||
<button class="mt-3 w-full px-3 py-2 bg-white text-hub text-[12px] font-semibold rounded-[3px] hover:bg-bg transition-colors flex items-center justify-center gap-1.5">
|
||||
<svg width="11" height="11" viewBox="0 0 12 12" fill="none">
|
||||
<path d="M9 3L3 9M3 9H8M3 9V4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
Zurück zum Admin
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- ============== MAIN ============== -->
|
||||
<main class="flex-1 min-w-0" data-screen-label="01 Meine Firmen">
|
||||
|
||||
<!-- Topbar -->
|
||||
<div class="bg-bg-elev border-b border-bg-rule">
|
||||
<div class="px-10 py-3 flex items-center gap-6">
|
||||
<div class="flex items-center gap-2 text-[12px] text-ink-3 font-medium">
|
||||
<a href="Hub Landing presseportale.html" class="hover:text-hub">Hub</a>
|
||||
<span class="text-ink-4">/</span>
|
||||
<a href="User Dashboard presseportale.html" class="hover:text-hub">User Backend</a>
|
||||
<span class="text-ink-4">/</span>
|
||||
<span class="text-hub font-semibold">Firmen</span>
|
||||
</div>
|
||||
|
||||
<span class="flex-1"></span>
|
||||
|
||||
<span class="bridge-row">
|
||||
<span class="dot-pe"></span> presseecho
|
||||
<span class="text-ink-4 mx-1">·</span>
|
||||
<span class="dot-bp"></span> businessportal24
|
||||
</span>
|
||||
|
||||
<span class="w-px h-5 bg-bg-rule"></span>
|
||||
|
||||
<div class="relative">
|
||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" class="absolute left-2.5 top-1/2 -translate-y-1/2 text-ink-3">
|
||||
<circle cx="7" cy="7" r="4.5" stroke="currentColor" stroke-width="1.3"/>
|
||||
<path d="M10.5 10.5L13 13" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<input class="pl-8 pr-3 py-1.5 w-[220px] bg-white border border-bg-rule rounded-[4px] text-[12.5px] placeholder:text-ink-4 focus:outline-none focus:border-hub" placeholder="Suchen…" />
|
||||
<span class="absolute right-2 top-1/2 -translate-y-1/2 text-[10px] font-mono text-ink-4 border border-bg-rule rounded px-1">⌘K</span>
|
||||
</div>
|
||||
|
||||
<button class="relative w-8 h-8 flex items-center justify-center rounded-[4px] hover:bg-bg border border-transparent hover:border-bg-rule">
|
||||
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" class="text-ink-2">
|
||||
<path d="M3.5 7a4.5 4.5 0 119 0v3.5l1 1.5H2.5l1-1.5z" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/>
|
||||
<path d="M6.5 13a1.5 1.5 0 003 0" stroke="currentColor" stroke-width="1.3"/>
|
||||
</svg>
|
||||
<span class="absolute top-1 right-1 w-1.5 h-1.5 rounded-full bg-accent"></span>
|
||||
</button>
|
||||
|
||||
<a href="User Firma Bearbeiten presseportale.html?mode=new" class="btn-primary text-[12px] py-1.5 px-3.5">
|
||||
<svg width="11" height="11" viewBox="0 0 12 12" fill="none">
|
||||
<path d="M6 2v8M2 6h8" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
|
||||
</svg>
|
||||
Firma anlegen
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Inhalt -->
|
||||
<div class="px-10 py-8 space-y-6">
|
||||
|
||||
<!-- ============== PAGE HEADER ============== -->
|
||||
<header class="grid items-end gap-8" style="grid-template-columns:1fr auto;">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-3 mb-3 flex-nowrap whitespace-nowrap">
|
||||
<span class="badge hub dot">User Backend</span>
|
||||
<span class="eyebrow muted">Mein Bereich · A · 03</span>
|
||||
</div>
|
||||
<h1 class="text-[34px] font-bold tracking-[-0.7px] text-ink leading-[1.1] m-0">Meine Firmen</h1>
|
||||
|
||||
<div class="counter-strip mt-3">
|
||||
<span class="seg"><b>2</b> Firmen</span>
|
||||
<span class="sep"></span>
|
||||
<span class="seg"><b style="color:#1F5E2E;">2</b> aktiv</span>
|
||||
<span class="sep"></span>
|
||||
<span class="seg"><b>24</b> Pressemitteilungen gesamt</span>
|
||||
<span class="sep"></span>
|
||||
<span class="seg"><b>5</b> Pressekontakte hinterlegt</span>
|
||||
</div>
|
||||
|
||||
<p class="mt-3 text-[12.5px] text-ink-3 leading-[1.55] max-w-[640px] m-0">
|
||||
Eine Firma ist der Container für Pressemitteilungen: Stammdaten, Boilerplate, Pressekontakte.
|
||||
Anlage ohne separate Freigabe — die redaktionelle Prüfung erfolgt erst bei der Pressemitteilung.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<button class="btn-secondary whitespace-nowrap">
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none"><path d="M3 4h10M3 8h7M3 12h4" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/><path d="M11 12l2 2 2-3" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
Export
|
||||
<span class="badge muted" style="font-size:9px;padding:0px 5px;letter-spacing:0.06em;">bald</span>
|
||||
</button>
|
||||
<a href="User Firma Bearbeiten presseportale.html?mode=new" class="btn-primary whitespace-nowrap">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M6 2v8M2 6h8" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>
|
||||
Neue Firma anlegen
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ============== SAVED VIEWS (TABS) ============== -->
|
||||
<nav class="border-b border-bg-rule flex items-center gap-1" aria-label="Gespeicherte Ansichten">
|
||||
<button class="view-tab is-active" data-view="all">Alle <span class="cnt">2</span></button>
|
||||
<button class="view-tab" data-view="active">Aktiv <span class="cnt">2</span></button>
|
||||
<button class="view-tab" data-view="drafts">In Anlage <span class="cnt">0</span></button>
|
||||
<button class="view-tab" data-view="inactive">Inaktiv <span class="cnt">0</span></button>
|
||||
<button class="view-tab" data-view="shared">Mit mir geteilt <span class="cnt">1</span></button>
|
||||
<span class="flex-1"></span>
|
||||
<button class="btn-ghost" title="Eigene Ansicht speichern">
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none"><path d="M3 2.5h8l2 2v9H3z" stroke="currentColor" stroke-width="1.3"/><path d="M5 2.5v3h5v-3M5 13.5v-4h6v4" stroke="currentColor" stroke-width="1.3"/></svg>
|
||||
Ansicht speichern
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- ============== FILTER + SUCHE ============== -->
|
||||
<section class="space-y-3">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<button class="filter-chip">
|
||||
<svg width="11" height="11" viewBox="0 0 12 12" fill="none" class="opacity-70"><circle cx="6" cy="6" r="2" fill="currentColor"/><circle cx="6" cy="6" r="4.5" stroke="currentColor" stroke-width="1.2"/></svg>
|
||||
Status: <strong class="font-semibold">Alle</strong>
|
||||
<svg class="caret" width="9" height="9" viewBox="0 0 12 12" fill="none"><path d="M3 4.5l3 3 3-3" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
<button class="filter-chip">
|
||||
<span class="dot-pe inline-block" style="margin-right:1px;"></span><span class="dot-bp inline-block" style="margin-left:-2px;"></span>
|
||||
Portal: <strong class="font-semibold">Alle</strong>
|
||||
<svg class="caret" width="9" height="9" viewBox="0 0 12 12" fill="none"><path d="M3 4.5l3 3 3-3" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
<button class="filter-chip">
|
||||
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" class="opacity-70"><circle cx="8" cy="6" r="2.5" stroke="currentColor" stroke-width="1.4"/><path d="M3 13.5c.7-2.4 2.7-4 5-4s4.3 1.6 5 4" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>
|
||||
Rolle: <strong class="font-semibold">Alle</strong>
|
||||
<svg class="caret" width="9" height="9" viewBox="0 0 12 12" fill="none"><path d="M3 4.5l3 3 3-3" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
<button class="filter-chip">
|
||||
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" class="opacity-70"><path d="M3 13V8M7 13V5M11 13V9" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>
|
||||
Branche: <strong class="font-semibold">Alle</strong>
|
||||
<svg class="caret" width="9" height="9" viewBox="0 0 12 12" fill="none"><path d="M3 4.5l3 3 3-3" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
|
||||
<span class="w-px h-6 bg-bg-rule mx-1"></span>
|
||||
|
||||
<div class="search-wrap" style="max-width:340px;">
|
||||
<svg class="ico" width="13" height="13" viewBox="0 0 16 16" fill="none">
|
||||
<circle cx="7" cy="7" r="4.5" stroke="currentColor" stroke-width="1.3"/>
|
||||
<path d="M10.5 10.5L13 13" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<input type="text" placeholder="Firmenname, Stadt oder Branche…" />
|
||||
<kbd>/</kbd>
|
||||
</div>
|
||||
|
||||
<span class="flex-1"></span>
|
||||
|
||||
<!-- View-Toggle Karten/Liste -->
|
||||
<div class="seg-toggle" role="tablist" aria-label="Ansicht umschalten">
|
||||
<button class="is-active" data-viewmode="cards" aria-label="Kartenansicht">
|
||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="none"><rect x="2.5" y="2.5" width="4.5" height="4.5" rx="1" stroke="currentColor" stroke-width="1.4"/><rect x="9" y="2.5" width="4.5" height="4.5" rx="1" stroke="currentColor" stroke-width="1.4"/><rect x="2.5" y="9" width="4.5" height="4.5" rx="1" stroke="currentColor" stroke-width="1.4"/><rect x="9" y="9" width="4.5" height="4.5" rx="1" stroke="currentColor" stroke-width="1.4"/></svg>
|
||||
Karten
|
||||
</button>
|
||||
<button data-viewmode="list" aria-label="Listenansicht">
|
||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="none"><path d="M3 4h10M3 8h10M3 12h10" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>
|
||||
Liste
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============== HOST: Karten ODER Liste ============== -->
|
||||
<article data-state-host>
|
||||
|
||||
<!-- ============== KARTEN-ANSICHT (DEFAULT) ============== -->
|
||||
<div data-state="cards">
|
||||
<div class="grid gap-4" style="grid-template-columns:repeat(3,1fr);">
|
||||
|
||||
<!-- 1) Tegernseer Brauerei -->
|
||||
<div class="firm-card is-self">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="logo lg-brew">TB</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="badge ok dot">Aktiv</span>
|
||||
<button class="menu-trigger" aria-label="Aktionen">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="3.5" r="1.2" fill="currentColor"/><circle cx="8" cy="8" r="1.2" fill="currentColor"/><circle cx="8" cy="12.5" r="1.2" fill="currentColor"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0">
|
||||
<h3 class="name">Tegernseer Brauerei AG</h3>
|
||||
<div class="meta-line">Tegernsee · Brauerei & Getränke · 142 MA</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="portal-pill pe"><span class="pdot"></span>presseecho</span>
|
||||
<span class="portal-pill bp"><span class="pdot"></span>businessportal24</span>
|
||||
<span class="role-pill admin">Admin</span>
|
||||
</div>
|
||||
|
||||
<div class="kpis">
|
||||
<div class="kpi"><span class="k">16</span><span class="l">PMs</span></div>
|
||||
<div class="kpi"><span class="k">2</span><span class="l">Kontakte</span></div>
|
||||
<div class="kpi"><span class="k">14.05.</span><span class="l">letzte PM</span></div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 pt-1">
|
||||
<a href="User Firma Bearbeiten presseportale.html" class="card-action primary" style="flex:1;">
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none"><path d="M11 3l2 2-8 8H3v-2z" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/></svg>
|
||||
Bearbeiten
|
||||
</a>
|
||||
<a href="Veroeffentlichen.html" class="card-action">
|
||||
<svg width="11" height="11" viewBox="0 0 12 12" fill="none"><path d="M6 2v8M2 6h8" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>
|
||||
Neue PM
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2) Mittelstandsverband Süd -->
|
||||
<div class="firm-card">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="logo lg-mv">MV</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="badge ok dot">Aktiv</span>
|
||||
<button class="menu-trigger" aria-label="Aktionen">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="3.5" r="1.2" fill="currentColor"/><circle cx="8" cy="8" r="1.2" fill="currentColor"/><circle cx="8" cy="12.5" r="1.2" fill="currentColor"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0">
|
||||
<h3 class="name">Mittelstandsverband Süd e. V.</h3>
|
||||
<div class="meta-line">München · Verband · 38 MA</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="portal-pill pe"><span class="pdot"></span>presseecho</span>
|
||||
<span class="portal-pill bp"><span class="pdot"></span>businessportal24</span>
|
||||
<span class="role-pill">Redakteur</span>
|
||||
</div>
|
||||
|
||||
<div class="kpis">
|
||||
<div class="kpi"><span class="k">8</span><span class="l">PMs</span></div>
|
||||
<div class="kpi"><span class="k">3</span><span class="l">Kontakte</span></div>
|
||||
<div class="kpi"><span class="k">12.05.</span><span class="l">letzte PM</span></div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 pt-1">
|
||||
<a href="User Firma Bearbeiten presseportale.html" class="card-action primary" style="flex:1;">
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none"><path d="M11 3l2 2-8 8H3v-2z" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/></svg>
|
||||
Bearbeiten
|
||||
</a>
|
||||
<a href="Veroeffentlichen.html" class="card-action">
|
||||
<svg width="11" height="11" viewBox="0 0 12 12" fill="none"><path d="M6 2v8M2 6h8" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>
|
||||
Neue PM
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3) Add tile -->
|
||||
<a href="User Firma Bearbeiten presseportale.html?mode=new" class="add-tile">
|
||||
<span class="ico">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none"><path d="M12 5v14M5 12h14" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||
</span>
|
||||
<span class="lbl">Neue Firma anlegen</span>
|
||||
<span class="sub">Stammdaten und Boilerplate. Die Anlage benötigt keine separate Freigabe.</span>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============== LISTEN-ANSICHT ============== -->
|
||||
<div data-state="list" style="display:none;">
|
||||
<div class="panel overflow-hidden">
|
||||
<table class="list">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:32px;"></th>
|
||||
<th>Firma</th>
|
||||
<th style="width:170px;">Portal</th>
|
||||
<th style="width:130px;">Rolle</th>
|
||||
<th style="width:110px;">Status</th>
|
||||
<th style="width:90px;text-align:right;">PMs</th>
|
||||
<th style="width:110px;text-align:right;">Kontakte</th>
|
||||
<th style="width:130px;">Letzte PM</th>
|
||||
<th style="width:40px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><span class="mini-logo lg-brew">TB</span></td>
|
||||
<td>
|
||||
<a href="User Firma Bearbeiten presseportale.html" class="row-title">Tegernseer Brauerei AG</a>
|
||||
<div class="row-sub">Tegernsee · Brauerei & Getränke · 142 MA</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="portal-pill pe"><span class="pdot"></span>presseecho</span>
|
||||
<span class="portal-pill bp" style="margin-left:4px;"><span class="pdot"></span>businessportal24</span>
|
||||
</td>
|
||||
<td><span class="role-pill admin">Admin</span></td>
|
||||
<td><span class="badge ok dot">Aktiv</span></td>
|
||||
<td style="text-align:right;"><span class="row-num">16</span></td>
|
||||
<td style="text-align:right;"><span class="row-num">2</span></td>
|
||||
<td><span class="row-num">14.05.2026</span></td>
|
||||
<td>
|
||||
<button class="menu-trigger" aria-label="Aktionen">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="3.5" r="1.2" fill="currentColor"/><circle cx="8" cy="8" r="1.2" fill="currentColor"/><circle cx="8" cy="12.5" r="1.2" fill="currentColor"/></svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="mini-logo lg-mv">MV</span></td>
|
||||
<td>
|
||||
<a href="User Firma Bearbeiten presseportale.html" class="row-title">Mittelstandsverband Süd e. V.</a>
|
||||
<div class="row-sub">München · Verband · 38 MA</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="portal-pill pe"><span class="pdot"></span>presseecho</span>
|
||||
<span class="portal-pill bp" style="margin-left:4px;"><span class="pdot"></span>businessportal24</span>
|
||||
</td>
|
||||
<td><span class="role-pill">Redakteur</span></td>
|
||||
<td><span class="badge ok dot">Aktiv</span></td>
|
||||
<td style="text-align:right;"><span class="row-num">8</span></td>
|
||||
<td style="text-align:right;"><span class="row-num">3</span></td>
|
||||
<td><span class="row-num">12.05.2026</span></td>
|
||||
<td>
|
||||
<button class="menu-trigger" aria-label="Aktionen">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="3.5" r="1.2" fill="currentColor"/><circle cx="8" cy="8" r="1.2" fill="currentColor"/><circle cx="8" cy="12.5" r="1.2" fill="currentColor"/></svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="px-5 py-3 bg-bg-elev border-t border-bg-rule flex items-center justify-between flex-wrap gap-3">
|
||||
<div class="flex items-center gap-3 text-[12px] text-ink-3">
|
||||
<span><strong class="text-ink-2 font-semibold">1–2</strong> von <strong class="text-ink-2 font-semibold">2</strong></span>
|
||||
<span class="text-ink-4">·</span>
|
||||
<label class="flex items-center gap-2">
|
||||
<span>Pro Seite</span>
|
||||
<select class="bg-white border border-bg-rule rounded-[3px] text-[12px] py-1 px-2 pr-6 text-ink-2 font-medium focus:outline-none focus:border-hub">
|
||||
<option>12</option><option selected="">25</option><option>50</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1.5">
|
||||
<button class="page-btn is-disabled" aria-label="Vorherige Seite">
|
||||
<svg width="11" height="11" viewBox="0 0 12 12" fill="none"><path d="M7.5 3L4.5 6l3 3" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
<button class="page-btn is-current">1</button>
|
||||
<button class="page-btn is-disabled" aria-label="Nächste Seite">
|
||||
<svg width="11" height="11" viewBox="0 0 12 12" fill="none"><path d="M4.5 3l3 3-3 3" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============== EMPTY — noch keine Firma ============== -->
|
||||
<div data-state="empty-none" style="display:none;">
|
||||
<div class="panel">
|
||||
<div class="empty-stage">
|
||||
<div class="empty-ico">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none"><rect x="3.5" y="5.5" width="17" height="14" stroke="currentColor" stroke-width="1.5"/><path d="M3.5 9.5h17M8 13h1M11 13h1M8 16h1M11 16h1" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>
|
||||
</div>
|
||||
<h3 class="empty-title">Noch keine Firma angelegt</h3>
|
||||
<p class="empty-sub">
|
||||
Lege deine erste Firma an. Du kannst direkt im Anschluss eine Pressemitteilung darauf veröffentlichen — eine separate Freigabe der Firma ist nicht erforderlich.
|
||||
</p>
|
||||
<div class="flex items-center gap-2.5 mt-6">
|
||||
<a href="User Firma Bearbeiten presseportale.html?mode=new" class="btn-primary">
|
||||
<svg width="11" height="11" viewBox="0 0 12 12" fill="none"><path d="M6 2v8M2 6h8" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>
|
||||
Erste Firma anlegen
|
||||
</a>
|
||||
<button class="btn-secondary">Beispielfirma laden</button>
|
||||
</div>
|
||||
<div class="mt-9 grid gap-3 w-full max-w-[560px]" style="grid-template-columns:repeat(3,1fr);">
|
||||
<div class="text-left px-3 py-2.5 bg-bg-elev border border-bg-rule rounded-[3px]">
|
||||
<div class="font-mono text-[9.5px] tracking-[0.16em] text-accent font-bold mb-1">01</div>
|
||||
<div class="text-[11.5px] font-semibold text-ink leading-tight">Stammdaten erfassen</div>
|
||||
</div>
|
||||
<div class="text-left px-3 py-2.5 bg-bg-elev border border-bg-rule rounded-[3px]">
|
||||
<div class="font-mono text-[9.5px] tracking-[0.16em] text-accent font-bold mb-1">02</div>
|
||||
<div class="text-[11.5px] font-semibold text-ink leading-tight">Boilerplate schreiben</div>
|
||||
</div>
|
||||
<div class="text-left px-3 py-2.5 bg-bg-elev border border-bg-rule rounded-[3px]">
|
||||
<div class="font-mono text-[9.5px] tracking-[0.16em] text-accent font-bold mb-1">03</div>
|
||||
<div class="text-[11.5px] font-semibold text-ink leading-tight">Pressekontakte zuordnen</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============== EMPTY — Filter ohne Treffer ============== -->
|
||||
<div data-state="empty-filter" style="display:none;">
|
||||
<div class="panel">
|
||||
<div class="empty-stage">
|
||||
<div class="empty-ico warm">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none"><path d="M3 5h18l-7 9v5l-4 1v-6z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/></svg>
|
||||
</div>
|
||||
<h3 class="empty-title">Keine Firmen mit diesen Filtern</h3>
|
||||
<p class="empty-sub">Aktive Filter passen auf keine Einträge. Filter zurücksetzen oder weiter fassen.</p>
|
||||
<div class="flex items-center gap-2.5 mt-6">
|
||||
<button class="btn-primary">Alle Filter zurücksetzen</button>
|
||||
<button class="btn-secondary">Filter bearbeiten</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</article>
|
||||
|
||||
<!-- ============== ROLLEN-LEGENDE ============== -->
|
||||
<article class="panel-warm p-5">
|
||||
<div class="grid items-start gap-6" style="grid-template-columns:auto 1fr;">
|
||||
<div class="min-w-[180px]">
|
||||
<div class="section-eyebrow">Rollen pro Firma</div>
|
||||
<p class="text-[12px] text-ink-3 leading-[1.55] mt-3 m-0 max-w-[220px]">
|
||||
Mehrere Personen können einer Firma zugeordnet sein. Rolle steuert, was im Backend möglich ist.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4" style="grid-template-columns:repeat(3,1fr);">
|
||||
<div>
|
||||
<span class="role-pill admin" style="margin-bottom:8px;">Admin</span>
|
||||
<ul class="text-[11.5px] text-ink-2 leading-[1.7] mt-2 list-none p-0 m-0 space-y-0.5">
|
||||
<li>Stammdaten & Boilerplate</li>
|
||||
<li>Pressekontakte verwalten</li>
|
||||
<li>PMs erstellen, einreichen, archivieren</li>
|
||||
<li>Weitere Mitglieder einladen</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<span class="role-pill" style="margin-bottom:8px;">Redakteur</span>
|
||||
<ul class="text-[11.5px] text-ink-2 leading-[1.7] mt-2 list-none p-0 m-0 space-y-0.5">
|
||||
<li>PMs erstellen & einreichen</li>
|
||||
<li>Stammdaten lesen</li>
|
||||
<li>Boilerplate lesen / Vorschlag</li>
|
||||
<li class="text-ink-4">keine Mitglieder-Verwaltung</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<span class="role-pill" style="margin-bottom:8px;">Beobachter <span class="text-ink-4 font-normal">· bald</span></span>
|
||||
<ul class="text-[11.5px] text-ink-2 leading-[1.7] mt-2 list-none p-0 m-0 space-y-0.5">
|
||||
<li>Read-only</li>
|
||||
<li>Statistik & PMs einsehen</li>
|
||||
<li class="text-ink-4">keine Bearbeitung</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<!-- ============== FUSSZEILE ============== -->
|
||||
<footer class="flex items-center justify-between pt-4 pb-2 text-[11px] text-ink-3 border-t border-bg-rule">
|
||||
<span>© 2026 presseportale.com · Publisher-Hub</span>
|
||||
<span class="flex items-center gap-5">
|
||||
<a href="#" class="hover:text-hub">Tastenkürzel</a>
|
||||
<a href="#" class="hover:text-hub">Changelog</a>
|
||||
<a href="#" class="hover:text-hub">Statusseite</a>
|
||||
<a href="/cdn-cgi/l/email-protection#e1929491918e9395a1919384929284918e9395808d84cf828e8c" class="hover:text-hub">Support</a>
|
||||
</span>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===================== TWEAKS ===================== -->
|
||||
<script data-cfasync="false" src="/cdn-cgi/scripts/5c5dd728/cloudflare-static/email-decode.min.js"></script><script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||
<script type="text/babel" src="tweaks-panel.jsx"></script>
|
||||
|
||||
<div id="tweaks-root"></div>
|
||||
|
||||
<script type="text/babel">
|
||||
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
|
||||
"view_state": "empty-none",
|
||||
"show_legend": true
|
||||
}/*EDITMODE-END*/;
|
||||
|
||||
function applyFirmenState(t) {
|
||||
const host = document.querySelector("[data-state-host]");
|
||||
if (host) {
|
||||
host.querySelectorAll("[data-state]").forEach((el) => {
|
||||
el.style.display = (el.getAttribute("data-state") === t.view_state) ? "" : "none";
|
||||
});
|
||||
}
|
||||
// sync segment toggle
|
||||
document.querySelectorAll(".seg-toggle button[data-viewmode]").forEach((b) => {
|
||||
b.classList.toggle("is-active",
|
||||
(t.view_state === "cards" && b.dataset.viewmode === "cards") ||
|
||||
(t.view_state === "list" && b.dataset.viewmode === "list"));
|
||||
});
|
||||
const legend = document.querySelector(".panel-warm");
|
||||
if (legend) legend.style.display = t.show_legend ? "" : "none";
|
||||
}
|
||||
|
||||
function TweaksApp() {
|
||||
const { TweaksPanel, TweakSection, TweakSelect, TweakToggle } = window;
|
||||
const [t, setTweak] = window.useTweaks(TWEAK_DEFAULTS);
|
||||
|
||||
React.useEffect(() => {
|
||||
applyFirmenState(t);
|
||||
// wire toggle clicks to tweak state
|
||||
const handlers = [];
|
||||
document.querySelectorAll(".seg-toggle button[data-viewmode]").forEach((b) => {
|
||||
const h = () => setTweak("view_state", b.dataset.viewmode);
|
||||
b.addEventListener("click", h);
|
||||
handlers.push([b, h]);
|
||||
});
|
||||
return () => handlers.forEach(([b, h]) => b.removeEventListener("click", h));
|
||||
}, [t]);
|
||||
|
||||
return (
|
||||
<TweaksPanel title="Tweaks">
|
||||
<TweakSection label="Ansicht">
|
||||
<TweakSelect
|
||||
label="Zustand"
|
||||
value={t.view_state}
|
||||
onChange={(v) => setTweak("view_state", v)}
|
||||
options={[
|
||||
{ value: "cards", label: "Karten (Default)" },
|
||||
{ value: "list", label: "Liste" },
|
||||
{ value: "empty-none", label: "Empty · noch keine Firma" },
|
||||
{ value: "empty-filter", label: "Empty · Filter ohne Treffer" },
|
||||
]}
|
||||
/>
|
||||
</TweakSection>
|
||||
<TweakSection label="Details">
|
||||
<TweakToggle
|
||||
label="Rollen-Legende"
|
||||
value={t.show_legend}
|
||||
onChange={(v) => setTweak("show_legend", v)}
|
||||
/>
|
||||
</TweakSection>
|
||||
</TweaksPanel>
|
||||
);
|
||||
}
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById("tweaks-root"));
|
||||
root.render(<TweaksApp />);
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
font-family: 'Inter Tight';
|
||||
font-style: normal;
|
||||
font-weight: 100;
|
||||
src: url('../fonts/inter-tight-v9-latin-100.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
src: url('./inter-tight-v9-latin-100.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
/* inter-tight-100italic - latin */
|
||||
@font-face {
|
||||
|
|
@ -12,7 +12,7 @@
|
|||
font-family: 'Inter Tight';
|
||||
font-style: italic;
|
||||
font-weight: 100;
|
||||
src: url('../fonts/inter-tight-v9-latin-100italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
src: url('./inter-tight-v9-latin-100italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
/* inter-tight-200 - latin */
|
||||
@font-face {
|
||||
|
|
@ -20,7 +20,7 @@
|
|||
font-family: 'Inter Tight';
|
||||
font-style: normal;
|
||||
font-weight: 200;
|
||||
src: url('../fonts/inter-tight-v9-latin-200.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
src: url('./inter-tight-v9-latin-200.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
/* inter-tight-200italic - latin */
|
||||
@font-face {
|
||||
|
|
@ -28,7 +28,7 @@
|
|||
font-family: 'Inter Tight';
|
||||
font-style: italic;
|
||||
font-weight: 200;
|
||||
src: url('../fonts/inter-tight-v9-latin-200italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
src: url('./inter-tight-v9-latin-200italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
/* inter-tight-300 - latin */
|
||||
@font-face {
|
||||
|
|
@ -36,7 +36,7 @@
|
|||
font-family: 'Inter Tight';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
src: url('../fonts/inter-tight-v9-latin-300.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
src: url('./inter-tight-v9-latin-300.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
/* inter-tight-300italic - latin */
|
||||
@font-face {
|
||||
|
|
@ -44,7 +44,7 @@
|
|||
font-family: 'Inter Tight';
|
||||
font-style: italic;
|
||||
font-weight: 300;
|
||||
src: url('../fonts/inter-tight-v9-latin-300italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
src: url('./inter-tight-v9-latin-300italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
/* inter-tight-regular - latin */
|
||||
@font-face {
|
||||
|
|
@ -52,7 +52,7 @@
|
|||
font-family: 'Inter Tight';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url('../fonts/inter-tight-v9-latin-regular.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
src: url('./inter-tight-v9-latin-regular.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
/* inter-tight-italic - latin */
|
||||
@font-face {
|
||||
|
|
@ -60,7 +60,7 @@
|
|||
font-family: 'Inter Tight';
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
src: url('../fonts/inter-tight-v9-latin-italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
src: url('./inter-tight-v9-latin-italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
/* inter-tight-500 - latin */
|
||||
@font-face {
|
||||
|
|
@ -68,7 +68,7 @@
|
|||
font-family: 'Inter Tight';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
src: url('../fonts/inter-tight-v9-latin-500.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
src: url('./inter-tight-v9-latin-500.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
/* inter-tight-500italic - latin */
|
||||
@font-face {
|
||||
|
|
@ -76,7 +76,7 @@
|
|||
font-family: 'Inter Tight';
|
||||
font-style: italic;
|
||||
font-weight: 500;
|
||||
src: url('../fonts/inter-tight-v9-latin-500italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
src: url('./inter-tight-v9-latin-500italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
/* inter-tight-600 - latin */
|
||||
@font-face {
|
||||
|
|
@ -84,7 +84,7 @@
|
|||
font-family: 'Inter Tight';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
src: url('../fonts/inter-tight-v9-latin-600.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
src: url('./inter-tight-v9-latin-600.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
/* inter-tight-600italic - latin */
|
||||
@font-face {
|
||||
|
|
@ -92,7 +92,7 @@
|
|||
font-family: 'Inter Tight';
|
||||
font-style: italic;
|
||||
font-weight: 600;
|
||||
src: url('../fonts/inter-tight-v9-latin-600italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
src: url('./inter-tight-v9-latin-600italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
/* inter-tight-700 - latin */
|
||||
@font-face {
|
||||
|
|
@ -100,7 +100,7 @@
|
|||
font-family: 'Inter Tight';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
src: url('../fonts/inter-tight-v9-latin-700.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
src: url('./inter-tight-v9-latin-700.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
/* inter-tight-700italic - latin */
|
||||
@font-face {
|
||||
|
|
@ -108,7 +108,7 @@
|
|||
font-family: 'Inter Tight';
|
||||
font-style: italic;
|
||||
font-weight: 700;
|
||||
src: url('../fonts/inter-tight-v9-latin-700italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
src: url('./inter-tight-v9-latin-700italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
/* inter-tight-800 - latin */
|
||||
@font-face {
|
||||
|
|
@ -116,7 +116,7 @@
|
|||
font-family: 'Inter Tight';
|
||||
font-style: normal;
|
||||
font-weight: 800;
|
||||
src: url('../fonts/inter-tight-v9-latin-800.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
src: url('./inter-tight-v9-latin-800.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
/* inter-tight-800italic - latin */
|
||||
@font-face {
|
||||
|
|
@ -124,7 +124,7 @@
|
|||
font-family: 'Inter Tight';
|
||||
font-style: italic;
|
||||
font-weight: 800;
|
||||
src: url('../fonts/inter-tight-v9-latin-800italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
src: url('./inter-tight-v9-latin-800italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
/* inter-tight-900 - latin */
|
||||
@font-face {
|
||||
|
|
@ -132,7 +132,7 @@
|
|||
font-family: 'Inter Tight';
|
||||
font-style: normal;
|
||||
font-weight: 900;
|
||||
src: url('../fonts/inter-tight-v9-latin-900.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
src: url('./inter-tight-v9-latin-900.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
/* inter-tight-900italic - latin */
|
||||
@font-face {
|
||||
|
|
@ -140,5 +140,5 @@
|
|||
font-family: 'Inter Tight';
|
||||
font-style: italic;
|
||||
font-weight: 900;
|
||||
src: url('../fonts/inter-tight-v9-latin-900italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
src: url('./inter-tight-v9-latin-900italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
font-family: 'JetBrains Mono';
|
||||
font-style: normal;
|
||||
font-weight: 100;
|
||||
src: url('../fonts/jetbrains-mono-v24-latin-100.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
src: url('./jetbrains-mono-v24-latin-100.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
/* jetbrains-mono-100italic - latin */
|
||||
@font-face {
|
||||
|
|
@ -12,7 +12,7 @@
|
|||
font-family: 'JetBrains Mono';
|
||||
font-style: italic;
|
||||
font-weight: 100;
|
||||
src: url('../fonts/jetbrains-mono-v24-latin-100italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
src: url('./jetbrains-mono-v24-latin-100italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
/* jetbrains-mono-200 - latin */
|
||||
@font-face {
|
||||
|
|
@ -20,7 +20,7 @@
|
|||
font-family: 'JetBrains Mono';
|
||||
font-style: normal;
|
||||
font-weight: 200;
|
||||
src: url('../fonts/jetbrains-mono-v24-latin-200.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
src: url('./jetbrains-mono-v24-latin-200.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
/* jetbrains-mono-200italic - latin */
|
||||
@font-face {
|
||||
|
|
@ -28,7 +28,7 @@
|
|||
font-family: 'JetBrains Mono';
|
||||
font-style: italic;
|
||||
font-weight: 200;
|
||||
src: url('../fonts/jetbrains-mono-v24-latin-200italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
src: url('./jetbrains-mono-v24-latin-200italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
/* jetbrains-mono-300 - latin */
|
||||
@font-face {
|
||||
|
|
@ -36,7 +36,7 @@
|
|||
font-family: 'JetBrains Mono';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
src: url('../fonts/jetbrains-mono-v24-latin-300.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
src: url('./jetbrains-mono-v24-latin-300.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
/* jetbrains-mono-300italic - latin */
|
||||
@font-face {
|
||||
|
|
@ -44,7 +44,7 @@
|
|||
font-family: 'JetBrains Mono';
|
||||
font-style: italic;
|
||||
font-weight: 300;
|
||||
src: url('../fonts/jetbrains-mono-v24-latin-300italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
src: url('./jetbrains-mono-v24-latin-300italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
/* jetbrains-mono-regular - latin */
|
||||
@font-face {
|
||||
|
|
@ -52,7 +52,7 @@
|
|||
font-family: 'JetBrains Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url('../fonts/jetbrains-mono-v24-latin-regular.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
src: url('./jetbrains-mono-v24-latin-regular.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
/* jetbrains-mono-italic - latin */
|
||||
@font-face {
|
||||
|
|
@ -60,7 +60,7 @@
|
|||
font-family: 'JetBrains Mono';
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
src: url('../fonts/jetbrains-mono-v24-latin-italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
src: url('./jetbrains-mono-v24-latin-italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
/* jetbrains-mono-500 - latin */
|
||||
@font-face {
|
||||
|
|
@ -68,7 +68,7 @@
|
|||
font-family: 'JetBrains Mono';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
src: url('../fonts/jetbrains-mono-v24-latin-500.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
src: url('./jetbrains-mono-v24-latin-500.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
/* jetbrains-mono-500italic - latin */
|
||||
@font-face {
|
||||
|
|
@ -76,7 +76,7 @@
|
|||
font-family: 'JetBrains Mono';
|
||||
font-style: italic;
|
||||
font-weight: 500;
|
||||
src: url('../fonts/jetbrains-mono-v24-latin-500italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
src: url('./jetbrains-mono-v24-latin-500italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
/* jetbrains-mono-600 - latin */
|
||||
@font-face {
|
||||
|
|
@ -84,7 +84,7 @@
|
|||
font-family: 'JetBrains Mono';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
src: url('../fonts/jetbrains-mono-v24-latin-600.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
src: url('./jetbrains-mono-v24-latin-600.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
/* jetbrains-mono-600italic - latin */
|
||||
@font-face {
|
||||
|
|
@ -92,7 +92,7 @@
|
|||
font-family: 'JetBrains Mono';
|
||||
font-style: italic;
|
||||
font-weight: 600;
|
||||
src: url('../fonts/jetbrains-mono-v24-latin-600italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
src: url('./jetbrains-mono-v24-latin-600italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
/* jetbrains-mono-700 - latin */
|
||||
@font-face {
|
||||
|
|
@ -100,7 +100,7 @@
|
|||
font-family: 'JetBrains Mono';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
src: url('../fonts/jetbrains-mono-v24-latin-700.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
src: url('./jetbrains-mono-v24-latin-700.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
/* jetbrains-mono-700italic - latin */
|
||||
@font-face {
|
||||
|
|
@ -108,7 +108,7 @@
|
|||
font-family: 'JetBrains Mono';
|
||||
font-style: italic;
|
||||
font-weight: 700;
|
||||
src: url('../fonts/jetbrains-mono-v24-latin-700italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
src: url('./jetbrains-mono-v24-latin-700italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
/* jetbrains-mono-800 - latin */
|
||||
@font-face {
|
||||
|
|
@ -116,7 +116,7 @@
|
|||
font-family: 'JetBrains Mono';
|
||||
font-style: normal;
|
||||
font-weight: 800;
|
||||
src: url('../fonts/jetbrains-mono-v24-latin-800.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
src: url('./jetbrains-mono-v24-latin-800.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
/* jetbrains-mono-800italic - latin */
|
||||
@font-face {
|
||||
|
|
@ -124,5 +124,5 @@
|
|||
font-family: 'JetBrains Mono';
|
||||
font-style: italic;
|
||||
font-weight: 800;
|
||||
src: url('../fonts/jetbrains-mono-v24-latin-800italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
src: url('./jetbrains-mono-v24-latin-800italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
font-family: 'Source Serif 4';
|
||||
font-style: normal;
|
||||
font-weight: 200;
|
||||
src: url('../fonts/source-serif-4-v14-latin-200.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
src: url('./source-serif-4-v14-latin-200.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
/* source-serif-4-200italic - latin */
|
||||
@font-face {
|
||||
|
|
@ -12,7 +12,7 @@
|
|||
font-family: 'Source Serif 4';
|
||||
font-style: italic;
|
||||
font-weight: 200;
|
||||
src: url('../fonts/source-serif-4-v14-latin-200italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
src: url('./source-serif-4-v14-latin-200italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
/* source-serif-4-300 - latin */
|
||||
@font-face {
|
||||
|
|
@ -20,7 +20,7 @@
|
|||
font-family: 'Source Serif 4';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
src: url('../fonts/source-serif-4-v14-latin-300.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
src: url('./source-serif-4-v14-latin-300.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
/* source-serif-4-300italic - latin */
|
||||
@font-face {
|
||||
|
|
@ -28,7 +28,7 @@
|
|||
font-family: 'Source Serif 4';
|
||||
font-style: italic;
|
||||
font-weight: 300;
|
||||
src: url('../fonts/source-serif-4-v14-latin-300italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
src: url('./source-serif-4-v14-latin-300italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
/* source-serif-4-regular - latin */
|
||||
@font-face {
|
||||
|
|
@ -36,7 +36,7 @@
|
|||
font-family: 'Source Serif 4';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url('../fonts/source-serif-4-v14-latin-regular.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
src: url('./source-serif-4-v14-latin-regular.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
/* source-serif-4-italic - latin */
|
||||
@font-face {
|
||||
|
|
@ -44,7 +44,7 @@
|
|||
font-family: 'Source Serif 4';
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
src: url('../fonts/source-serif-4-v14-latin-italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
src: url('./source-serif-4-v14-latin-italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
/* source-serif-4-500 - latin */
|
||||
@font-face {
|
||||
|
|
@ -52,7 +52,7 @@
|
|||
font-family: 'Source Serif 4';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
src: url('../fonts/source-serif-4-v14-latin-500.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
src: url('./source-serif-4-v14-latin-500.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
/* source-serif-4-500italic - latin */
|
||||
@font-face {
|
||||
|
|
@ -60,7 +60,7 @@
|
|||
font-family: 'Source Serif 4';
|
||||
font-style: italic;
|
||||
font-weight: 500;
|
||||
src: url('../fonts/source-serif-4-v14-latin-500italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
src: url('./source-serif-4-v14-latin-500italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
/* source-serif-4-600 - latin */
|
||||
@font-face {
|
||||
|
|
@ -68,7 +68,7 @@
|
|||
font-family: 'Source Serif 4';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
src: url('../fonts/source-serif-4-v14-latin-600.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
src: url('./source-serif-4-v14-latin-600.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
/* source-serif-4-600italic - latin */
|
||||
@font-face {
|
||||
|
|
@ -76,7 +76,7 @@
|
|||
font-family: 'Source Serif 4';
|
||||
font-style: italic;
|
||||
font-weight: 600;
|
||||
src: url('../fonts/source-serif-4-v14-latin-600italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
src: url('./source-serif-4-v14-latin-600italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
/* source-serif-4-700 - latin */
|
||||
@font-face {
|
||||
|
|
@ -84,7 +84,7 @@
|
|||
font-family: 'Source Serif 4';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
src: url('../fonts/source-serif-4-v14-latin-700.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
src: url('./source-serif-4-v14-latin-700.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
/* source-serif-4-700italic - latin */
|
||||
@font-face {
|
||||
|
|
@ -92,7 +92,7 @@
|
|||
font-family: 'Source Serif 4';
|
||||
font-style: italic;
|
||||
font-weight: 700;
|
||||
src: url('../fonts/source-serif-4-v14-latin-700italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
src: url('./source-serif-4-v14-latin-700italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
/* source-serif-4-800 - latin */
|
||||
@font-face {
|
||||
|
|
@ -100,7 +100,7 @@
|
|||
font-family: 'Source Serif 4';
|
||||
font-style: normal;
|
||||
font-weight: 800;
|
||||
src: url('../fonts/source-serif-4-v14-latin-800.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
src: url('./source-serif-4-v14-latin-800.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
/* source-serif-4-800italic - latin */
|
||||
@font-face {
|
||||
|
|
@ -108,7 +108,7 @@
|
|||
font-family: 'Source Serif 4';
|
||||
font-style: italic;
|
||||
font-weight: 800;
|
||||
src: url('../fonts/source-serif-4-v14-latin-800italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
src: url('./source-serif-4-v14-latin-800italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
/* source-serif-4-900 - latin */
|
||||
@font-face {
|
||||
|
|
@ -116,7 +116,7 @@
|
|||
font-family: 'Source Serif 4';
|
||||
font-style: normal;
|
||||
font-weight: 900;
|
||||
src: url('../fonts/source-serif-4-v14-latin-900.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
src: url('./source-serif-4-v14-latin-900.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
/* source-serif-4-900italic - latin */
|
||||
@font-face {
|
||||
|
|
@ -124,5 +124,5 @@
|
|||
font-family: 'Source Serif 4';
|
||||
font-style: italic;
|
||||
font-weight: 900;
|
||||
src: url('../fonts/source-serif-4-v14-latin-900italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
src: url('./source-serif-4-v14-latin-900italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
|
|
@ -665,6 +665,450 @@
|
|||
max-width: 440px;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* SEG-TOGGLE (Karten- vs. Listenansicht)
|
||||
* ============================================================ */
|
||||
.seg-toggle {
|
||||
display: inline-flex;
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-hub-soft-2);
|
||||
border-radius: 4px;
|
||||
padding: 2px;
|
||||
gap: 0;
|
||||
}
|
||||
.seg-toggle button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 11px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--color-ink-3);
|
||||
border-radius: 3px;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
cursor: pointer;
|
||||
}
|
||||
.seg-toggle button:hover {
|
||||
color: var(--color-hub);
|
||||
}
|
||||
.seg-toggle button.is-active {
|
||||
background: var(--color-hub);
|
||||
color: #fff;
|
||||
}
|
||||
.seg-toggle button svg {
|
||||
opacity: 0.7;
|
||||
}
|
||||
.seg-toggle button.is-active svg {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* FIRM-CARD — Firmen-Karte im Card-Grid
|
||||
* ============================================================ */
|
||||
.firm-card {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-bg-rule);
|
||||
border-radius: 6px;
|
||||
padding: 18px;
|
||||
transition: border-color 0.15s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
min-height: 266px;
|
||||
}
|
||||
.firm-card:hover {
|
||||
border-color: var(--color-hub-line);
|
||||
}
|
||||
.firm-card.is-self {
|
||||
border-color: var(--color-bg-rule);
|
||||
box-shadow: none;
|
||||
}
|
||||
.firm-card .logo {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 700;
|
||||
font-size: 18px;
|
||||
letter-spacing: -0.5px;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.firm-card .logo img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.firm-card .name {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.3px;
|
||||
color: var(--color-ink);
|
||||
line-height: 1.2;
|
||||
margin: 0;
|
||||
}
|
||||
.firm-card .meta-line {
|
||||
font-size: 11.5px;
|
||||
color: var(--color-ink-3);
|
||||
margin-top: 3px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.firm-card .kpis {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0;
|
||||
border-top: 1px solid var(--color-bg-rule-2);
|
||||
padding-top: 11px;
|
||||
margin-top: auto;
|
||||
}
|
||||
.firm-card .kpi {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 0 4px;
|
||||
border-right: 1px solid var(--color-bg-rule-2);
|
||||
}
|
||||
.firm-card .kpi:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
.firm-card .kpi .k {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 15.5px;
|
||||
font-weight: 600;
|
||||
color: var(--color-ink);
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
.firm-card .kpi .l {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-ink-4);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* ROLE-PILL — Rolle innerhalb einer Firma
|
||||
* ============================================================ */
|
||||
.role-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 2px 8px 2px 7px;
|
||||
background: var(--color-bg-elev);
|
||||
border: 1px dashed var(--color-hub-soft-2);
|
||||
border-radius: 99px;
|
||||
font-size: 10.5px;
|
||||
color: var(--color-ink-3);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.role-pill::before {
|
||||
content: "";
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 99px;
|
||||
background: var(--color-accent-warm);
|
||||
}
|
||||
.role-pill.admin {
|
||||
color: var(--color-hub);
|
||||
}
|
||||
.role-pill.admin::before {
|
||||
background: var(--color-hub);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* MINI-LOGO — Kleines Logo in Listen-Zeilen
|
||||
* ============================================================ */
|
||||
.mini-logo {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 4px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.3px;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.mini-logo img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* LOGO-COLOR-TOKENS — Deterministische Avatar-Varianten
|
||||
* ============================================================ */
|
||||
.lg-brew {
|
||||
background: linear-gradient(135deg, #3a4d2f 0%, #1f2e1a 100%);
|
||||
color: var(--color-accent-soft);
|
||||
}
|
||||
.lg-mv {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--color-hub) 0%,
|
||||
var(--color-hub-2) 100%
|
||||
);
|
||||
color: #fff;
|
||||
}
|
||||
.lg-soft {
|
||||
background: var(--color-accent-soft);
|
||||
color: var(--color-accent-deep);
|
||||
border: 1px solid
|
||||
color-mix(in oklab, var(--color-accent-warm), transparent 50%);
|
||||
}
|
||||
.lg-warm {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--color-accent-warm) 0%,
|
||||
var(--color-accent-deep) 100%
|
||||
);
|
||||
color: #fff;
|
||||
}
|
||||
.lg-blank {
|
||||
background: repeating-linear-gradient(
|
||||
135deg,
|
||||
var(--color-bg-elev) 0 6px,
|
||||
var(--color-bg-rule-2) 6px 12px
|
||||
);
|
||||
color: var(--color-ink-4);
|
||||
border: 1px dashed var(--color-hub-soft-2);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* CARD-ACTION — Aktion-Button auf einer Karte
|
||||
* ============================================================ */
|
||||
.card-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--color-hub-soft-2);
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-hub);
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
transition: border-color 0.12s, background 0.12s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.card-action:hover {
|
||||
border-color: var(--color-hub);
|
||||
background: var(--color-bg);
|
||||
}
|
||||
.card-action.primary {
|
||||
background: var(--color-hub);
|
||||
color: #fff;
|
||||
border-color: var(--color-hub);
|
||||
}
|
||||
.card-action.primary:hover {
|
||||
background: var(--color-hub-2);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* MENU-TRIGGER — 3-Dots Menu-Knopf
|
||||
* ============================================================ */
|
||||
.menu-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid transparent;
|
||||
color: var(--color-ink-3);
|
||||
background: transparent;
|
||||
transition: background 0.12s, border-color 0.12s, color 0.12s;
|
||||
cursor: pointer;
|
||||
}
|
||||
.menu-trigger:hover {
|
||||
background: var(--color-bg);
|
||||
border-color: var(--color-hub-soft-2);
|
||||
color: var(--color-hub);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* PAGE-BTN — Pagination-Buttons
|
||||
* ============================================================ */
|
||||
.page-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 30px;
|
||||
height: 30px;
|
||||
padding: 0 9px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-hub-soft-2);
|
||||
background: var(--color-bg-card);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--color-ink-2);
|
||||
font-family: var(--font-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
transition: border-color 0.12s, background 0.12s, color 0.12s;
|
||||
}
|
||||
.page-btn:hover {
|
||||
border-color: var(--color-hub);
|
||||
color: var(--color-hub);
|
||||
}
|
||||
.page-btn.is-current {
|
||||
background: var(--color-hub);
|
||||
border-color: var(--color-hub);
|
||||
color: #fff;
|
||||
}
|
||||
.page-btn.is-disabled {
|
||||
color: var(--color-ink-4);
|
||||
border-color: var(--color-bg-rule-2);
|
||||
background: var(--color-bg-elev);
|
||||
cursor: default;
|
||||
}
|
||||
.page-btn.is-disabled:hover {
|
||||
color: var(--color-ink-4);
|
||||
border-color: var(--color-bg-rule-2);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* TABLE.LIST — Hub-styled Datentabelle (für reine HTML-Tabellen)
|
||||
* ============================================================ */
|
||||
table.list {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
table.list thead th {
|
||||
text-align: left;
|
||||
font-weight: 700;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-ink-3);
|
||||
padding: 11px 14px;
|
||||
background: var(--color-bg-elev);
|
||||
border-bottom: 1px solid var(--color-bg-rule);
|
||||
white-space: nowrap;
|
||||
}
|
||||
table.list thead th:first-child {
|
||||
padding-left: 18px;
|
||||
}
|
||||
table.list thead th:last-child {
|
||||
padding-right: 18px;
|
||||
}
|
||||
table.list tbody td {
|
||||
padding: 14px;
|
||||
border-bottom: 1px solid var(--color-bg-rule-2);
|
||||
vertical-align: middle;
|
||||
}
|
||||
table.list tbody td:first-child {
|
||||
padding-left: 18px;
|
||||
}
|
||||
table.list tbody td:last-child {
|
||||
padding-right: 18px;
|
||||
}
|
||||
table.list tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
table.list tbody tr {
|
||||
transition: background 0.1s;
|
||||
}
|
||||
table.list tbody tr:hover {
|
||||
background: var(--color-bg-elev);
|
||||
}
|
||||
|
||||
.row-title {
|
||||
font-weight: 600;
|
||||
color: var(--color-ink);
|
||||
font-size: 13.5px;
|
||||
line-height: 1.35;
|
||||
letter-spacing: -0.1px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
}
|
||||
.row-title:hover {
|
||||
color: var(--color-hub);
|
||||
}
|
||||
.row-sub {
|
||||
font-size: 11.5px;
|
||||
color: var(--color-ink-3);
|
||||
margin-top: 3px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.row-num {
|
||||
font-family: var(--font-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 13px;
|
||||
color: var(--color-ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
.row-num .sub {
|
||||
font-family: var(--font-sans, "Inter Tight", sans-serif);
|
||||
font-weight: 400;
|
||||
font-size: 11px;
|
||||
color: var(--color-ink-4);
|
||||
margin-left: 4px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* ADD-TILE — "Neue Firma anlegen" Karte im Card-Grid
|
||||
* ============================================================ */
|
||||
.add-tile {
|
||||
border: 1.5px dashed var(--color-hub-soft-2);
|
||||
background: var(--color-bg-elev);
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
min-height: 266px;
|
||||
transition: border-color 0.15s, background 0.15s, color 0.15s;
|
||||
cursor: pointer;
|
||||
color: var(--color-ink-2);
|
||||
}
|
||||
.add-tile:hover {
|
||||
border-color: var(--color-hub);
|
||||
border-style: solid;
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-hub);
|
||||
}
|
||||
.add-tile .ico {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 6px;
|
||||
background: var(--color-hub-soft);
|
||||
color: var(--color-hub);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.add-tile:hover .ico {
|
||||
background: var(--color-hub);
|
||||
color: #fff;
|
||||
}
|
||||
.add-tile .lbl {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.add-tile .sub {
|
||||
font-size: 11.5px;
|
||||
color: var(--color-ink-3);
|
||||
margin-top: 6px;
|
||||
line-height: 1.5;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
|
|
|
|||
81
resources/js/portal-form-hooks.js
Normal file
81
resources/js/portal-form-hooks.js
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
/**
|
||||
* Portal Form Hooks
|
||||
*
|
||||
* Globale UX-Helper für FluxUI-Forms im Hub/Portal-Bereich.
|
||||
*
|
||||
* Aktuell:
|
||||
* 1) Smooth-Scroll zum ersten Validation-Error nach Submit-Klick,
|
||||
* damit der User in langen Forms (z.B. PR-Edit) nicht nach Errors
|
||||
* suchen muss.
|
||||
*
|
||||
* Wird im Portal-Layout NACH @fluxScripts eingebunden — Livewire ist
|
||||
* dann garantiert verfügbar. Bewusst KEIN Alpine.start() o.ä.; FluxUI
|
||||
* bringt seine eigene Alpine-Instanz mit, doppelter Bootstrap würde
|
||||
* Komponenten brechen (siehe partials/head.blade.php Kommentar).
|
||||
*/
|
||||
|
||||
(function () {
|
||||
if (typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Pending-Flag: wird nur gesetzt, wenn der User explizit auf einen
|
||||
// Submit-/Save-Button klickt. Andernfalls würde JEDES wire:model-Update
|
||||
// einen Scroll triggern, was bei Live-Validation extrem nervig wäre.
|
||||
let scrollPending = false;
|
||||
|
||||
// Selektoren für Buttons, die wir als "Submit-Intent" interpretieren.
|
||||
const SUBMIT_SELECTORS = [
|
||||
'[wire\\:click*="save"]',
|
||||
'[wire\\:click*="submit"]',
|
||||
'[wire\\:click*="update"]',
|
||||
'[wire\\:submit]',
|
||||
'button[type="submit"]',
|
||||
].join(',');
|
||||
|
||||
document.addEventListener('click', (event) => {
|
||||
const trigger = event.target.closest(SUBMIT_SELECTORS);
|
||||
if (trigger) {
|
||||
scrollPending = true;
|
||||
}
|
||||
}, true);
|
||||
|
||||
document.addEventListener('livewire:init', () => {
|
||||
if (!window.Livewire || typeof window.Livewire.hook !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
window.Livewire.hook('commit', ({ succeed }) => {
|
||||
succeed(() => {
|
||||
if (!scrollPending) {
|
||||
return;
|
||||
}
|
||||
scrollPending = false;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const invalid = document.querySelector('[data-flux-control][aria-invalid="true"]')
|
||||
|| document.querySelector('[aria-invalid="true"]')
|
||||
|| document.querySelector('[data-flux-error]:not(:empty)');
|
||||
|
||||
if (!invalid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const field = invalid.closest('[data-flux-field]')
|
||||
|| invalid.closest('[data-flux-control]')
|
||||
|| invalid;
|
||||
|
||||
field.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
|
||||
const focusable = field.querySelector('input, textarea, select, [contenteditable="true"]');
|
||||
if (focusable && typeof focusable.focus === 'function') {
|
||||
// Kleine Verzögerung, damit der Scroll erst sichtbar startet,
|
||||
// bevor wir den Cursor reinpacken — sonst springt der Browser
|
||||
// direkt zum Element und das smooth-Scroll wirkt unruhig.
|
||||
setTimeout(() => focusable.focus({ preventScroll: true }), 320);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
|
@ -10,9 +10,11 @@
|
|||
Flash beim allerersten Aufruf ist akzeptiert.
|
||||
--}}
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" @class(['dark' => request()->cookie('flux_appearance') === 'dark'])>
|
||||
|
||||
<head>
|
||||
@include('partials.head')
|
||||
</head>
|
||||
|
||||
<body class="min-h-screen bg-bg text-ink antialiased">
|
||||
<flux:sidebar sticky stashable class="border-e border-bg-rule">
|
||||
<flux:sidebar.toggle class="lg:hidden" icon="x-mark" />
|
||||
|
|
@ -42,7 +44,8 @@
|
|||
<flux:navlist variant="outline">
|
||||
{{-- Dashboard (Admin/Editor) --}}
|
||||
@if ($canAdmin)
|
||||
<flux:navlist.item icon="chart-bar" :href="route('dashboard')" :current="request()->routeIs('dashboard')" wire:navigate class="mb-4">
|
||||
<flux:navlist.item icon="chart-bar" :href="route('dashboard')"
|
||||
:current="request()->routeIs('dashboard')" wire:navigate class="mb-4">
|
||||
{{ __('Dashboard') }}
|
||||
</flux:navlist.item>
|
||||
@endif
|
||||
|
|
@ -50,16 +53,20 @@
|
|||
{{-- Mein Bereich – sichtbar für alle Panel-User --}}
|
||||
@if ($canCustomer)
|
||||
<flux:navlist.group :heading="__('Mein Bereich')" class="grid mb-4">
|
||||
<flux:navlist.item icon="home" :href="route('me.dashboard')" :current="request()->routeIs('me.dashboard')" wire:navigate>
|
||||
<flux:navlist.item icon="home" :href="route('me.dashboard')"
|
||||
:current="request()->routeIs('me.dashboard')" wire:navigate>
|
||||
{{ __('Übersicht') }}
|
||||
</flux:navlist.item>
|
||||
<flux:navlist.item icon="newspaper" :href="route('me.press-releases.index')" :current="request()->routeIs('me.press-releases.*')" wire:navigate>
|
||||
{{ __('Meine Pressemitteilungen') }}
|
||||
<flux:navlist.item icon="newspaper" :href="route('me.press-releases.index')"
|
||||
:current="request()->routeIs('me.press-releases.*')" wire:navigate>
|
||||
{{ __('Pressemitteilungen') }}
|
||||
</flux:navlist.item>
|
||||
<flux:navlist.item icon="building-office" :href="route('me.press-kits.index')" :current="request()->routeIs('me.press-kits.*')" wire:navigate>
|
||||
<flux:navlist.item icon="building-office" :href="route('me.press-kits.index')"
|
||||
:current="request()->routeIs('me.press-kits.*')" wire:navigate>
|
||||
{{ __('Firmen') }}
|
||||
</flux:navlist.item>
|
||||
<flux:navlist.item icon="shopping-bag" :href="route('me.bookings.index')" :current="request()->routeIs('me.bookings.*')" wire:navigate>
|
||||
<flux:navlist.item icon="shopping-bag" :href="route('me.bookings.index')"
|
||||
:current="request()->routeIs('me.bookings.*')" wire:navigate>
|
||||
{{ __('Buchungen & Add-ons') }}
|
||||
</flux:navlist.item>
|
||||
<div class="px-3 py-1.5 text-sm text-zinc-400 dark:text-zinc-500">
|
||||
|
|
@ -71,7 +78,8 @@
|
|||
<div class="px-3 py-1.5 text-sm text-zinc-400 dark:text-zinc-500">
|
||||
{{ __('Credits & Tarif') }} <span class="text-xs">{{ __('später') }}</span>
|
||||
</div>
|
||||
<flux:navlist.item icon="document-text" :href="route('me.invoices.index')" :current="request()->routeIs('me.invoices.*')" wire:navigate>
|
||||
<flux:navlist.item icon="document-text" :href="route('me.invoices.index')"
|
||||
:current="request()->routeIs('me.invoices.*')" wire:navigate>
|
||||
{{ __('Rechnungen') }}
|
||||
</flux:navlist.item>
|
||||
<div class="px-3 py-1.5 text-sm text-zinc-400 dark:text-zinc-500">
|
||||
|
|
@ -80,13 +88,16 @@
|
|||
</flux:navlist.group>
|
||||
|
||||
<flux:navlist.group :heading="__('Konto')" class="grid mb-4">
|
||||
<flux:navlist.item icon="user" :href="route('me.profile')" :current="request()->routeIs('me.profile')" wire:navigate>
|
||||
<flux:navlist.item icon="user" :href="route('me.profile')"
|
||||
:current="request()->routeIs('me.profile')" wire:navigate>
|
||||
{{ __('Profil') }}
|
||||
</flux:navlist.item>
|
||||
<flux:navlist.item icon="shield-check" :href="route('me.security')" :current="request()->routeIs('me.security')" wire:navigate>
|
||||
<flux:navlist.item icon="shield-check" :href="route('me.security')"
|
||||
:current="request()->routeIs('me.security')" wire:navigate>
|
||||
{{ __('Sicherheit') }}
|
||||
</flux:navlist.item>
|
||||
<flux:navlist.item icon="key" :href="route('me.tokens.index')" :current="request()->routeIs('me.tokens.*')" wire:navigate>
|
||||
<flux:navlist.item icon="key" :href="route('me.tokens.index')"
|
||||
:current="request()->routeIs('me.tokens.*')" wire:navigate>
|
||||
{{ __('API & Integrationen') }}
|
||||
</flux:navlist.item>
|
||||
<div class="px-3 py-1.5 text-sm text-zinc-400 dark:text-zinc-500">
|
||||
|
|
@ -98,66 +109,74 @@
|
|||
{{-- Content Management (Admin/Editor) --}}
|
||||
@if ($canAdmin)
|
||||
<flux:navlist.group :heading="__('Content')" class="grid mb-4">
|
||||
<flux:navlist.item
|
||||
icon="newspaper"
|
||||
<flux:navlist.item icon="newspaper"
|
||||
:href="route('admin.press-releases.index', $reviewCount > 0 ? ['status' => 'review'] : [])"
|
||||
:current="request()->routeIs('admin.press-releases.*')"
|
||||
:badge="$reviewCount > 0 ? $reviewCount : null"
|
||||
badge-color="yellow"
|
||||
wire:navigate
|
||||
>
|
||||
:badge="$reviewCount > 0 ? $reviewCount : null" badge-color="yellow" wire:navigate>
|
||||
{{ __('Pressemitteilungen') }}
|
||||
</flux:navlist.item>
|
||||
<flux:navlist.item icon="folder" :href="route('admin.categories.index')" :current="request()->routeIs('admin.categories.*')" wire:navigate>
|
||||
<flux:navlist.item icon="folder" :href="route('admin.categories.index')"
|
||||
:current="request()->routeIs('admin.categories.*')" wire:navigate>
|
||||
{{ __('Kategorien') }}
|
||||
</flux:navlist.item>
|
||||
<flux:navlist.item icon="code-bracket-square" :href="route('admin.footer-codes.index')" :current="request()->routeIs('admin.footer-codes.*')" wire:navigate>
|
||||
<flux:navlist.item icon="code-bracket-square" :href="route('admin.footer-codes.index')"
|
||||
:current="request()->routeIs('admin.footer-codes.*')" wire:navigate>
|
||||
{{ __('Footer-Codes') }}
|
||||
</flux:navlist.item>
|
||||
</flux:navlist.group>
|
||||
|
||||
{{-- CRM --}}
|
||||
<flux:navlist.group :heading="__('CRM')" class="grid mb-4">
|
||||
<flux:navlist.item icon="building-office" :href="route('admin.companies.index')" :current="request()->routeIs('admin.companies.*')" wire:navigate>
|
||||
<flux:navlist.item icon="building-office" :href="route('admin.companies.index')"
|
||||
:current="request()->routeIs('admin.companies.*')" wire:navigate>
|
||||
{{ __('Firmen') }}
|
||||
</flux:navlist.item>
|
||||
<flux:navlist.item icon="user-group" :href="route('admin.contacts.index')" :current="request()->routeIs('admin.contacts.*')" wire:navigate>
|
||||
<flux:navlist.item icon="user-group" :href="route('admin.contacts.index')"
|
||||
:current="request()->routeIs('admin.contacts.*')" wire:navigate>
|
||||
{{ __('Kontakte') }}
|
||||
</flux:navlist.item>
|
||||
</flux:navlist.group>
|
||||
|
||||
{{-- Billing --}}
|
||||
<flux:navlist.group :heading="__('Billing')" class="grid mb-4">
|
||||
<flux:navlist.item icon="archive-box" :href="route('admin.invoices.index')" :current="request()->routeIs('admin.invoices.*')" wire:navigate>
|
||||
<flux:navlist.item icon="archive-box" :href="route('admin.invoices.index')"
|
||||
:current="request()->routeIs('admin.invoices.*')" wire:navigate>
|
||||
{{ __('Legacy Rechnungen') }}
|
||||
</flux:navlist.item>
|
||||
<flux:navlist.item icon="credit-card" :href="route('admin.payments.index')" :current="request()->routeIs('admin.payments.*')" wire:navigate>
|
||||
<flux:navlist.item icon="credit-card" :href="route('admin.payments.index')"
|
||||
:current="request()->routeIs('admin.payments.*')" wire:navigate>
|
||||
{{ __('Zahlungen') }}
|
||||
</flux:navlist.item>
|
||||
<flux:navlist.item icon="ticket" :href="route('admin.coupons.index')" :current="request()->routeIs('admin.coupons.*')" wire:navigate>
|
||||
<flux:navlist.item icon="ticket" :href="route('admin.coupons.index')"
|
||||
:current="request()->routeIs('admin.coupons.*')" wire:navigate>
|
||||
{{ __('Gutscheine') }}
|
||||
</flux:navlist.item>
|
||||
<flux:navlist.item icon="envelope" :href="route('admin.newsletter.sync')" :current="request()->routeIs('admin.newsletter.sync')" wire:navigate>
|
||||
<flux:navlist.item icon="envelope" :href="route('admin.newsletter.sync')"
|
||||
:current="request()->routeIs('admin.newsletter.sync')" wire:navigate>
|
||||
{{ __('Newsletter Sync') }}
|
||||
</flux:navlist.item>
|
||||
</flux:navlist.group>
|
||||
|
||||
{{-- Administration --}}
|
||||
<flux:navlist.group :heading="__('Administration')" class="grid mb-4">
|
||||
<flux:navlist.item icon="cog" :href="route('admin.presets.index')" :current="request()->routeIs('admin.presets.*')" wire:navigate>
|
||||
<flux:navlist.item icon="cog" :href="route('admin.presets.index')"
|
||||
:current="request()->routeIs('admin.presets.*')" wire:navigate>
|
||||
{{ __('Voreinstellungen') }}
|
||||
</flux:navlist.item>
|
||||
<flux:navlist.item icon="users" :href="route('admin.users.index')" :current="request()->routeIs('admin.users.*')" wire:navigate>
|
||||
<flux:navlist.item icon="users" :href="route('admin.users.index')"
|
||||
:current="request()->routeIs('admin.users.*')" wire:navigate>
|
||||
{{ __('Benutzer') }}
|
||||
</flux:navlist.item>
|
||||
<flux:navlist.item icon="shield-check" :href="route('admin.roles.index')" :current="request()->routeIs('admin.roles.*')" wire:navigate>
|
||||
<flux:navlist.item icon="shield-check" :href="route('admin.roles.index')"
|
||||
:current="request()->routeIs('admin.roles.*')" wire:navigate>
|
||||
{{ __('Rollen & Rechte') }}
|
||||
</flux:navlist.item>
|
||||
</flux:navlist.group>
|
||||
|
||||
{{-- Reports --}}
|
||||
<flux:navlist.group :heading="__('Reports')" class="grid mb-4">
|
||||
<flux:navlist.item icon="chart-bar-square" :href="route('admin.reports.slow-requests')" :current="request()->routeIs('admin.reports.*')" wire:navigate>
|
||||
<flux:navlist.item icon="chart-bar-square" :href="route('admin.reports.slow-requests')"
|
||||
:current="request()->routeIs('admin.reports.*')" wire:navigate>
|
||||
{{ __('Performance') }}
|
||||
</flux:navlist.item>
|
||||
</flux:navlist.group>
|
||||
|
|
@ -196,12 +215,11 @@
|
|||
</p>
|
||||
<form method="POST" action="{{ route('admin.impersonate.leave') }}" class="mt-3">
|
||||
@csrf
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full px-3 py-2 bg-white text-hub text-[12px] font-semibold rounded-[3px] hover:bg-bg transition-colors flex items-center justify-center gap-1.5"
|
||||
>
|
||||
<button type="submit"
|
||||
class="w-full px-3 py-2 bg-white text-hub text-[12px] font-semibold rounded-[3px] hover:bg-bg transition-colors flex items-center justify-center gap-1.5">
|
||||
<svg width="11" height="11" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||
<path d="M9 3L3 9M3 9H8M3 9V4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M9 3L3 9M3 9H8M3 9V4" stroke="currentColor" stroke-width="1.5"
|
||||
stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
{{ __('Zurück zum Admin') }}
|
||||
</button>
|
||||
|
|
@ -213,11 +231,8 @@
|
|||
<flux:spacer />
|
||||
<!-- Desktop User Menu -->
|
||||
<flux:dropdown position="bottom" align="start">
|
||||
<flux:profile
|
||||
:name="auth()->user()->name"
|
||||
:initials="auth()->user()->initials()"
|
||||
icon-trailing="chevrons-up-down"
|
||||
/>
|
||||
<flux:profile :name="auth()->user()->name" :initials="auth()->user()->initials()"
|
||||
icon-trailing="chevrons-up-down" />
|
||||
|
||||
<flux:menu class="w-[220px]">
|
||||
<flux:menu.radio.group>
|
||||
|
|
@ -225,8 +240,7 @@
|
|||
<div class="flex items-center gap-2 px-1 py-1.5 text-start text-sm">
|
||||
<span class="relative flex h-8 w-8 shrink-0 overflow-hidden rounded-lg">
|
||||
<span
|
||||
class="flex h-full w-full items-center justify-center rounded-lg bg-neutral-200 text-black dark:bg-neutral-700 dark:text-white"
|
||||
>
|
||||
class="flex h-full w-full items-center justify-center rounded-lg bg-neutral-200 text-black dark:bg-neutral-700 dark:text-white">
|
||||
{{ auth()->user()->initials() }}
|
||||
</span>
|
||||
</span>
|
||||
|
|
@ -242,7 +256,8 @@
|
|||
<flux:menu.separator />
|
||||
|
||||
<flux:menu.radio.group>
|
||||
<flux:menu.item :href="route('settings.profile')" icon="cog" wire:navigate>{{ __('Settings') }}</flux:menu.item>
|
||||
<flux:menu.item :href="route('me.profile')" icon="user" wire:navigate>
|
||||
{{ __('Profil') }}</flux:menu.item>
|
||||
</flux:menu.radio.group>
|
||||
|
||||
<flux:menu.separator />
|
||||
|
|
@ -251,10 +266,12 @@
|
|||
`$flux.appearance` ist FluxUIs Magic-Object, persistent
|
||||
über LocalStorage. Werte: 'light' | 'dark' | 'system'. --}}
|
||||
<div class="px-3 py-2">
|
||||
<div class="mb-1.5 text-[10px] font-semibold tracking-[0.16em] uppercase text-[color:var(--color-ink-3)]">
|
||||
<div
|
||||
class="mb-1.5 text-[10px] font-semibold tracking-[0.16em] uppercase text-[color:var(--color-ink-3)]">
|
||||
{{ __('Erscheinung') }}
|
||||
</div>
|
||||
<flux:radio.group x-data variant="segmented" size="sm" x-model="$flux.appearance" class="w-full">
|
||||
<flux:radio.group x-data variant="segmented" size="sm" x-model="$flux.appearance"
|
||||
class="w-full">
|
||||
<flux:radio value="light" icon="sun" :title="__('Hell')" />
|
||||
<flux:radio value="dark" icon="moon" :title="__('Dunkel')" />
|
||||
<flux:radio value="system" icon="computer-desktop" :title="__('System')" />
|
||||
|
|
@ -265,8 +282,9 @@
|
|||
|
||||
<form method="POST" action="{{ route('logout') }}" class="w-full">
|
||||
@csrf
|
||||
<flux:menu.item as="button" type="submit" icon="arrow-right-start-on-rectangle" class="w-full">
|
||||
{{ __('Log Out') }}
|
||||
<flux:menu.item as="button" type="submit" icon="arrow-right-start-on-rectangle"
|
||||
class="w-full">
|
||||
{{ __('Abmelden') }}
|
||||
</flux:menu.item>
|
||||
</form>
|
||||
</flux:menu>
|
||||
|
|
@ -285,10 +303,7 @@
|
|||
<flux:spacer />
|
||||
|
||||
<flux:dropdown position="top" align="end">
|
||||
<flux:profile
|
||||
:initials="auth()->user()->initials()"
|
||||
icon-trailing="chevron-down"
|
||||
/>
|
||||
<flux:profile :initials="auth()->user()->initials()" icon-trailing="chevron-down" />
|
||||
|
||||
<flux:menu>
|
||||
<flux:menu.radio.group>
|
||||
|
|
@ -296,8 +311,7 @@
|
|||
<div class="flex items-center gap-2 px-1 py-1.5 text-start text-sm">
|
||||
<span class="relative flex h-8 w-8 shrink-0 overflow-hidden rounded-lg">
|
||||
<span
|
||||
class="flex h-full w-full items-center justify-center rounded-lg bg-neutral-200 text-black dark:bg-neutral-700 dark:text-white"
|
||||
>
|
||||
class="flex h-full w-full items-center justify-center rounded-lg bg-neutral-200 text-black dark:bg-neutral-700 dark:text-white">
|
||||
{{ auth()->user()->initials() }}
|
||||
</span>
|
||||
</span>
|
||||
|
|
@ -313,17 +327,20 @@
|
|||
<flux:menu.separator />
|
||||
|
||||
<flux:menu.radio.group>
|
||||
<flux:menu.item :href="route('settings.profile')" icon="cog" wire:navigate>{{ __('Settings') }}</flux:menu.item>
|
||||
<flux:menu.item :href="route('me.profile')" icon="user" wire:navigate>
|
||||
{{ __('Profil') }}</flux:menu.item>
|
||||
</flux:menu.radio.group>
|
||||
|
||||
<flux:menu.separator />
|
||||
|
||||
{{-- Phase 5: Appearance-Switcher (Mobile-Dropdown). --}}
|
||||
<div class="px-3 py-2">
|
||||
<div class="mb-1.5 text-[10px] font-semibold tracking-[0.16em] uppercase text-[color:var(--color-ink-3)]">
|
||||
<div
|
||||
class="mb-1.5 text-[10px] font-semibold tracking-[0.16em] uppercase text-[color:var(--color-ink-3)]">
|
||||
{{ __('Erscheinung') }}
|
||||
</div>
|
||||
<flux:radio.group x-data variant="segmented" size="sm" x-model="$flux.appearance" class="w-full">
|
||||
<flux:radio.group x-data variant="segmented" size="sm" x-model="$flux.appearance"
|
||||
class="w-full">
|
||||
<flux:radio value="light" icon="sun" :title="__('Hell')" />
|
||||
<flux:radio value="dark" icon="moon" :title="__('Dunkel')" />
|
||||
<flux:radio value="system" icon="computer-desktop" :title="__('System')" />
|
||||
|
|
@ -334,8 +351,9 @@
|
|||
|
||||
<form method="POST" action="{{ route('logout') }}" class="w-full">
|
||||
@csrf
|
||||
<flux:menu.item as="button" type="submit" icon="arrow-right-start-on-rectangle" class="w-full">
|
||||
{{ __('Log Out') }}
|
||||
<flux:menu.item as="button" type="submit" icon="arrow-right-start-on-rectangle"
|
||||
class="w-full">
|
||||
{{ __('Abmelden') }}
|
||||
</flux:menu.item>
|
||||
</form>
|
||||
</flux:menu>
|
||||
|
|
@ -344,6 +362,12 @@
|
|||
|
||||
{{ $slot }}
|
||||
|
||||
@persist('toast')
|
||||
<flux:toast position="top end" class="pt-24" />
|
||||
@endpersist
|
||||
|
||||
@vite(['resources/js/portal-form-hooks.js'], 'build/portal')
|
||||
@fluxScripts
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -40,9 +40,7 @@
|
|||
|
||||
<link rel="icon" href="{{ asset(\App\Helpers\ThemeHelper::getFaviconPath()) }}" />
|
||||
|
||||
<link rel="preconnect" href="https://fonts.bunny.net" />
|
||||
<link rel="preconnect" href="https://fonts.bunny.net" crossorigin />
|
||||
<link href="https://fonts.bunny.net/css?family=inter-tight:400,500,600,700|source-serif-4:400,500,600,700|jetbrains-mono:400,500,600" rel="stylesheet" />
|
||||
@include('partials.local-fonts')
|
||||
|
||||
{{-- Nur CSS aus dem Web-Build laden. Alpine bringt @livewireScripts mit;
|
||||
würden wir hier zusätzlich resources/js/app.js mit Alpine.start()
|
||||
|
|
|
|||
112
resources/views/components/portal/pagination.blade.php
Normal file
112
resources/views/components/portal/pagination.blade.php
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
@php
|
||||
if (! isset($scrollTo)) {
|
||||
$scrollTo = 'body';
|
||||
}
|
||||
|
||||
$scrollIntoViewJsSnippet = ($scrollTo !== false)
|
||||
? <<<JS
|
||||
(\$el.closest('{$scrollTo}') || document.querySelector('{$scrollTo}')).scrollIntoView()
|
||||
JS
|
||||
: '';
|
||||
|
||||
$pageName = $paginator->getPageName();
|
||||
$isLengthAware = method_exists($paginator, 'total');
|
||||
@endphp
|
||||
|
||||
@if ($paginator->hasPages())
|
||||
<nav role="navigation" aria-label="{{ __('Pagination Navigation') }}" class="portal-pagination flex flex-col gap-3 rounded-md border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-card)] px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p class="m-0 text-[12px] leading-5 text-[color:var(--color-ink-3)]">
|
||||
@if ($isLengthAware)
|
||||
{{ __('Zeige') }}
|
||||
<span class="font-mono font-semibold text-[color:var(--color-ink)]">{{ $paginator->firstItem() }}</span>
|
||||
{{ __('bis') }}
|
||||
<span class="font-mono font-semibold text-[color:var(--color-ink)]">{{ $paginator->lastItem() }}</span>
|
||||
{{ __('von') }}
|
||||
<span class="font-mono font-semibold text-[color:var(--color-ink)]">{{ $paginator->total() }}</span>
|
||||
@else
|
||||
{{ __('Seite') }}
|
||||
<span class="font-mono font-semibold text-[color:var(--color-ink)]">{{ $paginator->currentPage() }}</span>
|
||||
<span class="text-[color:var(--color-ink-4)]">·</span>
|
||||
{{ __('einfache Navigation') }}
|
||||
@endif
|
||||
</p>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
@if ($paginator->onFirstPage())
|
||||
<span aria-disabled="true" aria-label="{{ __('pagination.previous') }}" class="inline-flex h-9 min-w-9 cursor-default items-center justify-center rounded-[4px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] px-3 text-[12px] font-semibold text-[color:var(--color-ink-4)]">
|
||||
{{ __('Zurück') }}
|
||||
</span>
|
||||
@else
|
||||
<button
|
||||
type="button"
|
||||
wire:click="previousPage('{{ $pageName }}')"
|
||||
x-on:click="{{ $scrollIntoViewJsSnippet }}"
|
||||
wire:loading.attr="disabled"
|
||||
aria-label="{{ __('pagination.previous') }}"
|
||||
class="inline-flex h-9 min-w-9 cursor-pointer items-center justify-center rounded-[4px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] px-3 text-[12px] font-semibold text-[color:var(--color-hub)] transition hover:border-[color:var(--color-hub)] hover:bg-[color:var(--color-hub-soft)] focus:outline-none focus:ring-2 focus:ring-[color:var(--color-hub)]/25 disabled:cursor-wait disabled:opacity-60"
|
||||
>
|
||||
{{ __('Zurück') }}
|
||||
</button>
|
||||
@endif
|
||||
|
||||
@if ($isLengthAware)
|
||||
@foreach ($elements as $element)
|
||||
@if (is_string($element))
|
||||
<span aria-disabled="true" class="inline-flex h-9 min-w-9 cursor-default items-center justify-center rounded-[4px] px-2 text-[12px] font-semibold text-[color:var(--color-ink-4)]">
|
||||
{{ $element }}
|
||||
</span>
|
||||
@endif
|
||||
|
||||
@if (is_array($element))
|
||||
@foreach ($element as $page => $url)
|
||||
@if ($page === $paginator->currentPage())
|
||||
<span
|
||||
aria-current="page"
|
||||
class="is-active inline-flex h-9 min-w-9 cursor-default items-center justify-center rounded-[4px] border border-[color:var(--color-hub)] bg-[color:var(--color-hub)] px-3 text-[12px] font-bold text-[color:var(--color-ink-on-dark)] shadow-[0_0_0_2px_var(--color-hub-soft)]"
|
||||
>
|
||||
{{ $page }}
|
||||
</span>
|
||||
@else
|
||||
<button
|
||||
type="button"
|
||||
wire:key="paginator-{{ $pageName }}-page-{{ $page }}"
|
||||
wire:click="gotoPage({{ $page }}, '{{ $pageName }}')"
|
||||
x-on:click="{{ $scrollIntoViewJsSnippet }}"
|
||||
wire:loading.attr="disabled"
|
||||
aria-label="{{ __('Go to page :page', ['page' => $page]) }}"
|
||||
class="inline-flex h-9 min-w-9 cursor-pointer items-center justify-center rounded-[4px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] px-3 text-[12px] font-semibold text-[color:var(--color-ink-2)] transition hover:border-[color:var(--color-hub)] hover:bg-[color:var(--color-hub-soft)] hover:text-[color:var(--color-hub)] focus:outline-none focus:ring-2 focus:ring-[color:var(--color-hub)]/25 disabled:cursor-wait disabled:opacity-60"
|
||||
>
|
||||
{{ $page }}
|
||||
</button>
|
||||
@endif
|
||||
@endforeach
|
||||
@endif
|
||||
@endforeach
|
||||
@else
|
||||
<span
|
||||
aria-current="page"
|
||||
class="is-active inline-flex h-9 min-w-9 cursor-default items-center justify-center rounded-[4px] border border-[color:var(--color-hub)] bg-[color:var(--color-hub)] px-3 text-[12px] font-bold text-[color:var(--color-ink-on-dark)] shadow-[0_0_0_2px_var(--color-hub-soft)]"
|
||||
>
|
||||
{{ $paginator->currentPage() }}
|
||||
</span>
|
||||
@endif
|
||||
|
||||
@if ($paginator->hasMorePages())
|
||||
<button
|
||||
type="button"
|
||||
wire:click="nextPage('{{ $pageName }}')"
|
||||
x-on:click="{{ $scrollIntoViewJsSnippet }}"
|
||||
wire:loading.attr="disabled"
|
||||
aria-label="{{ __('pagination.next') }}"
|
||||
class="inline-flex h-9 min-w-9 cursor-pointer items-center justify-center rounded-[4px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] px-3 text-[12px] font-semibold text-[color:var(--color-hub)] transition hover:border-[color:var(--color-hub)] hover:bg-[color:var(--color-hub-soft)] focus:outline-none focus:ring-2 focus:ring-[color:var(--color-hub)]/25 disabled:cursor-wait disabled:opacity-60"
|
||||
>
|
||||
{{ __('Weiter') }}
|
||||
</button>
|
||||
@else
|
||||
<span aria-disabled="true" aria-label="{{ __('pagination.next') }}" class="inline-flex h-9 min-w-9 cursor-default items-center justify-center rounded-[4px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] px-3 text-[12px] font-semibold text-[color:var(--color-ink-4)]">
|
||||
{{ __('Weiter') }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
</nav>
|
||||
@endif
|
||||
|
|
@ -15,8 +15,7 @@
|
|||
<link rel="icon" href="{{ asset('img/favicons/admin-favicon.ico') }}">
|
||||
|
||||
<!-- Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||
<link href="https://fonts.bunny.net/css?family=instrument-sans:400,500,600,700" rel="stylesheet" />
|
||||
@include('partials.local-fonts')
|
||||
|
||||
<!-- Styles -->
|
||||
@vite(['resources/css/portal.css', 'resources/js/app.js'], 'build/portal')
|
||||
|
|
|
|||
|
|
@ -12,15 +12,7 @@
|
|||
<link rel="icon" href="{{ asset('img/favicons/' . $theme . '-favicon.ico') }}">
|
||||
|
||||
<!-- Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||
|
||||
@if ($theme === 'landing1')
|
||||
<link href="https://fonts.bunny.net/css?family=inter:400,500,600,700" rel="stylesheet" />
|
||||
@elseif($theme === 'landing2')
|
||||
<link href="https://fonts.bunny.net/css?family=poppins:400,500,600,700" rel="stylesheet" />
|
||||
@else
|
||||
<link href="https://fonts.bunny.net/css?family=instrument-sans:400,500,600,700" rel="stylesheet" />
|
||||
@endif
|
||||
@include('partials.local-fonts')
|
||||
|
||||
<!-- Dynamisches CSS basierend auf Domain-Konfiguration -->
|
||||
@if ($theme === 'landing1')
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ new #[Layout('components.layouts.app'), Title('Kategorien')] class extends Compo
|
|||
})
|
||||
->orderBy($sort, $this->sortDir);
|
||||
|
||||
$categories = $categoriesQuery->simplePaginate(50);
|
||||
$categories = $categoriesQuery->paginate(50);
|
||||
|
||||
if (! $sortsByCount) {
|
||||
$this->hydrateCounts($categories);
|
||||
|
|
@ -306,5 +306,5 @@ new #[Layout('components.layouts.app'), Title('Kategorien')] class extends Compo
|
|||
@endforelse
|
||||
</section>
|
||||
|
||||
{{ $categories->links() }}
|
||||
{{ $categories->links('components.portal.pagination') }}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ new #[Layout('components.layouts.app'), Title('Firmen')] class extends Component
|
|||
})
|
||||
->orderBy($sort, $this->sortDir);
|
||||
|
||||
$companies = $companiesQuery->simplePaginate(50);
|
||||
$companies = $companiesQuery->paginate(50);
|
||||
|
||||
if (! $sortsByCount) {
|
||||
$this->hydrateCounts($companies);
|
||||
|
|
@ -586,7 +586,7 @@ new #[Layout('components.layouts.app'), Title('Firmen')] class extends Component
|
|||
</flux:table.rows>
|
||||
</flux:table>
|
||||
<div class="border-t border-[color:var(--color-bg-rule)] p-4">
|
||||
{{ $companies->links() }}
|
||||
{{ $companies->links('components.portal.pagination') }}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -176,7 +176,7 @@ new #[Layout('components.layouts.app'), Title('Kontakte')] class extends Compone
|
|||
$query->where('portal', $this->portalFilter);
|
||||
})
|
||||
->orderBy(in_array($this->sortBy, ['last_name', 'email', 'company_id', 'press_releases_count', 'created_at'], true) ? $this->sortBy : 'created_at', $this->sortDir)
|
||||
->simplePaginate(50);
|
||||
->paginate(50);
|
||||
|
||||
// Firmen-Filter: nur Live-Suche, nie alle laden
|
||||
$term = trim($this->companySearch);
|
||||
|
|
@ -745,7 +745,7 @@ new #[Layout('components.layouts.app'), Title('Kontakte')] class extends Compone
|
|||
</flux:table>
|
||||
|
||||
<div class="border-t border-[color:var(--color-bg-rule)] p-4">
|
||||
{{ $contacts->links() }}
|
||||
{{ $contacts->links('components.portal.pagination') }}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -250,7 +250,7 @@ new #[Layout('components.layouts.app'), Title('Footer-Codes')] class extends Com
|
|||
</flux:table>
|
||||
|
||||
<div class="border-t border-[color:var(--color-bg-rule)] p-4">
|
||||
{{ $codes->links() }}
|
||||
{{ $codes->links('components.portal.pagination') }}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -328,7 +328,7 @@ new #[Layout('components.layouts.app'), Title('Legacy Rechnungen')] class extend
|
|||
</flux:table>
|
||||
|
||||
<div class="border-t border-[color:var(--color-bg-rule)] p-4">
|
||||
{{ $invoices->links() }}
|
||||
{{ $invoices->links('components.portal.pagination') }}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -208,7 +208,7 @@ new #[Layout('components.layouts.app'), Title('Voreinstellungen')] class extends
|
|||
</flux:table.rows>
|
||||
</flux:table>
|
||||
<div class="border-t border-[color:var(--color-bg-rule)] p-4">
|
||||
{{ $presets->links() }}
|
||||
{{ $presets->links('components.portal.pagination') }}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -10,6 +10,7 @@ use App\Models\User;
|
|||
use App\Services\Admin\AdminPerformanceCache;
|
||||
use App\Services\PressRelease\BlacklistViolationException;
|
||||
use App\Services\PressRelease\PressReleaseService;
|
||||
use Flux\Flux;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Title;
|
||||
|
|
@ -157,16 +158,21 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
|
|||
try {
|
||||
app(PressReleaseService::class)->publish($pr);
|
||||
} catch (BlacklistViolationException $e) {
|
||||
session()->flash('error', __('Automatisch abgelehnt: unzulässiges Wort ":word".', ['word' => $e->word]));
|
||||
Flux::toast(
|
||||
heading: __('Automatisch abgelehnt'),
|
||||
text: __('Unzulässiges Wort gefunden: ":word".', ['word' => $e->word]),
|
||||
variant: 'danger',
|
||||
duration: 8000,
|
||||
);
|
||||
|
||||
return;
|
||||
} catch (\LogicException $e) {
|
||||
session()->flash('error', $e->getMessage());
|
||||
Flux::toast(text: $e->getMessage(), variant: 'danger');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
session()->flash('success', __('Pressemitteilung veröffentlicht.'));
|
||||
Flux::toast(text: __('Pressemitteilung veröffentlicht.'), variant: 'success');
|
||||
}
|
||||
|
||||
public function reject(int $id): void
|
||||
|
|
@ -176,12 +182,12 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
|
|||
try {
|
||||
app(PressReleaseService::class)->reject($pr, __('Bitte überarbeiten Sie die Pressemitteilung.'));
|
||||
} catch (\LogicException $e) {
|
||||
session()->flash('error', $e->getMessage());
|
||||
Flux::toast(text: $e->getMessage(), variant: 'danger');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
session()->flash('success', __('Pressemitteilung abgelehnt.'));
|
||||
Flux::toast(text: __('Pressemitteilung abgelehnt.'), variant: 'warning');
|
||||
}
|
||||
|
||||
public function archive(int $id): void
|
||||
|
|
@ -191,12 +197,12 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
|
|||
try {
|
||||
app(PressReleaseService::class)->archive($pr);
|
||||
} catch (\LogicException $e) {
|
||||
session()->flash('error', $e->getMessage());
|
||||
Flux::toast(text: $e->getMessage(), variant: 'danger');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
session()->flash('success', __('Pressemitteilung archiviert.'));
|
||||
Flux::toast(text: __('Pressemitteilung archiviert.'), variant: 'success');
|
||||
}
|
||||
|
||||
public function with(): array
|
||||
|
|
@ -226,7 +232,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
|
|||
->when($this->companyFilter !== 'all', fn ($q) => $q->where('company_id', (int) $this->companyFilter))
|
||||
->when($this->contactFilter !== 'all', fn ($q) => $q->whereHas('contacts', fn ($contactQuery) => $contactQuery->where('contacts.id', (int) $this->contactFilter)))
|
||||
->orderBy(in_array($this->sortBy, ['title', 'status', 'portal', 'hits', 'created_at']) ? $this->sortBy : 'created_at', $this->sortDir)
|
||||
->simplePaginate(50);
|
||||
->paginate(50);
|
||||
|
||||
return [
|
||||
'pressReleases' => $query,
|
||||
|
|
@ -373,18 +379,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
|
|||
}; ?>
|
||||
|
||||
<div class="space-y-8">
|
||||
@if (session('success'))
|
||||
<div class="px-4 py-3 rounded-[5px] border text-[12.5px]
|
||||
bg-[color:var(--color-ok-soft)] border-[color:var(--color-ok)]/30 text-[color:var(--color-gain-deep)]">
|
||||
{{ session('success') }}
|
||||
</div>
|
||||
@endif
|
||||
@if (session('error'))
|
||||
<div class="px-4 py-3 rounded-[5px] border text-[12.5px]
|
||||
bg-[color:var(--color-err-soft)] border-[color:var(--color-err)]/30 text-[color:var(--color-loss)]">
|
||||
{{ session('error') }}
|
||||
</div>
|
||||
@endif
|
||||
{{-- Flash-Banner ersetzt durch <flux:toast /> im Layout. --}}
|
||||
|
||||
{{-- ============== PAGE HEADER ============== --}}
|
||||
<header class="grid items-end gap-8" style="grid-template-columns:1fr auto;">
|
||||
|
|
@ -905,6 +900,18 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
|
|||
<div class="text-[10.5px] text-[color:var(--color-ink-4)] mt-0.5">
|
||||
{{ $dateSubLabel }} · {{ $primaryDate?->format('H:i') }}
|
||||
</div>
|
||||
@if ($status === 'review' && $pr->scheduled_at && $pr->scheduled_at->isFuture())
|
||||
<div class="mt-1 inline-flex items-center gap-1 text-[10.5px] font-mono tabular-nums text-[color:var(--color-accent-deep)]">
|
||||
<flux:icon.calendar variant="micro" class="size-3" />
|
||||
<span>{{ __('geplant') }} · {{ $pr->scheduled_at->format('d.m. H:i') }}</span>
|
||||
</div>
|
||||
@endif
|
||||
@if ($pr->embargo_at && $pr->embargo_at->isFuture())
|
||||
<div class="mt-1 inline-flex items-center gap-1 text-[10.5px] font-mono tabular-nums text-[color:var(--color-accent-deep)]">
|
||||
<flux:icon.lock-closed variant="micro" class="size-3" />
|
||||
<span>{{ __('Embargo bis') }} {{ $pr->embargo_at->format('d.m.') }}</span>
|
||||
</div>
|
||||
@endif
|
||||
</flux:table.cell>
|
||||
|
||||
<flux:table.cell>
|
||||
|
|
@ -1029,5 +1036,5 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
|
|||
</flux:table>
|
||||
</article>
|
||||
|
||||
{{ $pressReleases->links() }}
|
||||
{{ $pressReleases->links('components.portal.pagination') }}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -28,13 +28,18 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
try {
|
||||
app(PressReleaseService::class)->publish($pr);
|
||||
} catch (BlacklistViolationException $e) {
|
||||
session()->flash('error', __('Automatisch abgelehnt: unzulässiges Wort ":word".', ['word' => $e->word]));
|
||||
Flux::toast(
|
||||
heading: __('Automatisch abgelehnt'),
|
||||
text: __('Unzulässiges Wort gefunden: ":word".', ['word' => $e->word]),
|
||||
variant: 'danger',
|
||||
duration: 8000,
|
||||
);
|
||||
Flux::modal('confirm-show-publish')->close();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
session()->flash('success', __('Pressemitteilung veröffentlicht. Autor wurde benachrichtigt.'));
|
||||
Flux::toast(text: __('Pressemitteilung veröffentlicht. Autor wurde benachrichtigt.'), variant: 'success');
|
||||
Flux::modal('confirm-show-publish')->close();
|
||||
}
|
||||
|
||||
|
|
@ -49,7 +54,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
|
||||
$this->rejectReason = '';
|
||||
|
||||
session()->flash('success', __('Pressemitteilung abgelehnt. Autor wurde benachrichtigt.'));
|
||||
Flux::toast(text: __('Pressemitteilung abgelehnt. Autor wurde benachrichtigt.'), variant: 'warning');
|
||||
Flux::modal('confirm-show-reject')->close();
|
||||
}
|
||||
|
||||
|
|
@ -57,7 +62,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
{
|
||||
$pr = PressRelease::withoutGlobalScopes()->findOrFail($this->id);
|
||||
app(PressReleaseService::class)->archive($pr);
|
||||
session()->flash('success', __('Archiviert.'));
|
||||
Flux::toast(text: __('Archiviert.'), variant: 'success');
|
||||
Flux::modal('confirm-show-archive')->close();
|
||||
}
|
||||
|
||||
|
|
@ -65,17 +70,31 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
{
|
||||
$pr = PressRelease::withoutGlobalScopes()
|
||||
->with([
|
||||
'company:id,name,slug',
|
||||
'company:id,name,email,phone,slug',
|
||||
'category.translations',
|
||||
'user:id,name',
|
||||
'user:id,name,email',
|
||||
'images',
|
||||
'attachments',
|
||||
'contacts' => fn ($query) => $query
|
||||
->withoutGlobalScopes()
|
||||
->orderBy('last_name')
|
||||
->orderBy('first_name')
|
||||
->select(['contacts.id', 'contacts.company_id', 'contacts.first_name', 'contacts.last_name', 'contacts.responsibility', 'contacts.email', 'contacts.phone']),
|
||||
'statusLogs.changedBy:id,name',
|
||||
])
|
||||
->findOrFail($this->id);
|
||||
|
||||
$latestRejection = null;
|
||||
if ($pr->status->value === 'rejected') {
|
||||
$latestRejection = $pr->statusLogs
|
||||
->firstWhere(fn ($log) => $log->to_status?->value === 'rejected');
|
||||
}
|
||||
|
||||
return [
|
||||
'pr' => $pr,
|
||||
'statusLogs' => $pr->statusLogs,
|
||||
'contacts' => $pr->contacts,
|
||||
'latestRejection' => $latestRejection,
|
||||
'categoryName' => $pr->category?->translations->firstWhere('locale', 'de')?->name
|
||||
?? $pr->category?->translations->first()?->name
|
||||
?? '–',
|
||||
|
|
@ -100,18 +119,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
};
|
||||
@endphp
|
||||
|
||||
@if (session('success'))
|
||||
<div class="px-4 py-3 rounded-[5px] border text-[12.5px]
|
||||
bg-[color:var(--color-ok-soft)] border-[color:var(--color-ok)]/30 text-[color:var(--color-gain-deep)]">
|
||||
{{ session('success') }}
|
||||
</div>
|
||||
@endif
|
||||
@if (session('error'))
|
||||
<div class="px-4 py-3 rounded-[5px] border text-[12.5px]
|
||||
bg-[color:var(--color-err-soft)] border-[color:var(--color-err)]/30 text-[color:var(--color-loss)]">
|
||||
{{ session('error') }}
|
||||
</div>
|
||||
@endif
|
||||
{{-- Flash-Banner ersetzt durch <flux:toast /> im Layout. --}}
|
||||
|
||||
{{-- ============== PAGE HEADER ============== --}}
|
||||
<header class="grid items-end gap-8" style="grid-template-columns:1fr auto;">
|
||||
|
|
@ -126,6 +134,11 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
<h1 class="text-[30px] font-bold tracking-[-0.6px] leading-[1.15] m-0 text-[color:var(--color-ink)]">
|
||||
{{ $pr->title }}
|
||||
</h1>
|
||||
@if ($pr->subtitle)
|
||||
<p class="text-[18px] font-medium tracking-[-0.2px] leading-[1.35] mt-2 m-0 max-w-[720px] text-[color:var(--color-ink-2)]">
|
||||
{{ $pr->subtitle }}
|
||||
</p>
|
||||
@endif
|
||||
<p class="text-[13px] leading-[1.55] mt-2 m-0 max-w-[720px] text-[color:var(--color-ink-2)]">
|
||||
<strong class="font-semibold text-[color:var(--color-ink)]">{{ __('Firma') }}:</strong>
|
||||
{{ $pr->company?->name ?? '–' }}
|
||||
|
|
@ -148,17 +161,58 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
</div>
|
||||
</header>
|
||||
|
||||
{{-- ============== REJECTION-HINWEIS ============== --}}
|
||||
@if ($pr->status === \App\Enums\PressReleaseStatus::Rejected && $latestRejection)
|
||||
<article class="panel" style="border-color:var(--color-err); border-left-width:3px;">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Diese Pressemitteilung wurde abgelehnt') }}</span>
|
||||
<span class="badge err dot">{{ __('Handlung erforderlich') }}</span>
|
||||
</div>
|
||||
<div class="p-5 flex items-start gap-3">
|
||||
<div class="w-9 h-9 rounded-[5px] flex items-center justify-center flex-shrink-0
|
||||
bg-[color:var(--color-err-soft)] border border-[color:var(--color-err)]/30 text-[color:var(--color-loss)]">
|
||||
<flux:icon.exclamation-triangle class="size-[18px]" />
|
||||
</div>
|
||||
<div class="flex-1 text-[13px] text-[color:var(--color-ink-2)]">
|
||||
@if ($latestRejection->reason)
|
||||
<strong class="text-[color:var(--color-ink)] font-semibold">{{ __('Begründung') }}:</strong>
|
||||
<span class="block mt-1 whitespace-pre-line">{{ $latestRejection->reason }}</span>
|
||||
@else
|
||||
{{ __('Der Autor sollte den Inhalt überarbeiten und erneut einreichen.') }}
|
||||
@endif
|
||||
<span class="mt-2 block text-[11.5px] text-[color:var(--color-ink-3)]">
|
||||
{{ __('Abgelehnt am') }} {{ $latestRejection->created_at->format('d.m.Y H:i') }}
|
||||
@if ($latestRejection->changedBy)
|
||||
· {{ __('durch :name', ['name' => $latestRejection->changedBy->name]) }}
|
||||
@endif
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
@endif
|
||||
|
||||
{{-- ============== STATUS-WORKFLOW ============== --}}
|
||||
@if ($pr->status === \App\Enums\PressReleaseStatus::Review)
|
||||
<article class="panel">
|
||||
<article class="panel" style="border-color:var(--color-warn); border-left-width:3px;">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Status-Workflow') }}</span>
|
||||
<span class="badge warn dot">{{ __('Wartet auf Prüfung') }}</span>
|
||||
</div>
|
||||
<div class="p-5 flex flex-wrap items-center gap-3">
|
||||
<p class="text-[13px] text-[color:var(--color-ink-2)] m-0 flex-1 min-w-[200px]">
|
||||
{{ __('Diese PM wartet auf Prüfung.') }}
|
||||
<div class="p-5 flex flex-wrap items-start gap-3">
|
||||
<div class="w-9 h-9 rounded-[5px] flex items-center justify-center flex-shrink-0
|
||||
bg-[color:var(--color-warn-soft)] border border-[color:var(--color-warn)]/30 text-[color:var(--color-accent-deep)]">
|
||||
<flux:icon.clock class="size-[18px]" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-[220px] text-[13px] text-[color:var(--color-ink-2)]">
|
||||
<p class="m-0">{{ __('Diese PM wartet auf eine redaktionelle Entscheidung.') }}</p>
|
||||
@if ($pr->scheduled_at)
|
||||
<p class="m-0 mt-1 text-[12px] text-[color:var(--color-ink-3)]">
|
||||
<flux:icon.calendar variant="micro" class="inline-block size-3.5 -mt-0.5 mr-0.5" />
|
||||
{{ __('Geplante Veröffentlichung: :date', ['date' => $pr->scheduled_at->format('d.m.Y H:i')]) }}
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<flux:modal.trigger name="confirm-show-publish">
|
||||
<flux:button type="button" variant="primary">{{ __('Veröffentlichen') }}</flux:button>
|
||||
</flux:modal.trigger>
|
||||
|
|
@ -166,21 +220,39 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
<flux:button type="button" variant="danger">{{ __('Ablehnen') }}</flux:button>
|
||||
</flux:modal.trigger>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
@endif
|
||||
|
||||
@if ($pr->status === \App\Enums\PressReleaseStatus::Published)
|
||||
<article class="panel">
|
||||
<article class="panel" style="border-color:var(--color-ok); border-left-width:3px;">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Status-Workflow') }}</span>
|
||||
<span class="badge ok dot">{{ __('Live') }}</span>
|
||||
</div>
|
||||
<div class="p-5 flex flex-wrap items-center gap-3">
|
||||
<div class="p-5 flex flex-wrap items-start gap-3">
|
||||
<div class="w-9 h-9 rounded-[5px] flex items-center justify-center flex-shrink-0
|
||||
bg-[color:var(--color-ok-soft)] border border-[color:var(--color-ok)]/30 text-[color:var(--color-gain-deep)]">
|
||||
<flux:icon.check-circle class="size-[18px]" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-[220px] text-[13px] text-[color:var(--color-ink-2)]">
|
||||
<p class="m-0">
|
||||
{{ __('Veröffentlicht am') }}
|
||||
<strong class="text-[color:var(--color-ink)] font-semibold">{{ $pr->published_at?->format('d.m.Y H:i') ?? '–' }}</strong>
|
||||
</p>
|
||||
@if ($pr->embargo_at && $pr->embargo_at->isFuture())
|
||||
<p class="m-0 mt-1 text-[12px] text-[color:var(--color-ink-3)]">
|
||||
<flux:icon.lock-closed variant="micro" class="inline-block size-3.5 -mt-0.5 mr-0.5" />
|
||||
{{ __('Sperrfrist bis: :date', ['date' => $pr->embargo_at->format('d.m.Y H:i')]) }}
|
||||
</p>
|
||||
@endif
|
||||
@if ($pr->hits > 0)
|
||||
<p class="text-[13px] text-[color:var(--color-ink-2)] m-0 flex-1 min-w-[200px]">
|
||||
<strong class="text-[color:var(--color-ink)] font-semibold">{{ number_format($pr->hits) }}</strong>
|
||||
<p class="m-0 mt-1 text-[12px] text-[color:var(--color-ink-3)]">
|
||||
<strong class="text-[color:var(--color-ink)] font-semibold">{{ number_format($pr->hits, 0, ',', '.') }}</strong>
|
||||
{{ __('Aufrufe seit Veröffentlichung') }}
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
<flux:modal.trigger name="confirm-show-archive">
|
||||
<flux:button type="button" variant="ghost">{{ __('Archivieren') }}</flux:button>
|
||||
</flux:modal.trigger>
|
||||
|
|
@ -188,102 +260,110 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
</article>
|
||||
@endif
|
||||
|
||||
{{-- ============== TEXT + SIDEBAR ============== --}}
|
||||
<div class="grid gap-6 lg:grid-cols-[1fr,300px]">
|
||||
{{-- ============== KONTAKTE + STATUS/VERLAUF ============== --}}
|
||||
<div class="grid gap-6 xl:grid-cols-2">
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Inhalt') }}</span>
|
||||
<span class="section-eyebrow">{{ __('Zugeordnete Pressekontakte') }}</span>
|
||||
@if ($pr->company)
|
||||
<flux:button size="sm" variant="ghost" icon="building-office" href="{{ route('admin.companies.show', $pr->company->id) }}" wire:navigate>
|
||||
{{ __('Firma') }}
|
||||
</flux:button>
|
||||
@endif
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<div class="prose prose-zinc dark:prose-invert max-w-none text-[color:var(--color-ink)]">
|
||||
{!! $pr->renderedText() !!}
|
||||
<p class="text-[12px] text-[color:var(--color-ink-3)] mt-0 mb-4">
|
||||
{{ __('Kontakte, die dieser Pressemitteilung zugeordnet sind.') }}
|
||||
</p>
|
||||
<div class="space-y-2">
|
||||
@forelse ($contacts as $contact)
|
||||
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
|
||||
<div class="text-[13px] font-semibold text-[color:var(--color-ink)]">
|
||||
{{ trim(($contact->first_name ?? '').' '.($contact->last_name ?? '')) ?: __('Kontakt ohne Name') }}
|
||||
</div>
|
||||
<div class="text-[12px] text-[color:var(--color-ink-3)]">
|
||||
{{ $contact->responsibility ?: __('Keine Rolle hinterlegt') }}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<aside class="space-y-4">
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Details') }}</span>
|
||||
</div>
|
||||
<dl class="p-5 space-y-2.5 text-[12.5px]">
|
||||
<div class="flex justify-between gap-2">
|
||||
<dt class="text-[color:var(--color-ink-3)]">{{ __('Status') }}</dt>
|
||||
<dd class="font-semibold text-[color:var(--color-ink)]">{{ $pr->status->label() }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-2">
|
||||
<dt class="text-[color:var(--color-ink-3)]">{{ __('Erstellt') }}</dt>
|
||||
<dd class="text-[color:var(--color-ink)]">{{ $pr->created_at->format('d.m.Y H:i') }}</dd>
|
||||
</div>
|
||||
@if ($pr->published_at)
|
||||
<div class="flex justify-between gap-2">
|
||||
<dt class="text-[color:var(--color-ink-3)]">{{ __('Veröffentlicht') }}</dt>
|
||||
<dd class="text-[color:var(--color-ink)]">{{ $pr->published_at->format('d.m.Y H:i') }}</dd>
|
||||
</div>
|
||||
@endif
|
||||
<div class="flex justify-between gap-2">
|
||||
<dt class="text-[color:var(--color-ink-3)]">{{ __('Aufrufe') }}</dt>
|
||||
<dd class="text-[color:var(--color-ink)] font-semibold">{{ number_format($pr->hits) }}</dd>
|
||||
</div>
|
||||
@if ($pr->keywords)
|
||||
<div class="pt-2 border-t border-[color:var(--color-bg-rule)]">
|
||||
<dt class="text-[color:var(--color-ink-3)] mb-1">{{ __('Stichwörter') }}</dt>
|
||||
<dd class="text-[color:var(--color-ink)]">{{ $pr->keywords }}</dd>
|
||||
</div>
|
||||
@endif
|
||||
@if ($pr->backlink_url)
|
||||
<div class="pt-2 border-t border-[color:var(--color-bg-rule)]">
|
||||
<dt class="text-[color:var(--color-ink-3)] mb-1">{{ __('Backlink') }}</dt>
|
||||
<dd class="break-all">
|
||||
<a href="{{ $pr->backlink_url }}" target="_blank"
|
||||
<div class="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-[11.5px] text-[color:var(--color-ink-3)]">
|
||||
@if ($contact->email)
|
||||
<a href="mailto:{{ $contact->email }}"
|
||||
class="text-[color:var(--color-hub)] underline underline-offset-2 decoration-[color:var(--color-hub)]/40 hover:decoration-[color:var(--color-hub)]">
|
||||
{{ $pr->backlink_url }}
|
||||
{{ $contact->email }}
|
||||
</a>
|
||||
</dd>
|
||||
</div>
|
||||
@endif
|
||||
@if ($pr->no_export)
|
||||
<div class="mt-2 pt-2 border-t border-[color:var(--color-bg-rule)]">
|
||||
<span class="badge hub">{{ __('Kein Export') }}</span>
|
||||
</div>
|
||||
@if ($contact->phone)
|
||||
<span>{{ $contact->phone }}</span>
|
||||
@endif
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<div class="rounded-[5px] border border-dashed border-[color:var(--color-bg-rule)] p-4 text-[12.5px] text-[color:var(--color-ink-3)]">
|
||||
{{ __('Dieser Pressemitteilung ist kein Pressekontakt zugeordnet.') }}
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@if ($pr->images->isNotEmpty())
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Bilder') }}</span>
|
||||
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
|
||||
{{ $pr->images->count() }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="p-5 space-y-2">
|
||||
@foreach ($pr->images as $image)
|
||||
<div class="flex items-center gap-2 text-[12.5px]">
|
||||
<flux:icon.photo class="size-4 flex-shrink-0 text-[color:var(--color-ink-3)]" />
|
||||
<span class="truncate text-[color:var(--color-ink-2)]">{{ basename($image->path) }}</span>
|
||||
@if ($image->is_preview)
|
||||
<span class="badge hub">{{ __('Preview') }}</span>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</article>
|
||||
@endif
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{{-- ============== STATUS-VERLAUF ============== --}}
|
||||
@if ($statusLogs->isNotEmpty())
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Status-Verlauf') }}</span>
|
||||
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
|
||||
{{ $statusLogs->count() }} {{ __('Einträge') }}
|
||||
</span>
|
||||
<span class="section-eyebrow">{{ __('Status & Verlauf') }}</span>
|
||||
<span @class(['badge', $statusClass])>{{ $pr->status->label() }}</span>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<div class="grid gap-2 sm:grid-cols-2">
|
||||
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
|
||||
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Autor') }}</div>
|
||||
<div class="text-[13px] font-semibold text-[color:var(--color-ink)] mt-1 truncate">
|
||||
{{ $pr->user?->name ?? '–' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
|
||||
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Erstellt') }}</div>
|
||||
<div class="text-[13px] font-semibold text-[color:var(--color-ink)] mt-1">
|
||||
{{ $pr->created_at?->format('d.m.Y H:i') ?? '–' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
|
||||
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Veröffentlicht') }}</div>
|
||||
<div class="text-[13px] font-semibold text-[color:var(--color-ink)] mt-1">
|
||||
{{ $pr->published_at?->format('d.m.Y H:i') ?? '–' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
|
||||
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Aufrufe') }}</div>
|
||||
<div class="text-[13px] font-semibold text-[color:var(--color-ink)] mt-1">
|
||||
{{ number_format($pr->hits, 0, ',', '.') }}
|
||||
</div>
|
||||
</div>
|
||||
@if ($pr->scheduled_at)
|
||||
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
|
||||
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Geplant') }}</div>
|
||||
<div class="text-[13px] font-semibold text-[color:var(--color-ink)] mt-1">
|
||||
{{ $pr->scheduled_at->format('d.m.Y H:i') }}
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@if ($pr->embargo_at)
|
||||
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
|
||||
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Sperrfrist bis') }}</div>
|
||||
<div class="text-[13px] font-semibold text-[color:var(--color-ink)] mt-1">
|
||||
{{ $pr->embargo_at->format('d.m.Y H:i') }}
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($pr->no_export)
|
||||
<div class="mt-3 flex items-center gap-2 text-[12px] text-[color:var(--color-ink-3)]">
|
||||
<flux:icon.no-symbol variant="micro" class="size-3.5" />
|
||||
<span>{{ __('Kein Export aktiv (PM wird nicht über Feeds verteilt).') }}</span>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="my-4 border-t border-[color:var(--color-bg-rule)]"></div>
|
||||
|
||||
@if ($statusLogs->isNotEmpty())
|
||||
<ol class="space-y-3 border-s border-[color:var(--color-bg-rule)] ps-4">
|
||||
@foreach ($statusLogs as $log)
|
||||
<li class="text-[12.5px]">
|
||||
|
|
@ -310,20 +390,126 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">·</span>
|
||||
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">{{ $log->changedBy->name }}</span>
|
||||
@endif
|
||||
@if ($log->source !== 'admin')
|
||||
@if ($log->source && $log->source !== 'admin')
|
||||
<span class="badge hub">{{ $log->source }}</span>
|
||||
@endif
|
||||
</div>
|
||||
@if ($log->reason)
|
||||
<p class="mt-1.5 text-[color:var(--color-ink-2)] m-0">{{ $log->reason }}</p>
|
||||
<p class="mt-1.5 text-[color:var(--color-ink-2)] m-0 whitespace-pre-line">{{ $log->reason }}</p>
|
||||
@endif
|
||||
</li>
|
||||
@endforeach
|
||||
</ol>
|
||||
@else
|
||||
<p class="text-[12.5px] text-[color:var(--color-ink-3)] m-0">
|
||||
{{ __('Noch keine Statusänderungen protokolliert.') }}
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
{{-- ============== INHALT ============== --}}
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Inhalt') }}</span>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<div class="prose prose-zinc dark:prose-invert max-w-none text-[color:var(--color-ink)]">
|
||||
{!! $pr->renderedText() !!}
|
||||
</div>
|
||||
|
||||
@if ($pr->keywords || $pr->backlink_url)
|
||||
<div class="mt-6 space-y-2 border-t border-[color:var(--color-bg-rule)] pt-4 text-[12.5px] text-[color:var(--color-ink-2)]">
|
||||
@if ($pr->keywords)
|
||||
<p class="m-0">
|
||||
<strong class="text-[color:var(--color-ink)] font-semibold">{{ __('Stichwörter') }}:</strong>
|
||||
{{ $pr->keywords }}
|
||||
</p>
|
||||
@endif
|
||||
@if ($pr->backlink_url)
|
||||
<p class="m-0">
|
||||
<strong class="text-[color:var(--color-ink)] font-semibold">{{ __('Backlink') }}:</strong>
|
||||
<a href="{{ $pr->backlink_url }}" target="_blank"
|
||||
class="text-[color:var(--color-hub)] underline underline-offset-2 decoration-[color:var(--color-hub)]/40 hover:decoration-[color:var(--color-hub)]">
|
||||
{{ $pr->backlink_url }}
|
||||
</a>
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{{-- ============== BOILERPLATE-OVERRIDE ============== --}}
|
||||
@if ($pr->boilerplate_override)
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Eigener Abbinder (Boilerplate)') }}</span>
|
||||
<span class="badge hub">{{ __('Override') }}</span>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<p class="text-[12px] text-[color:var(--color-ink-3)] mt-0 mb-3">
|
||||
{{ __('Dieser Text wird für diese Pressemitteilung anstelle des Standard-Abbinders der Firma verwendet.') }}
|
||||
</p>
|
||||
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-4 text-[13px] leading-[1.6] text-[color:var(--color-ink-2)] whitespace-pre-line">
|
||||
{{ $pr->boilerplate_override }}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
@endif
|
||||
|
||||
{{-- ============== MEDIEN ============== --}}
|
||||
@if ($pr->images->isNotEmpty())
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Bilder') }}</span>
|
||||
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
|
||||
{{ $pr->images->count() }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="p-5 space-y-2">
|
||||
@foreach ($pr->images as $image)
|
||||
<div class="flex items-center gap-2 text-[12.5px]">
|
||||
<flux:icon.photo class="size-4 flex-shrink-0 text-[color:var(--color-ink-3)]" />
|
||||
<span class="truncate text-[color:var(--color-ink-2)]">{{ basename($image->path) }}</span>
|
||||
@if ($image->is_preview)
|
||||
<span class="badge hub">{{ __('Preview') }}</span>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</article>
|
||||
@endif
|
||||
|
||||
{{-- ANHÄNGE-ANZEIGE — TEMPORÄR DEAKTIVIERT
|
||||
Datei-Uploads erfordern eine vollständige Sicherheitsprüfung.
|
||||
Wird mit dem Anhang-Manager in einer späteren Phase wieder aktiviert.
|
||||
@if ($pr->attachments->isNotEmpty())
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Anhänge') }}</span>
|
||||
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
|
||||
{{ $pr->attachments->count() }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="p-5 space-y-2">
|
||||
@foreach ($pr->attachments as $attachment)
|
||||
<div class="flex items-center gap-2 text-[12.5px]">
|
||||
<flux:icon.paper-clip class="size-4 flex-shrink-0 text-[color:var(--color-ink-3)]" />
|
||||
<span class="truncate text-[color:var(--color-ink-2)] flex-1">
|
||||
{{ $attachment->title ?: $attachment->original_name }}
|
||||
</span>
|
||||
<span class="text-[11px] text-[color:var(--color-ink-3)] flex-shrink-0">
|
||||
{{ number_format($attachment->size / 1024, 0, ',', '.') }} KB
|
||||
</span>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</article>
|
||||
@endif
|
||||
--}}
|
||||
|
||||
@if($pr->status === \App\Enums\PressReleaseStatus::Review)
|
||||
<flux:modal name="confirm-show-publish" class="max-w-lg">
|
||||
<div class="space-y-6">
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ use App\Models\Company;
|
|||
use App\Models\User;
|
||||
use App\Services\Admin\AdminPerformanceCache;
|
||||
use Flux\Flux;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Title;
|
||||
|
|
@ -66,18 +66,8 @@ new #[Layout('components.layouts.app'), Title('Benutzer')] class extends Compone
|
|||
'pressReleases as published_press_releases_count' => fn ($query) => $query->where('status', PressReleaseStatus::Published->value),
|
||||
])
|
||||
->withExists(['profile', 'billingAddress'])
|
||||
->when($this->search, function ($query): void {
|
||||
$term = trim($this->search);
|
||||
|
||||
if ($this->supportsFullTextSearch($term)) {
|
||||
$query->whereFullText(['name', 'email'], $term);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$query->where(function ($searchQuery): void {
|
||||
$searchQuery->where('name', 'like', '%'.$this->search.'%')->orWhere('email', 'like', '%'.$this->search.'%');
|
||||
});
|
||||
->when(filled(trim($this->search)), function (Builder $query): void {
|
||||
$this->applySearch($query, $this->search);
|
||||
})
|
||||
->when($this->activeFilter !== 'all', function ($query): void {
|
||||
$query->where('is_active', $this->activeFilter === 'active');
|
||||
|
|
@ -115,7 +105,7 @@ new #[Layout('components.layouts.app'), Title('Benutzer')] class extends Compone
|
|||
};
|
||||
})
|
||||
->orderBy($sort, $this->sortDir)
|
||||
->simplePaginate(50);
|
||||
->paginate(50);
|
||||
|
||||
$this->hydrateCompanyCounts($users);
|
||||
|
||||
|
|
@ -266,9 +256,30 @@ new #[Layout('components.layouts.app'), Title('Benutzer')] class extends Compone
|
|||
->find($this->viewingUserId);
|
||||
}
|
||||
|
||||
private function supportsFullTextSearch(string $term): bool
|
||||
private function applySearch(Builder $query, string $search): void
|
||||
{
|
||||
return mb_strlen($term) >= 3 && in_array(DB::connection()->getDriverName(), ['mysql', 'pgsql'], true);
|
||||
$terms = preg_split('/\s+/', trim($search), -1, PREG_SPLIT_NO_EMPTY);
|
||||
|
||||
if ($terms === false || $terms === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$query->where(function (Builder $searchQuery) use ($terms): void {
|
||||
foreach ($terms as $term) {
|
||||
$pattern = '%'.$this->escapeLikeTerm($term).'%';
|
||||
|
||||
$searchQuery->where(function (Builder $termQuery) use ($pattern): void {
|
||||
$termQuery
|
||||
->whereLike('name', $pattern)
|
||||
->orWhereLike('email', $pattern);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function escapeLikeTerm(string $term): string
|
||||
{
|
||||
return addcslashes($term, '\%_');
|
||||
}
|
||||
|
||||
public function updatedSearch(): void
|
||||
|
|
@ -586,7 +597,7 @@ new #[Layout('components.layouts.app'), Title('Benutzer')] class extends Compone
|
|||
</flux:table>
|
||||
|
||||
<div class="border-t border-[color:var(--color-bg-rule)] p-4">
|
||||
{{ $users->links() }}
|
||||
{{ $users->links('components.portal.pagination') }}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,382 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\PressReleaseAttachment;
|
||||
use App\Services\PressRelease\PressReleaseAttachmentStorage;
|
||||
use Flux\Flux;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Volt\Component;
|
||||
use Livewire\WithFileUploads;
|
||||
|
||||
/**
|
||||
* Reusable attachments manager for a single press release. Mirrors the API
|
||||
* of the press-release-images-manager: upload, edit metadata, reorder,
|
||||
* delete. Authorisation is delegated to the `update` ability on the
|
||||
* PressReleasePolicy so the component is safe for admins and customers.
|
||||
*
|
||||
* Storage uses the `public` disk with path obscurity (UUID prefix). Embargo
|
||||
* / unpublished-state guards live at the PressRelease level.
|
||||
*/
|
||||
new class extends Component
|
||||
{
|
||||
use WithFileUploads;
|
||||
|
||||
#[Locked]
|
||||
public int $pressReleaseId;
|
||||
|
||||
public $newFile = null;
|
||||
|
||||
public string $newTitle = '';
|
||||
|
||||
public string $newDescription = '';
|
||||
|
||||
public ?int $editingId = null;
|
||||
|
||||
public string $editTitle = '';
|
||||
|
||||
public string $editDescription = '';
|
||||
|
||||
public function mount(int $pressReleaseId): void
|
||||
{
|
||||
$this->pressReleaseId = $pressReleaseId;
|
||||
}
|
||||
|
||||
public function upload(PressReleaseAttachmentStorage $storage): void
|
||||
{
|
||||
$pressRelease = $this->getPressRelease();
|
||||
$this->authorize('update', $pressRelease);
|
||||
|
||||
if (! $this->canChangeAttachments($pressRelease)) {
|
||||
$this->addError('newFile', __('Anhänge können nur bei Entwürfen oder abgelehnten PMs geändert werden.'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$maxKb = (int) (PressReleaseAttachmentStorage::MAX_BYTES / 1024);
|
||||
$allowedExtensions = implode(',', PressReleaseAttachmentStorage::ALLOWED_EXTENSIONS);
|
||||
|
||||
$this->validate([
|
||||
'newFile' => ['required', 'file', 'mimes:'.$allowedExtensions, 'max:'.$maxKb],
|
||||
'newTitle' => ['nullable', 'string', 'max:120'],
|
||||
'newDescription' => ['nullable', 'string', 'max:500'],
|
||||
]);
|
||||
|
||||
$stored = $storage->store($this->newFile, $pressRelease->id);
|
||||
|
||||
$pressRelease->attachments()->create([
|
||||
'disk' => $stored['disk'],
|
||||
'path' => $stored['path'],
|
||||
'original_name' => $stored['original_name'],
|
||||
'mime' => $stored['mime'],
|
||||
'size' => $stored['size'],
|
||||
'title' => $this->newTitle ?: null,
|
||||
'description' => $this->newDescription ?: null,
|
||||
'sort_order' => ((int) $pressRelease->attachments()->max('sort_order')) + 1,
|
||||
]);
|
||||
|
||||
$this->reset(['newFile', 'newTitle', 'newDescription']);
|
||||
|
||||
Flux::toast(text: __('Anhang hochgeladen.'), variant: 'success');
|
||||
}
|
||||
|
||||
public function startEdit(int $attachmentId): void
|
||||
{
|
||||
$attachment = $this->getAttachment($attachmentId);
|
||||
|
||||
if (! $attachment) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->editingId = $attachment->id;
|
||||
$this->editTitle = $attachment->title ?? '';
|
||||
$this->editDescription = $attachment->description ?? '';
|
||||
}
|
||||
|
||||
public function cancelEdit(): void
|
||||
{
|
||||
$this->reset(['editingId', 'editTitle', 'editDescription']);
|
||||
}
|
||||
|
||||
public function updateAttachment(): void
|
||||
{
|
||||
$pressRelease = $this->getPressRelease();
|
||||
$this->authorize('update', $pressRelease);
|
||||
|
||||
if (! $this->canChangeAttachments($pressRelease) || $this->editingId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->validate([
|
||||
'editTitle' => ['nullable', 'string', 'max:120'],
|
||||
'editDescription' => ['nullable', 'string', 'max:500'],
|
||||
]);
|
||||
|
||||
$attachment = $this->getAttachment($this->editingId);
|
||||
|
||||
if (! $attachment) {
|
||||
return;
|
||||
}
|
||||
|
||||
$attachment->update([
|
||||
'title' => trim($this->editTitle) ?: null,
|
||||
'description' => trim($this->editDescription) ?: null,
|
||||
]);
|
||||
|
||||
$this->cancelEdit();
|
||||
|
||||
Flux::toast(text: __('Anhang aktualisiert.'), variant: 'success');
|
||||
}
|
||||
|
||||
public function moveUp(int $attachmentId): void
|
||||
{
|
||||
$this->swapSortOrder($attachmentId, -1);
|
||||
}
|
||||
|
||||
public function moveDown(int $attachmentId): void
|
||||
{
|
||||
$this->swapSortOrder($attachmentId, 1);
|
||||
}
|
||||
|
||||
public function remove(int $attachmentId, PressReleaseAttachmentStorage $storage): void
|
||||
{
|
||||
$pressRelease = $this->getPressRelease();
|
||||
$this->authorize('update', $pressRelease);
|
||||
|
||||
if (! $this->canChangeAttachments($pressRelease)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$attachment = $this->getAttachment($attachmentId);
|
||||
|
||||
if (! $attachment) {
|
||||
return;
|
||||
}
|
||||
|
||||
$storage->delete($attachment->disk, $attachment->path);
|
||||
$attachment->delete();
|
||||
|
||||
Flux::toast(text: __('Anhang entfernt.'), variant: 'success');
|
||||
}
|
||||
|
||||
public function with(): array
|
||||
{
|
||||
$pressRelease = $this->getPressRelease();
|
||||
|
||||
return [
|
||||
'attachments' => $pressRelease->attachments()
|
||||
->orderBy('sort_order')
|
||||
->orderBy('id')
|
||||
->get(),
|
||||
'canEdit' => auth()->user()?->can('update', $pressRelease) === true
|
||||
&& $this->canChangeAttachments($pressRelease),
|
||||
'maxMb' => round(PressReleaseAttachmentStorage::MAX_BYTES / 1024 / 1024),
|
||||
'allowedExtensions' => PressReleaseAttachmentStorage::ALLOWED_EXTENSIONS,
|
||||
];
|
||||
}
|
||||
|
||||
private function swapSortOrder(int $attachmentId, int $direction): void
|
||||
{
|
||||
$pressRelease = $this->getPressRelease();
|
||||
$this->authorize('update', $pressRelease);
|
||||
|
||||
if (! $this->canChangeAttachments($pressRelease)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$attachments = $pressRelease->attachments()->orderBy('sort_order')->orderBy('id')->get();
|
||||
$currentIndex = $attachments->search(fn (PressReleaseAttachment $att) => $att->id === $attachmentId);
|
||||
|
||||
if ($currentIndex === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
$targetIndex = $currentIndex + $direction;
|
||||
|
||||
if ($targetIndex < 0 || $targetIndex >= $attachments->count()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$current = $attachments[$currentIndex];
|
||||
$target = $attachments[$targetIndex];
|
||||
|
||||
$currentSort = $current->sort_order;
|
||||
$current->update(['sort_order' => $target->sort_order]);
|
||||
$target->update(['sort_order' => $currentSort]);
|
||||
}
|
||||
|
||||
private function getPressRelease(): PressRelease
|
||||
{
|
||||
return PressRelease::withoutGlobalScopes()
|
||||
->findOrFail($this->pressReleaseId);
|
||||
}
|
||||
|
||||
private function getAttachment(int $attachmentId): ?PressReleaseAttachment
|
||||
{
|
||||
return PressReleaseAttachment::query()
|
||||
->where('press_release_id', $this->pressReleaseId)
|
||||
->whereKey($attachmentId)
|
||||
->first();
|
||||
}
|
||||
|
||||
private function canChangeAttachments(PressRelease $pressRelease): bool
|
||||
{
|
||||
if (auth()->user()?->canAccessAdmin()) {
|
||||
return ! in_array($pressRelease->status, [PressReleaseStatus::Archived], true);
|
||||
}
|
||||
|
||||
return in_array(
|
||||
$pressRelease->status,
|
||||
[PressReleaseStatus::Draft, PressReleaseStatus::Rejected],
|
||||
true,
|
||||
);
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<section class="panel">
|
||||
<div class="p-5">
|
||||
<div class="flex items-center justify-between mb-3 gap-4 flex-wrap">
|
||||
<span class="pr-form-label" style="margin-bottom:0;">
|
||||
{{ __('Anhänge / Downloads') }}
|
||||
<span class="text-[color:var(--color-ink-4)] font-normal" style="letter-spacing:0;text-transform:none;">
|
||||
— {{ count($attachments) }}/10
|
||||
</span>
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-[10.5px] text-[color:var(--color-ink-4)]">
|
||||
{{ strtoupper(implode(' · ', $allowedExtensions)) }} · max. {{ $maxMb }} MB
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($canEdit)
|
||||
<form wire:submit="upload"
|
||||
class="rounded-[5px] border border-dashed border-[color:var(--color-hub-soft-2)] bg-[color:var(--color-bg-elev)] p-4 space-y-3">
|
||||
<div class="grid gap-3 sm:grid-cols-[1fr_auto] items-start">
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Datei') }} <span class="text-[color:var(--color-err)]">*</span></flux:label>
|
||||
<flux:input
|
||||
type="file"
|
||||
wire:model="newFile"
|
||||
accept=".{{ implode(',.', $allowedExtensions) }}"
|
||||
/>
|
||||
<flux:error name="newFile" />
|
||||
</flux:field>
|
||||
<flux:button type="submit" variant="primary" icon="arrow-up-tray" wire:loading.attr="disabled">
|
||||
<span wire:loading.remove wire:target="upload,newFile">{{ __('Hochladen') }}</span>
|
||||
<span wire:loading wire:target="upload,newFile">{{ __('Lädt…') }}</span>
|
||||
</flux:button>
|
||||
</div>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Titel (optional)') }}</flux:label>
|
||||
<flux:input wire:model="newTitle" placeholder="{{ __('z.B. Pressemappe Frühjahr 2026') }}" />
|
||||
<flux:error name="newTitle" />
|
||||
</flux:field>
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Beschreibung (optional)') }}</flux:label>
|
||||
<flux:input wire:model="newDescription" placeholder="{{ __('Kurze Beschreibung des Dokuments') }}" />
|
||||
<flux:error name="newDescription" />
|
||||
</flux:field>
|
||||
</div>
|
||||
</form>
|
||||
@endif
|
||||
|
||||
@if ($attachments->isEmpty())
|
||||
<div class="mt-4 rounded-[5px] border border-dashed border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-6 text-center">
|
||||
<flux:icon name="document" class="mx-auto mb-2 size-8 text-[color:var(--color-ink-4)]" />
|
||||
<p class="text-[12.5px] text-[color:var(--color-ink-3)] m-0">
|
||||
{{ __('Noch keine Anhänge — Pressemappen, Factsheets oder Bildmaterial-Pakete passen hier rein.') }}
|
||||
</p>
|
||||
</div>
|
||||
@else
|
||||
<div class="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
@foreach ($attachments as $attachment)
|
||||
<article class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-card)] p-3"
|
||||
wire:key="att-{{ $attachment->id }}">
|
||||
@if ($editingId === $attachment->id && $canEdit)
|
||||
{{-- Inline-Edit-Form --}}
|
||||
<div class="space-y-2">
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Titel') }}</flux:label>
|
||||
<flux:input wire:model="editTitle" />
|
||||
<flux:error name="editTitle" />
|
||||
</flux:field>
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Beschreibung') }}</flux:label>
|
||||
<flux:input wire:model="editDescription" />
|
||||
<flux:error name="editDescription" />
|
||||
</flux:field>
|
||||
<div class="flex justify-end gap-1 pt-1">
|
||||
<flux:button size="xs" variant="ghost" wire:click="cancelEdit">{{ __('Abbrechen') }}</flux:button>
|
||||
<flux:button size="xs" variant="primary" icon="check" wire:click="updateAttachment">{{ __('Speichern') }}</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-shrink-0 w-11 h-11 rounded-[4px] bg-[color:var(--color-hub-soft)] text-[color:var(--color-hub)] flex items-center justify-center">
|
||||
@php
|
||||
$ext = strtolower(pathinfo($attachment->original_name ?? '', PATHINFO_EXTENSION));
|
||||
$iconName = match (true) {
|
||||
$ext === 'pdf' => 'document-text',
|
||||
in_array($ext, ['doc', 'docx'], true) => 'document-text',
|
||||
in_array($ext, ['xls', 'xlsx'], true) => 'table-cells',
|
||||
in_array($ext, ['ppt', 'pptx'], true) => 'presentation-chart-bar',
|
||||
$ext === 'zip' => 'archive-box',
|
||||
default => 'document',
|
||||
};
|
||||
@endphp
|
||||
<flux:icon :name="$iconName" variant="mini" class="size-5" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-[13px] font-semibold text-[color:var(--color-ink)] m-0 truncate"
|
||||
title="{{ $attachment->title ?? $attachment->original_name }}">
|
||||
{{ $attachment->title ?? $attachment->original_name }}
|
||||
</p>
|
||||
@if ($attachment->title)
|
||||
<p class="text-[11px] text-[color:var(--color-ink-4)] m-0 truncate"
|
||||
title="{{ $attachment->original_name }}">
|
||||
{{ $attachment->original_name }}
|
||||
</p>
|
||||
@endif
|
||||
@if ($attachment->description)
|
||||
<p class="text-[11.5px] text-[color:var(--color-ink-3)] mt-1 mb-0 line-clamp-2">
|
||||
{{ $attachment->description }}
|
||||
</p>
|
||||
@endif
|
||||
<p class="text-[10.5px] text-[color:var(--color-ink-4)] font-mono mt-1 mb-0">
|
||||
{{ strtoupper($ext ?: '?') }} ·
|
||||
@php
|
||||
$bytes = (int) $attachment->size;
|
||||
$sizeLabel = $bytes >= 1048576
|
||||
? number_format($bytes / 1048576, 1, ',', '.').' MB'
|
||||
: number_format(max(1, (int) round($bytes / 1024)), 0, ',', '.').' KB';
|
||||
@endphp
|
||||
{{ $sizeLabel }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-1 mt-3 pt-3 border-t border-[color:var(--color-bg-rule)]">
|
||||
@if ($attachment->url())
|
||||
<flux:button size="xs" variant="ghost" icon="arrow-down-tray"
|
||||
href="{{ $attachment->url() }}" target="_blank" rel="noopener">
|
||||
{{ __('Download') }}
|
||||
</flux:button>
|
||||
@endif
|
||||
@if ($canEdit)
|
||||
<flux:button size="xs" variant="ghost" icon="pencil-square" wire:click="startEdit({{ $attachment->id }})" :title="__('Bearbeiten')" />
|
||||
<flux:button size="xs" variant="ghost" icon="arrow-up" wire:click="moveUp({{ $attachment->id }})" :title="__('Hoch')" />
|
||||
<flux:button size="xs" variant="ghost" icon="arrow-down" wire:click="moveDown({{ $attachment->id }})" :title="__('Runter')" />
|
||||
<span class="flex-1"></span>
|
||||
<flux:button size="xs" variant="ghost" icon="trash" wire:click="remove({{ $attachment->id }})"
|
||||
wire:confirm="{{ __('Anhang wirklich entfernen?') }}" :title="__('Entfernen')" />
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</article>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -4,6 +4,7 @@ use App\Enums\PressReleaseStatus;
|
|||
use App\Models\PressRelease;
|
||||
use App\Models\PressReleaseImage;
|
||||
use App\Services\Image\ImageService;
|
||||
use Flux\Flux;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Volt\Component;
|
||||
|
|
@ -73,7 +74,7 @@ new class extends Component
|
|||
|
||||
$this->reset(['newImage', 'newTitle', 'newCopyright', 'newIsPreview']);
|
||||
|
||||
session()->flash('image-status', __('Bild hochgeladen.'));
|
||||
Flux::toast(text: __('Bild hochgeladen.'), variant: 'success');
|
||||
}
|
||||
|
||||
public function setPreview(int $imageId): void
|
||||
|
|
@ -90,7 +91,7 @@ new class extends Component
|
|||
$pressRelease->images()->where('id', '!=', $image->id)->update(['is_preview' => false]);
|
||||
$image->update(['is_preview' => true]);
|
||||
|
||||
session()->flash('image-status', __('Vorschaubild gesetzt.'));
|
||||
Flux::toast(text: __('Vorschaubild gesetzt.'), variant: 'success');
|
||||
}
|
||||
|
||||
public function moveUp(int $imageId): void
|
||||
|
|
@ -121,7 +122,7 @@ new class extends Component
|
|||
$imageService->deletePressReleaseImage($image->disk, $image->path, $image->variants);
|
||||
$image->delete();
|
||||
|
||||
session()->flash('image-status', __('Bild entfernt.'));
|
||||
Flux::toast(text: __('Bild entfernt.'), variant: 'success');
|
||||
}
|
||||
|
||||
public function with(): array
|
||||
|
|
@ -198,10 +199,6 @@ new class extends Component
|
|||
<flux:badge color="zinc" size="sm">{{ count($images) }}</flux:badge>
|
||||
</div>
|
||||
|
||||
@if(session('image-status'))
|
||||
<flux:callout color="green" icon="check-circle" class="mt-3">{{ session('image-status') }}</flux:callout>
|
||||
@endif
|
||||
|
||||
@if($canEdit)
|
||||
<form wire:submit="upload" class="mt-4 space-y-3 rounded-md border border-zinc-200 p-4 dark:border-zinc-700">
|
||||
<flux:heading size="xs">{{ __('Neues Bild hinzufügen') }}</flux:heading>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,96 @@ use Livewire\Volt\Component;
|
|||
|
||||
new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class extends Component
|
||||
{
|
||||
public function with(): array
|
||||
{
|
||||
return [
|
||||
'creditSummary' => [
|
||||
'total' => 17,
|
||||
'bonus' => 12,
|
||||
'paid' => 5,
|
||||
'auto_refill' => __('ab 10 Credits empfohlen'),
|
||||
'validity' => __('Bonus-Credits verfallen monatlich, gekaufte Credits bleiben 24 Monate gültig.'),
|
||||
],
|
||||
'currentPlan' => [
|
||||
'name' => 'Starter',
|
||||
'price' => '19 €/Mo.',
|
||||
'press_releases' => '3 PMs/Monat',
|
||||
'bonus_credits' => 12,
|
||||
],
|
||||
'creditPackages' => [
|
||||
['name' => 'Test', 'credits' => 10, 'price' => '10 €', 'rate' => '1,00 €', 'saving' => null],
|
||||
['name' => 'Standard', 'credits' => 50, 'price' => '45 €', 'rate' => '0,90 €', 'saving' => '10 %'],
|
||||
['name' => 'Plus', 'credits' => 150, 'price' => '120 €', 'rate' => '0,80 €', 'saving' => '20 %'],
|
||||
['name' => 'Pro', 'credits' => 500, 'price' => '375 €', 'rate' => '0,75 €', 'saving' => '25 %'],
|
||||
['name' => 'Business', 'credits' => 1500, 'price' => '1.050 €', 'rate' => '0,70 €', 'saving' => '30 %'],
|
||||
],
|
||||
'serviceGroups' => [
|
||||
[
|
||||
'title' => __('Veröffentlichung'),
|
||||
'description' => __('Basisleistungen rund um Veröffentlichung, Korrektur und Aktualisierung.'),
|
||||
'services' => [
|
||||
['name' => __('Standard-PM (Pay-as-you-go)'), 'credits' => '19', 'meta' => __('1 Veröffentlichung')],
|
||||
['name' => __('PM-Korrektur'), 'credits' => '8', 'meta' => __('Pfad C')],
|
||||
['name' => __('PM-Update'), 'credits' => '4', 'meta' => __('im ersten Jahr ggf. kostenlos')],
|
||||
['name' => __('Depublizierung'), 'credits' => '19–25', 'meta' => __('abhängig vom Aufwand')],
|
||||
],
|
||||
],
|
||||
[
|
||||
'title' => __('Bilder'),
|
||||
'description' => __('Stock- und KI-Bilder für mehr Sichtbarkeit in Listen und Detailseiten.'),
|
||||
'services' => [
|
||||
['name' => __('Free-Stock'), 'credits' => '0', 'meta' => __('Unsplash, Pexels')],
|
||||
['name' => __('Premium-Stock'), 'credits' => '8', 'meta' => __('Adobe, Shutterstock')],
|
||||
['name' => __('KI-Bild generieren'), 'credits' => '4', 'meta' => __('neues Motiv')],
|
||||
['name' => __('KI-Bild Re-Generation'), 'credits' => '2', 'meta' => __('Variante erzeugen')],
|
||||
],
|
||||
],
|
||||
[
|
||||
'title' => __('KI-Textservices'),
|
||||
'description' => __('Qualität verbessern, Score-Stufe erreichen und bessere Headlines testen.'),
|
||||
'services' => [
|
||||
['name' => __('Quality-Check'), 'credits' => '3', 'meta' => __('Stil und Pressestil')],
|
||||
['name' => __('Lektorat'), 'credits' => '8', 'meta' => __('sprachliche Prüfung')],
|
||||
['name' => __('Pressetext-Optimierung'), 'credits' => '15', 'meta' => __('Headlines und SEO')],
|
||||
['name' => __('Headline-Booster'), 'credits' => '5', 'meta' => __('nur Headlines')],
|
||||
['name' => __('PM aus Stichworten generieren'), 'credits' => '25', 'meta' => __('Entwurf aus Briefing')],
|
||||
['name' => __('Übersetzung DE/EN'), 'credits' => '12', 'meta' => __('pro Sprachrichtung')],
|
||||
],
|
||||
],
|
||||
[
|
||||
'title' => __('Distribution'),
|
||||
'description' => __('Zusätzliche Formate und externe Reichweite für passende Meldungen.'),
|
||||
'services' => [
|
||||
['name' => __('PDF-Export mit Branding'), 'credits' => '2', 'meta' => __('für Weitergabe')],
|
||||
['name' => __('Social-Snippet-Generierung'), 'credits' => '3', 'meta' => __('Kurztexte')],
|
||||
['name' => __('Verteiler-Versand klein'), 'credits' => '39', 'meta' => __('branchenspezifisch')],
|
||||
['name' => __('Verteiler-Versand mittel'), 'credits' => '99', 'meta' => __('mehr Empfänger')],
|
||||
['name' => __('Verteiler-Versand groß'), 'credits' => '199', 'meta' => __('branchenübergreifend')],
|
||||
],
|
||||
],
|
||||
[
|
||||
'title' => __('Account & Profil'),
|
||||
'description' => __('Vertrauen, Wiedererkennung und zusätzliche Profilfunktionen.'),
|
||||
'services' => [
|
||||
['name' => __('Verifiziertes Firmenprofil'), 'credits' => '79', 'meta' => __('einmalig')],
|
||||
['name' => __('Custom Subdomain'), 'credits' => '49', 'meta' => __('pro Jahr')],
|
||||
['name' => __('Erweiterte Statistiken'), 'credits' => '15', 'meta' => __('pro Monat')],
|
||||
],
|
||||
],
|
||||
],
|
||||
'placements' => [
|
||||
['name' => __('Highlight Kategorie'), 'credits' => '15', 'duration' => __('3 Tage'), 'tier' => __('Standard'), 'score' => '30+'],
|
||||
['name' => __('Highlight Kategorie'), 'credits' => '30', 'duration' => __('7 Tage'), 'tier' => __('Standard'), 'score' => '30+'],
|
||||
['name' => __('Startseite-Highlight'), 'credits' => '39', 'duration' => __('24 h'), 'tier' => __('Geprüft'), 'score' => '60+'],
|
||||
['name' => __('Startseite-Highlight'), 'credits' => '89', 'duration' => __('3 Tage'), 'tier' => __('Geprüft'), 'score' => '60+'],
|
||||
['name' => __('Top-Slot Startseite'), 'credits' => '119', 'duration' => __('24 h'), 'tier' => __('Hochwertig'), 'score' => '80+'],
|
||||
['name' => __('Newsletter-Erwähnung'), 'credits' => '59', 'duration' => __('nächster Versand'), 'tier' => __('Geprüft'), 'score' => '60+'],
|
||||
['name' => __('Social-Share'), 'credits' => '25', 'duration' => __('offizieller Kanal'), 'tier' => __('Geprüft'), 'score' => '60+'],
|
||||
],
|
||||
'activeBookings' => [],
|
||||
'bookingHistory' => [],
|
||||
];
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<div class="space-y-8">
|
||||
|
|
@ -15,57 +105,286 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte
|
|||
<div class="flex items-center gap-3 mb-3 flex-wrap">
|
||||
<span class="badge hub dot">{{ __('User Backend') }}</span>
|
||||
<span class="eyebrow muted">{{ __('Mein Bereich · Finanzen') }}</span>
|
||||
<span class="badge warn">{{ __('In Vorbereitung') }}</span>
|
||||
<span class="badge hub">{{ __('Konzeptstand Mai 2026') }}</span>
|
||||
</div>
|
||||
<h1 class="text-[30px] font-bold tracking-[-0.6px] leading-[1.15] m-0 text-[color:var(--color-ink)]">
|
||||
{{ __('Buchungen & Add-ons') }}
|
||||
</h1>
|
||||
<p class="text-[13px] leading-[1.55] mt-2 m-0 max-w-[640px] text-[color:var(--color-ink-2)]">
|
||||
{{ __('Hier werden künftig gebuchte Leistungen, Add-ons und Erweiterungen für Ihre Firmen gebündelt.') }}
|
||||
{{ __('Der Marktplatz für Credit-Pakete, KI-Services, Platzierungen und Firmen-Add-ons. Die Preise folgen dem neuen Credit-Modell: 1 Credit entspricht dem Listenwert von 1 €.') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<flux:button size="sm" variant="ghost" icon="document-text" href="{{ route('me.invoices.index') }}" wire:navigate>
|
||||
{{ __('Rechnungen') }}
|
||||
</flux:button>
|
||||
<flux:button size="sm" variant="primary" icon="plus" disabled>
|
||||
{{ __('Credits kaufen') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="px-4 py-3 rounded-[5px] border text-[12.5px] flex items-start gap-3
|
||||
{{-- ============== CREDIT-ÜBERSICHT ============== --}}
|
||||
<section class="grid gap-4 lg:grid-cols-[1.2fr_0.8fr]">
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Credit-Stand') }}</span>
|
||||
<span class="badge ok dot">{{ __('Auto-Refill vorbereitet') }}</span>
|
||||
</div>
|
||||
<div class="p-5 grid gap-5 md:grid-cols-[0.8fr_1.2fr]">
|
||||
<div>
|
||||
<div class="text-[42px] font-bold tracking-[-1.2px] leading-none text-[color:var(--color-ink)]">
|
||||
{{ $creditSummary['total'] }}
|
||||
</div>
|
||||
<p class="text-[12.5px] text-[color:var(--color-ink-3)] mt-2 mb-0">
|
||||
{{ __('verfügbare Credits') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div class="rounded-[6px] border border-[color:var(--color-bg-rule)] p-4 bg-[color:var(--color-bg-subtle)]">
|
||||
<div class="text-[12px] text-[color:var(--color-ink-3)]">{{ __('Bonus-Credits') }}</div>
|
||||
<div class="text-[22px] font-semibold text-[color:var(--color-ink)]">{{ $creditSummary['bonus'] }}</div>
|
||||
<div class="text-[11.5px] text-[color:var(--color-ink-3)]">{{ __('monatlich verfallend') }}</div>
|
||||
</div>
|
||||
<div class="rounded-[6px] border border-[color:var(--color-bg-rule)] p-4 bg-[color:var(--color-bg-subtle)]">
|
||||
<div class="text-[12px] text-[color:var(--color-ink-3)]">{{ __('Gekaufte Credits') }}</div>
|
||||
<div class="text-[22px] font-semibold text-[color:var(--color-ink)]">{{ $creditSummary['paid'] }}</div>
|
||||
<div class="text-[11.5px] text-[color:var(--color-ink-3)]">{{ __('24 Monate gültig') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2 px-4 py-3 rounded-[5px] border text-[12.5px] flex items-start gap-3
|
||||
bg-[color:var(--color-hub-soft)] border-[color:var(--color-hub-soft-2)] text-[color:var(--color-ink-2)]">
|
||||
<flux:icon.information-circle class="size-[16px] flex-shrink-0 mt-0.5 text-[color:var(--color-hub)]" />
|
||||
<div class="flex-1">
|
||||
{{ __('Der Bereich ist bereits in der Navigation vorbereitet. Buchbare Add-ons werden aktiviert, sobald das Preismodell und die Zahlungslogik final sind.') }}
|
||||
{{ $creditSummary['validity'] }}
|
||||
{{ __('Für spätere Checkouts ist Auto-Refill :threshold vorgesehen.', ['threshold' => $creditSummary['auto_refill']]) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Firmenbezogene Add-ons') }}</span>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<p class="text-[12.5px] text-[color:var(--color-ink-3)] m-0">
|
||||
{{ __('Zum Beispiel zusätzliche Sichtbarkeit, Verifizierung oder besondere Platzierungen.') }}
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Credits & Tarif') }}</span>
|
||||
<span class="section-eyebrow">{{ __('Aktueller Tarif') }}</span>
|
||||
<span class="badge hub">{{ $currentPlan['name'] }}</span>
|
||||
</div>
|
||||
<div class="p-5 space-y-4">
|
||||
<div>
|
||||
<div class="text-[28px] font-bold tracking-[-0.7px] text-[color:var(--color-ink)]">
|
||||
{{ $currentPlan['price'] }}
|
||||
</div>
|
||||
<p class="text-[12.5px] text-[color:var(--color-ink-3)] mt-1 mb-0">
|
||||
{{ __('inkl. :credits Bonus-Credits und :pms', [
|
||||
'credits' => $currentPlan['bonus_credits'],
|
||||
'pms' => $currentPlan['press_releases'],
|
||||
]) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-[6px] border border-[color:var(--color-bg-rule)] p-4">
|
||||
<div class="text-[12px] font-semibold text-[color:var(--color-ink)] mb-1">
|
||||
{{ __('Nächster sinnvoller Schritt') }}
|
||||
</div>
|
||||
<p class="text-[12.5px] text-[color:var(--color-ink-3)] m-0">
|
||||
{{ __('Bei mehreren PMs mit KI-Optimierung oder Platzierungen ergänzt das Standard-Paket die monatlichen Bonus-Credits am saubersten.') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
{{-- ============== CREDIT-PAKETE ============== --}}
|
||||
<article class="panel overflow-hidden">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Credit-Pakete') }}</span>
|
||||
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">{{ __('Volumenrabatt nach Paketgröße') }}</span>
|
||||
</div>
|
||||
<flux:table>
|
||||
<flux:table.columns>
|
||||
<flux:table.column>{{ __('Paket') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Credits') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Preis') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Effektiv/Credit') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Ersparnis') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Aktion') }}</flux:table.column>
|
||||
</flux:table.columns>
|
||||
|
||||
@foreach ($creditPackages as $package)
|
||||
<flux:table.row wire:key="credit-package-{{ $package['name'] }}">
|
||||
<flux:table.cell>
|
||||
<span class="text-[13px] font-semibold text-[color:var(--color-ink)]">{{ $package['name'] }}</span>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>{{ number_format($package['credits'], 0, ',', '.') }}</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<span class="font-semibold text-[color:var(--color-ink)]">{{ $package['price'] }}</span>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>{{ $package['rate'] }}</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
@if ($package['saving'])
|
||||
<span class="badge ok">{{ $package['saving'] }}</span>
|
||||
@else
|
||||
<span class="text-[12px] text-[color:var(--color-ink-3)]">–</span>
|
||||
@endif
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<flux:button size="sm" variant="ghost" disabled>
|
||||
{{ __('Kaufen') }}
|
||||
</flux:button>
|
||||
</flux:table.cell>
|
||||
</flux:table.row>
|
||||
@endforeach
|
||||
</flux:table>
|
||||
</article>
|
||||
|
||||
{{-- ============== PLATZIERUNGEN ============== --}}
|
||||
<section class="space-y-4">
|
||||
<div>
|
||||
<span class="section-eyebrow">{{ __('Boost & Platzierungen') }}</span>
|
||||
<h2 class="text-[22px] font-bold tracking-[-0.4px] mt-1 mb-1 text-[color:var(--color-ink)]">
|
||||
{{ __('Sichtbarkeit buchen, wenn die Score-Stufe passt') }}
|
||||
</h2>
|
||||
<p class="text-[12.5px] text-[color:var(--color-ink-3)] max-w-[760px] m-0">
|
||||
{{ __('Platzierungen bleiben an Qualitätsstufen gekoppelt: Standard reicht für Kategorie-Highlights, Geprüft für Startseite/Newsletter/Social und Hochwertig für den Top-Slot.') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
@foreach ($placements as $placement)
|
||||
<article class="panel">
|
||||
<div class="p-5 space-y-4">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-10 h-10 rounded-[6px] flex items-center justify-center bg-[color:var(--color-hub-soft)] text-[color:var(--color-hub)]">
|
||||
<flux:icon.megaphone class="size-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-[14px] font-semibold text-[color:var(--color-ink)] m-0">
|
||||
{{ $placement['name'] }}
|
||||
</h3>
|
||||
<p class="text-[12px] text-[color:var(--color-ink-3)] mt-1 mb-0">
|
||||
{{ $placement['duration'] }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-[22px] font-bold text-[color:var(--color-ink)]">{{ $placement['credits'] }}</div>
|
||||
<div class="text-[11px] text-[color:var(--color-ink-3)]">{{ __('Credits') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-3 rounded-[6px] border border-[color:var(--color-bg-rule)] p-3">
|
||||
<div>
|
||||
<div class="text-[11.5px] text-[color:var(--color-ink-3)]">{{ __('Mindeststufe') }}</div>
|
||||
<div class="text-[13px] font-semibold text-[color:var(--color-ink)]">{{ $placement['tier'] }}</div>
|
||||
</div>
|
||||
<flux:tooltip content="{{ __('Interner Score-Schwellenwert: :score', ['score' => $placement['score']]) }}">
|
||||
<span class="badge hub">{{ __('Score :score', ['score' => $placement['score']]) }}</span>
|
||||
</flux:tooltip>
|
||||
</div>
|
||||
|
||||
<flux:button size="sm" variant="primary" class="w-full" disabled>
|
||||
{{ __('Buchung vorbereiten') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</article>
|
||||
@endforeach
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{-- ============== SERVICE-MARKTPLATZ ============== --}}
|
||||
<section class="space-y-4">
|
||||
<div>
|
||||
<span class="section-eyebrow">{{ __('Add-on-Marktplatz') }}</span>
|
||||
<h2 class="text-[22px] font-bold tracking-[-0.4px] mt-1 mb-1 text-[color:var(--color-ink)]">
|
||||
{{ __('Buchbare Services nach Kategorie') }}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 xl:grid-cols-2">
|
||||
@foreach ($serviceGroups as $group)
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<div class="flex items-center gap-2">
|
||||
<flux:icon.sparkles class="size-4 text-[color:var(--color-hub)]" />
|
||||
<span class="section-eyebrow">{{ $group['title'] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<p class="text-[12.5px] text-[color:var(--color-ink-3)] m-0">
|
||||
{{ __('Tarif- und Credit-Informationen folgen, sobald das neue Preismodell live ist.') }}
|
||||
<p class="text-[12.5px] text-[color:var(--color-ink-3)] mt-0 mb-4">
|
||||
{{ $group['description'] }}
|
||||
</p>
|
||||
<div class="divide-y divide-[color:var(--color-bg-rule)]">
|
||||
@foreach ($group['services'] as $service)
|
||||
<div class="py-3 first:pt-0 last:pb-0 flex items-center justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<div class="text-[13px] font-semibold text-[color:var(--color-ink)]">{{ $service['name'] }}</div>
|
||||
<div class="text-[11.5px] text-[color:var(--color-ink-3)]">{{ $service['meta'] }}</div>
|
||||
</div>
|
||||
<div class="text-right flex-shrink-0">
|
||||
<div class="text-[15px] font-bold text-[color:var(--color-ink)]">{{ $service['credits'] }}</div>
|
||||
<div class="text-[10.5px] uppercase tracking-[0.08em] text-[color:var(--color-ink-3)]">{{ __('Credits') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
@endforeach
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{-- ============== AKTIVE BUCHUNGEN / VERLAUF ============== --}}
|
||||
<section class="grid gap-4 lg:grid-cols-2">
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Aktive Buchungen') }}</span>
|
||||
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">{{ __('läuft aktuell') }}</span>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
@forelse ($activeBookings as $booking)
|
||||
<div>{{ $booking }}</div>
|
||||
@empty
|
||||
<div class="flex flex-col items-center justify-center px-4 py-10 text-center">
|
||||
<div class="w-14 h-14 rounded-[6px] flex items-center justify-center mb-3
|
||||
bg-[color:var(--color-hub-soft)] border border-[color:var(--color-hub-soft-2)] text-[color:var(--color-hub)]">
|
||||
<flux:icon.calendar-days class="size-6" />
|
||||
</div>
|
||||
<div class="text-[14px] font-semibold text-[color:var(--color-ink)] mb-1">
|
||||
{{ __('Noch keine aktiven Buchungen') }}
|
||||
</div>
|
||||
<p class="text-[12px] text-[color:var(--color-ink-3)] max-w-md m-0">
|
||||
{{ __('Gebuchte Highlights, Newsletter-Platzierungen oder Add-ons erscheinen hier mit Laufzeit und zugehöriger Firma.') }}
|
||||
</p>
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Zahlungsarten') }}</span>
|
||||
<span class="section-eyebrow">{{ __('Verlauf') }}</span>
|
||||
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">{{ __('verbrauchte Credits') }}</span>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<p class="text-[12.5px] text-[color:var(--color-ink-3)] m-0">
|
||||
{{ __('Zahlungsarten werden später unter Finanzen eingebunden.') }}
|
||||
@forelse ($bookingHistory as $booking)
|
||||
<div>{{ $booking }}</div>
|
||||
@empty
|
||||
<div class="flex flex-col items-center justify-center px-4 py-10 text-center">
|
||||
<div class="w-14 h-14 rounded-[6px] flex items-center justify-center mb-3
|
||||
bg-[color:var(--color-bg-subtle)] border border-[color:var(--color-bg-rule)] text-[color:var(--color-ink-3)]">
|
||||
<flux:icon.clock class="size-6" />
|
||||
</div>
|
||||
<div class="text-[14px] font-semibold text-[color:var(--color-ink)] mb-1">
|
||||
{{ __('Noch kein Buchungsverlauf') }}
|
||||
</div>
|
||||
<p class="text-[12px] text-[color:var(--color-ink-3)] max-w-md m-0">
|
||||
{{ __('Nach dem ersten Checkout werden Verbrauch, Rechnungsbezug und betroffene Pressemitteilung hier nachvollziehbar.') }}
|
||||
</p>
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -28,10 +28,16 @@ new class extends Component
|
|||
public function with(CustomerCompanyContext $context): array
|
||||
{
|
||||
$user = auth()->user();
|
||||
$selectedCompanyId = $context->selectedCompanyId($user);
|
||||
$companies = $context->switcherCompaniesFor($user, $selectedCompanyId, 51);
|
||||
$visibleCompanies = $companies->take(50)->values();
|
||||
|
||||
return [
|
||||
'companies' => $context->companiesFor($user),
|
||||
'selectedCompany' => $context->selectedCompany($user),
|
||||
'companies' => $visibleCompanies,
|
||||
'hasMoreCompanies' => $companies->count() > 50,
|
||||
'selectedCompany' => $selectedCompanyId === null
|
||||
? null
|
||||
: $visibleCompanies->firstWhere('id', $selectedCompanyId),
|
||||
'context' => $context,
|
||||
'user' => $user,
|
||||
];
|
||||
|
|
@ -55,6 +61,9 @@ new class extends Component
|
|||
{{ $company->name }} · {{ $context->roleLabelFor($company, $user) }}
|
||||
</option>
|
||||
@endforeach
|
||||
@if ($hasMoreCompanies)
|
||||
<option value="all" disabled>{{ __('Weitere Firmen über „Firmen" öffnen') }}</option>
|
||||
@endif
|
||||
</flux:select>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -21,16 +21,28 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
|
|||
$user = auth()->user();
|
||||
$context = app(CustomerCompanyContext::class);
|
||||
$selectedCompanyId = $context->selectedCompanyId($user);
|
||||
$selectedCompany = $context->selectedCompany($user);
|
||||
$selectedCompany = $selectedCompanyId === null
|
||||
? null
|
||||
: $context->findFor($user, $selectedCompanyId);
|
||||
|
||||
$pressReleaseQuery = PressRelease::withoutGlobalScopes()
|
||||
->where('user_id', $user->id)
|
||||
->when($selectedCompanyId !== null, fn ($query) => $query->where('company_id', $selectedCompanyId));
|
||||
|
||||
$myPRs = (clone $pressReleaseQuery)
|
||||
->selectRaw('status, count(*) as count')
|
||||
->groupBy('status')
|
||||
->pluck('count', 'status');
|
||||
$now = Carbon::now();
|
||||
$currentStart = $now->copy()->startOfMonth();
|
||||
$previousStart = $now->copy()->subMonthNoOverflow()->startOfMonth();
|
||||
$previousEnd = $now->copy()->subMonthNoOverflow()->endOfMonth();
|
||||
|
||||
$stats = (clone $pressReleaseQuery)
|
||||
->toBase()
|
||||
->selectRaw('COUNT(*) as total')
|
||||
->selectRaw('SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as published', [PressReleaseStatus::Published->value])
|
||||
->selectRaw('SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as review', [PressReleaseStatus::Review->value])
|
||||
->selectRaw('SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as draft', [PressReleaseStatus::Draft->value])
|
||||
->selectRaw('SUM(CASE WHEN created_at >= ? THEN 1 ELSE 0 END) as current_month', [$currentStart])
|
||||
->selectRaw('SUM(CASE WHEN created_at BETWEEN ? AND ? THEN 1 ELSE 0 END) as previous_month', [$previousStart, $previousEnd])
|
||||
->first();
|
||||
|
||||
$recent = (clone $pressReleaseQuery)
|
||||
->with('company:id,name')
|
||||
|
|
@ -45,11 +57,11 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
|
|||
'user' => $user,
|
||||
'selectedCompany' => $selectedCompany,
|
||||
'stats' => [
|
||||
'total' => (clone $pressReleaseQuery)->count(),
|
||||
'published' => $myPRs->get('published', 0),
|
||||
'review' => $myPRs->get('review', 0),
|
||||
'draft' => $myPRs->get('draft', 0),
|
||||
'deltaMonth' => $this->totalDeltaToPreviousMonth(clone $pressReleaseQuery),
|
||||
'total' => (int) ($stats->total ?? 0),
|
||||
'published' => (int) ($stats->published ?? 0),
|
||||
'review' => (int) ($stats->review ?? 0),
|
||||
'draft' => (int) ($stats->draft ?? 0),
|
||||
'deltaMonth' => (int) ($stats->current_month ?? 0) - (int) ($stats->previous_month ?? 0),
|
||||
],
|
||||
'profileCompleteness' => $this->profileCompleteness($profile),
|
||||
'billingCompleteness' => $this->billingCompleteness($billingAddress),
|
||||
|
|
@ -61,7 +73,8 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
|
|||
$pressReleaseQuery,
|
||||
),
|
||||
'recent' => $recent,
|
||||
'companies' => $context->companiesFor($user),
|
||||
'companies' => $context->latestCompaniesFor($user),
|
||||
'companiesTotal' => $context->companyCountFor($user),
|
||||
'bridgeStatus' => [
|
||||
/* Heute hardcoded — perspektivisch aus echtem Sync-Service. */
|
||||
'presseecho' => ['state' => 'connected', 'subline' => __('Archiv · Branchen-Tiefe')],
|
||||
|
|
@ -110,27 +123,6 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
|
|||
return (int) round(($filled / count($fields)) * 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vergleicht PRs im aktuellen Monat mit dem Vormonat (Differenz, Vorzeichen mit Pfeil im View).
|
||||
*/
|
||||
private function totalDeltaToPreviousMonth(Builder $pressReleaseQuery): int
|
||||
{
|
||||
$now = Carbon::now();
|
||||
$currentStart = $now->copy()->startOfMonth();
|
||||
$previousStart = $now->copy()->subMonthNoOverflow()->startOfMonth();
|
||||
$previousEnd = $now->copy()->subMonthNoOverflow()->endOfMonth();
|
||||
|
||||
$currentCount = (clone $pressReleaseQuery)
|
||||
->where('created_at', '>=', $currentStart)
|
||||
->count();
|
||||
|
||||
$previousCount = (clone $pressReleaseQuery)
|
||||
->whereBetween('created_at', [$previousStart, $previousEnd])
|
||||
->count();
|
||||
|
||||
return $currentCount - $previousCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{icon: string, title: string, description: string, href: string, action: string, percent?: int}>
|
||||
*/
|
||||
|
|
@ -234,7 +226,7 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
|
|||
{{ __('Aktive Firma:') }} <strong class="font-semibold">{{ $selectedCompany->name }}</strong>
|
||||
</span>
|
||||
@else
|
||||
<a href="{{ route('me.profile') }}#firmen" wire:navigate
|
||||
<a href="{{ route('me.press-kits.index') }}" wire:navigate
|
||||
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-[4px] text-[12px] font-semibold whitespace-nowrap bg-[color:var(--color-warn-soft)] border border-[color:var(--color-warn)]/30 text-[color:var(--color-accent-deep)] hover:bg-[color:var(--color-warn-soft)]/80 transition">
|
||||
<flux:icon.exclamation-triangle class="size-[13px] flex-shrink-0" />
|
||||
{{ __('Keine Firma zugeordnet') }}
|
||||
|
|
@ -448,11 +440,11 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
|
|||
<span class="section-eyebrow">{{ __('Meine Firmen') }}</span>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="badge hub" style="font-size:9.5px;padding:1px 6px;">
|
||||
{{ $companies->count() }} {{ __('zugeordnet') }}
|
||||
{{ $companiesTotal }} {{ __('zugeordnet') }}
|
||||
</span>
|
||||
<a href="{{ route('me.profile') }}" wire:navigate
|
||||
<a href="{{ route('me.press-kits.index') }}" wire:navigate
|
||||
class="text-[12px] font-semibold text-[color:var(--color-hub)] hover:underline underline-offset-[3px] decoration-[color:var(--color-hub)]/30">
|
||||
{{ __('Profil & Firma verwalten') }} →
|
||||
{{ __('Alle Firmen anzeigen') }} →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -483,6 +475,15 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
|
|||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@if ($companiesTotal > $companies->count())
|
||||
<div class="mt-4 text-[11.5px] leading-[1.5] text-[color:var(--color-ink-3)]">
|
||||
{{ __('Die zehn neuesten Firmen werden hier als Vorschau angezeigt.') }}
|
||||
<a href="{{ route('me.press-kits.index') }}" wire:navigate
|
||||
class="font-semibold text-[color:var(--color-hub)] hover:underline underline-offset-[3px] decoration-[color:var(--color-hub)]/30">
|
||||
{{ __('Zur vollständigen Firmenliste') }} →
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
@else
|
||||
<div class="grid gap-3 grid-cols-1 md:grid-cols-2">
|
||||
<div class="relative rounded-[5px] p-5 transition-colors
|
||||
|
|
@ -511,11 +512,11 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
|
|||
bg-[color:var(--color-bg-elev)] border border-[color:var(--color-bg-rule)]">
|
||||
<div class="eyebrow muted mb-2">{{ __('Hinweis') }}</div>
|
||||
<div class="text-[13px] leading-[1.55] m-0 text-[color:var(--color-ink-2)]">
|
||||
{{ __('Keine Firmen zugeordnet. Wenn hier eine Firma fehlen sollte, prüfen Sie bitte Ihr Profil oder wenden Sie sich an den Support.') }}
|
||||
{{ __('Keine Firmen zugeordnet. Wenn hier eine Firma fehlen sollte, prüfen Sie bitte die Firmenverwaltung oder wenden Sie sich an den Support.') }}
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<flux:button size="sm" variant="ghost" href="{{ route('me.profile') }}" wire:navigate>
|
||||
{{ __('Profil prüfen') }}
|
||||
<flux:button size="sm" variant="ghost" href="{{ route('me.press-kits.index') }}" wire:navigate>
|
||||
{{ __('Firmen öffnen') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -224,7 +224,7 @@ new #[Layout('components.layouts.app'), Title('Rechnungen')] class extends Compo
|
|||
@endforelse
|
||||
</flux:table>
|
||||
<div class="border-t border-[color:var(--color-bg-rule)] p-4">
|
||||
{{ $invoices->links() }}
|
||||
{{ $invoices->links('components.portal.pagination') }}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
|
|
|||
218
resources/views/livewire/customer/press-kits/create.blade.php
Normal file
218
resources/views/livewire/customer/press-kits/create.blade.php
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\CompanyType;
|
||||
use App\Enums\Portal;
|
||||
use App\Models\Company;
|
||||
use Flux\Flux;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Volt\Component;
|
||||
|
||||
new #[Layout('components.layouts.app'), Title('Neue Firma anlegen')] class extends Component
|
||||
{
|
||||
public string $name = '';
|
||||
|
||||
public string $portal = '';
|
||||
|
||||
public string $type = '';
|
||||
|
||||
public string $address = '';
|
||||
|
||||
public string $email = '';
|
||||
|
||||
public string $phone = '';
|
||||
|
||||
public string $website = '';
|
||||
|
||||
public string $countryCode = 'DE';
|
||||
|
||||
public bool $disableFooterCode = false;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->type = CompanyType::Company->value;
|
||||
$this->countryCode = (string) config('countries.default', 'DE');
|
||||
}
|
||||
|
||||
public function save(): void
|
||||
{
|
||||
try {
|
||||
$validated = $this->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'portal' => ['required', Rule::in([
|
||||
Portal::Presseecho->value,
|
||||
Portal::Businessportal24->value,
|
||||
Portal::Both->value,
|
||||
])],
|
||||
'type' => ['required', Rule::in([CompanyType::Company->value, CompanyType::Agency->value])],
|
||||
'address' => ['nullable', 'string', 'max:1000'],
|
||||
'email' => ['nullable', 'email', 'max:190'],
|
||||
'phone' => ['nullable', 'string', 'max:40'],
|
||||
'website' => ['nullable', 'url', 'max:190'],
|
||||
'countryCode' => ['nullable', 'string', 'size:2', Rule::in(array_keys((array) config('countries.items', [])))],
|
||||
]);
|
||||
} catch (ValidationException $e) {
|
||||
$count = array_sum(array_map('count', $e->errors()));
|
||||
Flux::toast(
|
||||
heading: __('Bitte Eingaben prüfen'),
|
||||
text: $count > 1
|
||||
? __(':count Felder benötigen deine Aufmerksamkeit.', ['count' => $count])
|
||||
: __('Ein Feld benötigt deine Aufmerksamkeit.'),
|
||||
variant: 'danger',
|
||||
duration: 6000,
|
||||
);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
$company = new Company([
|
||||
'portal' => $validated['portal'],
|
||||
'owner_user_id' => $user->id,
|
||||
'type' => $validated['type'],
|
||||
'name' => $validated['name'],
|
||||
'address' => $validated['address'] ?: null,
|
||||
'country_code' => $validated['countryCode'] ?: null,
|
||||
'email' => $validated['email'] ?: null,
|
||||
'phone' => $validated['phone'] ?: null,
|
||||
'website' => $validated['website'] ?: null,
|
||||
'is_active' => true,
|
||||
'disable_footer_code' => $this->disableFooterCode,
|
||||
]);
|
||||
|
||||
$company->slug = $company->generateUniqueSlug($validated['name'], [
|
||||
'portal' => $validated['portal'],
|
||||
]);
|
||||
|
||||
$company->save();
|
||||
|
||||
$user->companies()->syncWithoutDetaching([
|
||||
$company->id => ['role' => 'owner'],
|
||||
]);
|
||||
|
||||
Flux::toast(
|
||||
heading: __('Firma angelegt'),
|
||||
text: __('„:name" wurde angelegt und steht sofort zur Verfügung.', ['name' => $company->name]),
|
||||
variant: 'success',
|
||||
);
|
||||
|
||||
$this->redirect(route('me.press-kits.show', $company->id), navigate: true);
|
||||
}
|
||||
|
||||
public function with(): array
|
||||
{
|
||||
return [
|
||||
'portals' => [
|
||||
Portal::Presseecho->value => Portal::Presseecho->label(),
|
||||
Portal::Businessportal24->value => Portal::Businessportal24->label(),
|
||||
Portal::Both->value => Portal::Both->label(),
|
||||
],
|
||||
'types' => [
|
||||
CompanyType::Company->value => CompanyType::Company->label(),
|
||||
CompanyType::Agency->value => CompanyType::Agency->label(),
|
||||
],
|
||||
'countries' => (array) config('countries.items', []),
|
||||
];
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<div class="space-y-6">
|
||||
{{-- ============== PAGE HEADER ============== --}}
|
||||
<header class="grid items-end gap-8" style="grid-template-columns:1fr auto;">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-3 mb-3 flex-nowrap whitespace-nowrap">
|
||||
<span class="badge hub dot">{{ __('User Backend') }}</span>
|
||||
<span class="eyebrow muted">{{ __('Mein Bereich · Firmen · Anlegen') }}</span>
|
||||
</div>
|
||||
<h1 class="text-[30px] font-bold tracking-[-0.6px] leading-[1.15] m-0 text-[color:var(--color-ink)]">
|
||||
{{ __('Neue Firma anlegen') }}
|
||||
</h1>
|
||||
<p class="text-[12.5px] leading-[1.55] mt-2 m-0 max-w-[640px] text-[color:var(--color-ink-3)]">
|
||||
{{ __('Lege Stammdaten und Portal-Zuordnung an. Die Firma steht sofort zur Verfügung — die redaktionelle Prüfung erfolgt erst bei der ersten Pressemitteilung.') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<flux:button variant="ghost" icon="arrow-left" href="{{ route('me.press-kits.index') }}" wire:navigate>
|
||||
{{ __('Zurück zur Liste') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<form wire:submit.prevent="save" class="space-y-6">
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Stammdaten') }}</span>
|
||||
</div>
|
||||
<div class="p-5 grid gap-4 sm:grid-cols-2">
|
||||
<flux:field class="sm:col-span-2">
|
||||
<flux:input wire:model="name" :label="__('Firmenname')" required autofocus />
|
||||
<flux:error name="name" />
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:select wire:model="portal" :label="__('Portal')" :placeholder="__('Bitte wählen…')" required>
|
||||
@foreach ($portals as $value => $label)
|
||||
<flux:select.option value="{{ $value }}">{{ $label }}</flux:select.option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
<flux:error name="portal" />
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:select wire:model="type" :label="__('Typ')" required>
|
||||
@foreach ($types as $value => $label)
|
||||
<flux:select.option value="{{ $value }}">{{ $label }}</flux:select.option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
<flux:error name="type" />
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:input wire:model="email" :label="__('E-Mail')" type="email" />
|
||||
<flux:error name="email" />
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:input wire:model="phone" :label="__('Telefon')" />
|
||||
<flux:error name="phone" />
|
||||
</flux:field>
|
||||
|
||||
<flux:field class="sm:col-span-2">
|
||||
<flux:input wire:model="website" :label="__('Website')" placeholder="https://..." />
|
||||
<flux:error name="website" />
|
||||
</flux:field>
|
||||
|
||||
<flux:field class="sm:col-span-2">
|
||||
<flux:textarea wire:model="address" :label="__('Adresse')" rows="3" />
|
||||
<flux:error name="address" />
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:select wire:model="countryCode" :label="__('Land')">
|
||||
@foreach ($countries as $code => $countryName)
|
||||
<flux:select.option value="{{ $code }}">{{ $countryName }}</flux:select.option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
<flux:error name="countryCode" />
|
||||
</flux:field>
|
||||
|
||||
<flux:field class="sm:col-span-2">
|
||||
<flux:checkbox wire:model="disableFooterCode" :label="__('Footer-Code deaktivieren (z. B. wenn die Firma keine Quellenangabe haben möchte)')" />
|
||||
</flux:field>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<flux:button variant="ghost" href="{{ route('me.press-kits.index') }}" wire:navigate>
|
||||
{{ __('Abbrechen') }}
|
||||
</flux:button>
|
||||
<flux:button type="submit" variant="primary" icon="check">
|
||||
{{ __('Firma anlegen') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
|
@ -1,8 +1,15 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Company;
|
||||
use App\Models\User;
|
||||
use App\Services\Customer\CustomerCompanyContext;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Attributes\Url;
|
||||
use Livewire\Volt\Component;
|
||||
use Livewire\WithPagination;
|
||||
|
||||
|
|
@ -12,39 +19,402 @@ new #[Layout('components.layouts.app'), Title('Meine Firmen')] class extends Com
|
|||
|
||||
public string $search = '';
|
||||
|
||||
#[Url(as: 'view', except: 'all')]
|
||||
public string $savedView = 'all';
|
||||
|
||||
#[Url(as: 'portal', except: '')]
|
||||
public string $portalFilter = '';
|
||||
|
||||
#[Url(as: 'role', except: 'all')]
|
||||
public string $roleFilter = 'all';
|
||||
|
||||
#[Url(as: 'mode', except: 'cards')]
|
||||
public string $viewMode = 'cards';
|
||||
|
||||
public function updatedSearch(): void
|
||||
{
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function with(): array
|
||||
public function setSavedView(string $view): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
$context = app(CustomerCompanyContext::class);
|
||||
$allowed = ['all', 'active', 'drafts', 'inactive', 'shared'];
|
||||
$this->savedView = in_array($view, $allowed, true) ? $view : 'all';
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
$pressKits = $context->accessibleCompanyQuery($user)
|
||||
->withCount(['contacts', 'pressReleases'])
|
||||
->when(filled($this->search), function ($query): void {
|
||||
public function setPortalFilter(string $portal): void
|
||||
{
|
||||
$allowed = ['', 'presseecho', 'businessportal24'];
|
||||
$this->portalFilter = in_array($portal, $allowed, true) ? $portal : '';
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function setRoleFilter(string $role): void
|
||||
{
|
||||
$allowed = ['all', 'owner', 'responsible', 'member'];
|
||||
$this->roleFilter = in_array($role, $allowed, true) ? $role : 'all';
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function setViewMode(string $mode): void
|
||||
{
|
||||
$this->viewMode = $mode === 'list' ? 'list' : 'cards';
|
||||
}
|
||||
|
||||
public function resetFilters(): void
|
||||
{
|
||||
$this->search = '';
|
||||
$this->savedView = 'all';
|
||||
$this->portalFilter = '';
|
||||
$this->roleFilter = 'all';
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Builder<Company>
|
||||
*/
|
||||
private function baseQuery(User $user): Builder
|
||||
{
|
||||
return app(CustomerCompanyContext::class)
|
||||
->accessibleCompanyQuery($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wendet die "Saved View"-Logik auf eine Query an.
|
||||
*
|
||||
* @param Builder<Company> $query
|
||||
*/
|
||||
private function applySavedView(Builder $query, User $user, string $view): void
|
||||
{
|
||||
match ($view) {
|
||||
'active' => $query->where('is_active', true),
|
||||
'inactive' => $query->where('is_active', false),
|
||||
'drafts' => $query->whereRaw('1 = 0'),
|
||||
'shared' => $query->where('owner_user_id', '!=', $user->id),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<Company> $query
|
||||
*/
|
||||
private function applySharedFilters(Builder $query): void
|
||||
{
|
||||
if (filled($this->portalFilter)) {
|
||||
$query->where(function ($query) {
|
||||
$query->where('portal', $this->portalFilter)
|
||||
->orWhere('portal', 'both');
|
||||
});
|
||||
}
|
||||
|
||||
if (filled($this->search)) {
|
||||
$search = trim($this->search);
|
||||
|
||||
$query->where(function ($query) use ($search): void {
|
||||
$query->where('name', 'like', '%'.$search.'%')
|
||||
->orWhere('email', 'like', '%'.$search.'%')
|
||||
->orWhere('address', 'like', '%'.$search.'%')
|
||||
->orWhere('slug', 'like', '%'.$search.'%');
|
||||
});
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<Company> $query
|
||||
*/
|
||||
private function applyRoleFilter(Builder $query, User $user, string $role): void
|
||||
{
|
||||
if ($role === 'all') {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($role === 'owner') {
|
||||
$query->where('owner_user_id', $user->id);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$query->where('owner_user_id', '!=', $user->id)
|
||||
->whereHas('users', function ($query) use ($user, $role): void {
|
||||
$query->where('users.id', $user->id)
|
||||
->where('company_user.role', $role);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sammelt alle Counter-Werte in genau drei Queries:
|
||||
* 1) aggregiertes COUNT/SUM CASE auf companies
|
||||
* 2) COUNT auf press_releases
|
||||
* 3) COUNT auf contacts
|
||||
*
|
||||
* @return array{
|
||||
* counters: array{companies: int, active: int, press_releases: int, contacts: int},
|
||||
* saved_views: array{all: int, active: int, drafts: int, inactive: int, shared: int},
|
||||
* }
|
||||
*/
|
||||
private function buildAggregateCounts(User $user): array
|
||||
{
|
||||
$row = $this->baseQuery($user)
|
||||
->selectRaw(
|
||||
'COUNT(*) as total_companies,
|
||||
SUM(CASE WHEN is_active = 1 THEN 1 ELSE 0 END) as active_companies,
|
||||
SUM(CASE WHEN is_active = 0 THEN 1 ELSE 0 END) as inactive_companies,
|
||||
SUM(CASE WHEN owner_user_id <> ? THEN 1 ELSE 0 END) as shared_companies',
|
||||
[$user->id]
|
||||
)
|
||||
->first();
|
||||
|
||||
$totalCompanies = (int) ($row->total_companies ?? 0);
|
||||
$activeCompanies = (int) ($row->active_companies ?? 0);
|
||||
$inactiveCompanies = (int) ($row->inactive_companies ?? 0);
|
||||
$sharedCompanies = (int) ($row->shared_companies ?? 0);
|
||||
|
||||
if ($totalCompanies === 0) {
|
||||
return [
|
||||
'counters' => [
|
||||
'companies' => 0,
|
||||
'active' => 0,
|
||||
'press_releases' => 0,
|
||||
'contacts' => 0,
|
||||
],
|
||||
'saved_views' => [
|
||||
'all' => 0,
|
||||
'active' => 0,
|
||||
'drafts' => 0,
|
||||
'inactive' => 0,
|
||||
'shared' => 0,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
$companyIdsQuery = $this->baseQuery($user)->select('companies.id');
|
||||
|
||||
$pressReleaseCount = (int) \App\Models\PressRelease::query()
|
||||
->withoutGlobalScopes()
|
||||
->whereIn('company_id', $companyIdsQuery)
|
||||
->count();
|
||||
|
||||
$contactsCount = (int) \App\Models\Contact::query()
|
||||
->withoutGlobalScopes()
|
||||
->whereIn('company_id', $companyIdsQuery)
|
||||
->count();
|
||||
|
||||
return [
|
||||
'counters' => [
|
||||
'companies' => $totalCompanies,
|
||||
'active' => $activeCompanies,
|
||||
'press_releases' => $pressReleaseCount,
|
||||
'contacts' => $contactsCount,
|
||||
],
|
||||
'saved_views' => [
|
||||
'all' => $totalCompanies,
|
||||
'active' => $activeCompanies,
|
||||
'drafts' => 0,
|
||||
'inactive' => $inactiveCompanies,
|
||||
'shared' => $sharedCompanies,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Bestimmt deterministisch einen Logo-Token (lg-*) anhand der Company-Id.
|
||||
*/
|
||||
public function logoVariant(Company $company): string
|
||||
{
|
||||
$variants = ['lg-brew', 'lg-mv', 'lg-soft', 'lg-warm'];
|
||||
|
||||
if (blank($company->name)) {
|
||||
return 'lg-blank';
|
||||
}
|
||||
|
||||
return $variants[$company->id % count($variants)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialen aus dem Firmennamen (max. 2 Zeichen, Großbuchstaben).
|
||||
*/
|
||||
public function logoInitials(Company $company): string
|
||||
{
|
||||
$name = trim((string) $company->name);
|
||||
if (blank($name)) {
|
||||
return '–';
|
||||
}
|
||||
|
||||
$words = preg_split('/\s+/u', $name) ?: [];
|
||||
$letters = '';
|
||||
foreach ($words as $word) {
|
||||
$first = mb_substr($word, 0, 1);
|
||||
if ($first !== '') {
|
||||
$letters .= $first;
|
||||
}
|
||||
if (mb_strlen($letters) >= 2) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($letters === '') {
|
||||
$letters = mb_substr($name, 0, 2);
|
||||
}
|
||||
|
||||
return mb_strtoupper($letters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert eine kompakte Meta-Line: Stadt · Typ.
|
||||
*/
|
||||
public function metaLine(Company $company): string
|
||||
{
|
||||
$parts = [];
|
||||
|
||||
$address = trim((string) ($company->address ?? ''));
|
||||
if (filled($address)) {
|
||||
$lastLine = collect(preg_split('/\r?\n/', $address))
|
||||
->map(fn ($line) => trim((string) $line))
|
||||
->filter()
|
||||
->last();
|
||||
if (is_string($lastLine) && filled($lastLine)) {
|
||||
$parts[] = $lastLine;
|
||||
}
|
||||
}
|
||||
|
||||
$type = $company->type?->label();
|
||||
if (is_string($type) && filled($type)) {
|
||||
$parts[] = $type;
|
||||
}
|
||||
|
||||
return implode(' · ', $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rolle des aktuellen Users für die Karte (admin|member).
|
||||
*/
|
||||
public function userRoleKey(Company $company, User $user): string
|
||||
{
|
||||
if ($company->owner_user_id === $user->id) {
|
||||
return 'owner';
|
||||
}
|
||||
|
||||
return (string) ($company->getAttribute('current_user_role') ?? $company->pivot?->role ?? 'member');
|
||||
}
|
||||
|
||||
public function isAdminRole(string $roleKey): bool
|
||||
{
|
||||
return in_array($roleKey, ['owner', 'responsible'], true);
|
||||
}
|
||||
|
||||
public function roleLabel(string $roleKey): string
|
||||
{
|
||||
return match ($roleKey) {
|
||||
'owner' => __('Owner'),
|
||||
'responsible' => __('Verantwortlich'),
|
||||
default => __('Mitglied'),
|
||||
};
|
||||
}
|
||||
|
||||
public function fastLogoUrl(Company $company): ?string
|
||||
{
|
||||
if (blank($company->logo_path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$logoPath = trim((string) $company->logo_path);
|
||||
|
||||
if (Str::startsWith($logoPath, ['http://', 'https://']) && blank($company->legacy_portal)) {
|
||||
return $logoPath;
|
||||
}
|
||||
|
||||
if (Str::startsWith($logoPath, '/storage/')) {
|
||||
return asset($logoPath);
|
||||
}
|
||||
|
||||
if (filled($company->legacy_portal)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! Str::startsWith($logoPath, ['http://', 'https://'])) {
|
||||
return asset('storage/'.ltrim($logoPath, '/'));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function with(): array
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
$query = $this->baseQuery($user)
|
||||
->select([
|
||||
'companies.id',
|
||||
'companies.owner_user_id',
|
||||
'companies.portal',
|
||||
'companies.type',
|
||||
'companies.name',
|
||||
'companies.address',
|
||||
'companies.logo_path',
|
||||
'companies.legacy_portal',
|
||||
'companies.is_active',
|
||||
])
|
||||
->addSelect([
|
||||
'current_user_role' => DB::table('company_user')
|
||||
->select('role')
|
||||
->whereColumn('company_user.company_id', 'companies.id')
|
||||
->where('company_user.user_id', $user->id)
|
||||
->limit(1),
|
||||
])
|
||||
->withCount([
|
||||
'contacts' => fn ($q) => $q->withoutGlobalScopes(),
|
||||
'pressReleases' => fn ($q) => $q->withoutGlobalScopes(),
|
||||
])
|
||||
->withMax(['pressReleases' => fn ($q) => $q->withoutGlobalScopes()], 'published_at');
|
||||
|
||||
$this->applySavedView($query, $user, $this->savedView);
|
||||
$this->applySharedFilters($query);
|
||||
$this->applyRoleFilter($query, $user, $this->roleFilter);
|
||||
|
||||
$pressKits = $query
|
||||
->orderBy('name')
|
||||
->simplePaginate(24);
|
||||
->paginate(50)
|
||||
->through(function (Company $company) use ($user): Company {
|
||||
$roleKey = $this->userRoleKey($company, $user);
|
||||
$lastPublishedAt = $company->press_releases_max_published_at
|
||||
? Carbon::parse($company->press_releases_max_published_at)
|
||||
: null;
|
||||
|
||||
$company->setAttribute('panel_role_key', $roleKey);
|
||||
$company->setAttribute('panel_is_admin', $this->isAdminRole($roleKey));
|
||||
$company->setAttribute('panel_role_label', $this->roleLabel($roleKey));
|
||||
$company->setAttribute('panel_logo_url', $this->fastLogoUrl($company));
|
||||
$company->setAttribute('panel_logo_variant', $this->logoVariant($company));
|
||||
$company->setAttribute('panel_logo_initials', $this->logoInitials($company));
|
||||
$company->setAttribute('panel_meta_line', $this->metaLine($company));
|
||||
$company->setAttribute(
|
||||
'panel_last_press_release_short',
|
||||
$lastPublishedAt?->format('d.m.') ?? '—'
|
||||
);
|
||||
$company->setAttribute(
|
||||
'panel_last_press_release_date',
|
||||
$lastPublishedAt?->format('d.m.Y') ?? '—'
|
||||
);
|
||||
|
||||
return $company;
|
||||
});
|
||||
|
||||
$aggregates = $this->buildAggregateCounts($user);
|
||||
|
||||
return [
|
||||
'pressKits' => $pressKits,
|
||||
'context' => $context,
|
||||
'user' => $user,
|
||||
'hasActiveFilters' => filled($this->search)
|
||||
|| $this->savedView !== 'all'
|
||||
|| filled($this->portalFilter)
|
||||
|| $this->roleFilter !== 'all',
|
||||
'counters' => $aggregates['counters'],
|
||||
'savedViewCounts' => $aggregates['saved_views'],
|
||||
];
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<div class="space-y-8">
|
||||
<div class="space-y-6">
|
||||
{{-- ============== PAGE HEADER ============== --}}
|
||||
<header class="grid items-end gap-8" style="grid-template-columns:1fr auto;">
|
||||
<div class="min-w-0">
|
||||
|
|
@ -55,102 +425,486 @@ new #[Layout('components.layouts.app'), Title('Meine Firmen')] class extends Com
|
|||
<h1 class="text-[30px] font-bold tracking-[-0.6px] leading-[1.15] m-0 text-[color:var(--color-ink)]">
|
||||
{{ __('Meine Firmen') }}
|
||||
</h1>
|
||||
<p class="text-[13px] leading-[1.55] mt-2 m-0 max-w-[640px] text-[color:var(--color-ink-2)]">
|
||||
{{ __('Verwalten Sie Firmen, Pressekontakte und zugeordnete Pressemitteilungen.') }}
|
||||
|
||||
<div class="counter-strip mt-3">
|
||||
<span class="seg">
|
||||
<b>{{ $counters['companies'] }}</b> {{ __('Firmen') }}
|
||||
</span>
|
||||
<span class="sep"></span>
|
||||
<span class="seg is-ok">
|
||||
<b>{{ $counters['active'] }}</b> {{ __('aktiv') }}
|
||||
</span>
|
||||
<span class="sep"></span>
|
||||
<span class="seg">
|
||||
<b>{{ $counters['press_releases'] }}</b>
|
||||
{{ __('Pressemitteilungen gesamt') }}
|
||||
</span>
|
||||
<span class="sep"></span>
|
||||
<span class="seg">
|
||||
<b>{{ $counters['contacts'] }}</b>
|
||||
{{ __('Pressekontakte hinterlegt') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="mt-3 text-[12.5px] leading-[1.55] max-w-[640px] m-0 text-[color:var(--color-ink-3)]">
|
||||
{{ __('Eine Firma ist der Container für Pressemitteilungen: Stammdaten, Boilerplate, Pressekontakte. Anlage ohne separate Freigabe — die redaktionelle Prüfung erfolgt erst bei der Pressemitteilung.') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<flux:button variant="primary" icon="plus" href="{{ route('me.profile') }}" wire:navigate>
|
||||
{{ __('Firma anlegen anfragen') }}
|
||||
<flux:button variant="ghost" icon="document-arrow-down" disabled>
|
||||
{{ __('Export') }}
|
||||
<span class="badge muted ml-2" style="font-size:9px;padding:0 5px;letter-spacing:0.06em;">
|
||||
{{ __('bald') }}
|
||||
</span>
|
||||
</flux:button>
|
||||
<flux:button variant="primary" icon="plus" href="{{ route('me.press-kits.create') }}" wire:navigate>
|
||||
{{ __('Firma anlegen') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{{-- ============== FILTER-PANEL ============== --}}
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Filter & Suche') }}</span>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<flux:input wire:model.live.debounce.300ms="search" icon="magnifying-glass" placeholder="{{ __('Firma suchen...') }}" />
|
||||
</div>
|
||||
</article>
|
||||
{{-- ============== SAVED VIEW TABS ============== --}}
|
||||
<nav class="view-tabs" aria-label="{{ __('Gespeicherte Ansichten') }}">
|
||||
@php
|
||||
$savedViewMeta = [
|
||||
'all' => __('Alle'),
|
||||
'active' => __('Aktiv'),
|
||||
'drafts' => __('In Anlage'),
|
||||
'inactive' => __('Inaktiv'),
|
||||
'shared' => __('Mit mir geteilt'),
|
||||
];
|
||||
@endphp
|
||||
|
||||
{{-- ============== FIRMEN-CARDS ============== --}}
|
||||
<section class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
@forelse ($pressKits as $company)
|
||||
<article class="panel flex flex-col">
|
||||
<div class="panel-head">
|
||||
<div class="min-w-0">
|
||||
<span class="section-eyebrow truncate">{{ $company->name }}</span>
|
||||
@foreach ($savedViewMeta as $key => $label)
|
||||
<button
|
||||
type="button"
|
||||
wire:click="setSavedView('{{ $key }}')"
|
||||
class="view-tab {{ $savedView === $key ? 'is-active' : '' }}"
|
||||
data-view="{{ $key }}"
|
||||
@if ($key === 'drafts') disabled aria-disabled="true" @endif
|
||||
>
|
||||
{{ $label }}
|
||||
<span class="cnt">{{ $savedViewCounts[$key] }}</span>
|
||||
</button>
|
||||
@endforeach
|
||||
</nav>
|
||||
|
||||
{{-- ============== FILTER + SUCHE ============== --}}
|
||||
<section class="space-y-3">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<flux:dropdown align="start">
|
||||
<button type="button" class="filter-chip {{ filled($portalFilter) ? 'is-active' : '' }}">
|
||||
@if ($portalFilter === 'presseecho')
|
||||
<span class="dot-pe inline-block"></span>
|
||||
@elseif ($portalFilter === 'businessportal24')
|
||||
<span class="dot-bp inline-block"></span>
|
||||
@else
|
||||
<span class="dot-pe inline-block" style="margin-right:1px;"></span>
|
||||
<span class="dot-bp inline-block" style="margin-left:-2px;"></span>
|
||||
@endif
|
||||
{{ __('Portal') }}:
|
||||
<strong class="font-semibold">
|
||||
@switch($portalFilter)
|
||||
@case('presseecho') presseecho @break
|
||||
@case('businessportal24') businessportal24 @break
|
||||
@default {{ __('Alle') }}
|
||||
@endswitch
|
||||
</strong>
|
||||
<flux:icon.chevron-down class="size-3 caret" />
|
||||
</button>
|
||||
<flux:menu>
|
||||
<flux:menu.item wire:click="setPortalFilter('')">{{ __('Alle Portale') }}</flux:menu.item>
|
||||
<flux:menu.item wire:click="setPortalFilter('presseecho')">presseecho</flux:menu.item>
|
||||
<flux:menu.item wire:click="setPortalFilter('businessportal24')">businessportal24</flux:menu.item>
|
||||
</flux:menu>
|
||||
</flux:dropdown>
|
||||
|
||||
<flux:dropdown align="start">
|
||||
<button type="button" class="filter-chip {{ $roleFilter !== 'all' ? 'is-active' : '' }}">
|
||||
<flux:icon.user class="size-3 opacity-70" />
|
||||
{{ __('Rolle') }}:
|
||||
<strong class="font-semibold">
|
||||
@switch($roleFilter)
|
||||
@case('owner') {{ __('Owner') }} @break
|
||||
@case('responsible') {{ __('Verantwortlich') }} @break
|
||||
@case('member') {{ __('Mitglied') }} @break
|
||||
@default {{ __('Alle') }}
|
||||
@endswitch
|
||||
</strong>
|
||||
<flux:icon.chevron-down class="size-3 caret" />
|
||||
</button>
|
||||
<flux:menu>
|
||||
<flux:menu.item wire:click="setRoleFilter('all')">{{ __('Alle Rollen') }}</flux:menu.item>
|
||||
<flux:menu.item wire:click="setRoleFilter('owner')">{{ __('Owner') }}</flux:menu.item>
|
||||
<flux:menu.item wire:click="setRoleFilter('responsible')">{{ __('Verantwortlich') }}</flux:menu.item>
|
||||
<flux:menu.item wire:click="setRoleFilter('member')">{{ __('Mitglied') }}</flux:menu.item>
|
||||
</flux:menu>
|
||||
</flux:dropdown>
|
||||
|
||||
<button type="button" class="filter-chip" disabled aria-disabled="true" title="{{ __('Branche-Filter folgt') }}">
|
||||
<flux:icon.tag class="size-3 opacity-70" />
|
||||
{{ __('Branche') }}: <strong class="font-semibold">{{ __('bald') }}</strong>
|
||||
</button>
|
||||
|
||||
<span class="w-px h-6 bg-[color:var(--color-bg-rule)] mx-1"></span>
|
||||
|
||||
<div class="search-wrap" style="max-width:340px;">
|
||||
<flux:icon.magnifying-glass class="ico size-3" />
|
||||
<input
|
||||
type="search"
|
||||
wire:model.live.debounce.300ms="search"
|
||||
placeholder="{{ __('Firmenname, Stadt oder E-Mail…') }}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span class="flex-1"></span>
|
||||
|
||||
{{-- View-Toggle Karten/Liste --}}
|
||||
<div class="seg-toggle" role="tablist" aria-label="{{ __('Ansicht umschalten') }}">
|
||||
<button
|
||||
type="button"
|
||||
wire:click="setViewMode('cards')"
|
||||
class="cursor-pointer {{ $viewMode === 'cards' ? 'is-active' : '' }}"
|
||||
aria-label="{{ __('Kartenansicht') }}"
|
||||
data-viewmode="cards"
|
||||
>
|
||||
<flux:icon.squares-2x2 class="size-3" />
|
||||
{{ __('Karten') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
wire:click="setViewMode('list')"
|
||||
class="cursor-pointer {{ $viewMode === 'list' ? 'is-active' : '' }}"
|
||||
aria-label="{{ __('Listenansicht') }}"
|
||||
data-viewmode="list"
|
||||
>
|
||||
<flux:icon.list-bullet class="size-3" />
|
||||
{{ __('Liste') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{-- ============== CONTENT-HOST ============== --}}
|
||||
<article data-state-host>
|
||||
|
||||
@if ($pressKits->isEmpty())
|
||||
{{-- Empty States --}}
|
||||
@if ($hasActiveFilters)
|
||||
{{-- Empty: Filter ohne Treffer --}}
|
||||
<div class="panel" data-state="empty-filter">
|
||||
<div class="empty-stage">
|
||||
<div class="empty-ico warm">
|
||||
<flux:icon.funnel class="size-6" />
|
||||
</div>
|
||||
<h3 class="empty-title">{{ __('Keine Firmen mit diesen Filtern') }}</h3>
|
||||
<p class="empty-sub">
|
||||
{{ __('Aktive Filter passen auf keine Einträge. Filter zurücksetzen oder weiter fassen.') }}
|
||||
</p>
|
||||
<div class="flex items-center gap-2.5 mt-6">
|
||||
<flux:button variant="primary" wire:click="resetFilters">
|
||||
{{ __('Alle Filter zurücksetzen') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
{{-- Empty: noch keine Firma --}}
|
||||
<div class="panel" data-state="empty-none">
|
||||
<div class="empty-stage">
|
||||
<div class="empty-ico">
|
||||
<flux:icon.building-office class="size-6" />
|
||||
</div>
|
||||
<h3 class="empty-title">{{ __('Noch keine Firma angelegt') }}</h3>
|
||||
<p class="empty-sub">
|
||||
{{ __('Lege deine erste Firma an. Du kannst direkt im Anschluss eine Pressemitteilung darauf veröffentlichen — eine separate Freigabe der Firma ist nicht erforderlich.') }}
|
||||
</p>
|
||||
<div class="flex items-center gap-2.5 mt-6">
|
||||
<flux:button variant="primary" icon="plus" href="{{ route('me.press-kits.create') }}" wire:navigate>
|
||||
{{ __('Erste Firma anlegen') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
<div class="mt-9 grid gap-3 w-full max-w-[560px]" style="grid-template-columns:repeat(3,1fr);">
|
||||
<div class="text-left px-3 py-2.5 rounded-[3px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)]">
|
||||
<div class="font-mono text-[9.5px] tracking-[0.16em] font-bold mb-1 text-[color:var(--color-accent-deep)]">01</div>
|
||||
<div class="text-[11.5px] font-semibold leading-tight text-[color:var(--color-ink)]">
|
||||
{{ __('Stammdaten erfassen') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-left px-3 py-2.5 rounded-[3px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)]">
|
||||
<div class="font-mono text-[9.5px] tracking-[0.16em] font-bold mb-1 text-[color:var(--color-accent-deep)]">02</div>
|
||||
<div class="text-[11.5px] font-semibold leading-tight text-[color:var(--color-ink)]">
|
||||
{{ __('Boilerplate schreiben') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-left px-3 py-2.5 rounded-[3px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)]">
|
||||
<div class="font-mono text-[9.5px] tracking-[0.16em] font-bold mb-1 text-[color:var(--color-accent-deep)]">03</div>
|
||||
<div class="text-[11.5px] font-semibold leading-tight text-[color:var(--color-ink)]">
|
||||
{{ __('Pressekontakte zuordnen') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@elseif ($viewMode === 'cards')
|
||||
{{-- Karten-Ansicht --}}
|
||||
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-3" data-state="cards">
|
||||
@foreach ($pressKits as $company)
|
||||
<div
|
||||
class="firm-card {{ $company->panel_is_admin ? 'is-self' : '' }}"
|
||||
wire:key="firm-card-{{ $company->id }}"
|
||||
data-testid="firm-card-{{ $company->id }}"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="logo {{ $company->panel_logo_url ? '' : $company->panel_logo_variant }}">
|
||||
@if ($company->panel_logo_url)
|
||||
<img src="{{ $company->panel_logo_url }}" alt="{{ $company->name }}" loading="lazy" />
|
||||
@else
|
||||
{{ $company->panel_logo_initials }}
|
||||
@endif
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
@if ($company->is_active)
|
||||
<span class="badge ok dot">{{ __('Aktiv') }}</span>
|
||||
@else
|
||||
<span class="badge err dot">{{ __('Inaktiv') }}</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="p-5 space-y-4 flex-1">
|
||||
<div class="text-[11.5px] text-[color:var(--color-ink-3)] truncate">
|
||||
{{ $company->slug }}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span class="badge hub">{{ $company->portal?->label() ?? __('Portal unbekannt') }}</span>
|
||||
<span class="badge hub">{{ $context->roleLabelFor($company, $user) }}</span>
|
||||
@if ($company->disable_footer_code)
|
||||
<span class="badge warn">{{ __('Footer-Code aus') }}</span>
|
||||
<div class="min-w-0">
|
||||
<h3 class="name">{{ $company->name }}</h3>
|
||||
@if (filled($company->panel_meta_line))
|
||||
<div class="meta-line">{{ $company->panel_meta_line }}</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3 pt-1">
|
||||
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
|
||||
<div class="text-[10.5px] uppercase tracking-[0.6px] font-semibold text-[color:var(--color-ink-3)]">
|
||||
{{ __('Pressemitteilungen') }}
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
@if ($company->portal === \App\Enums\Portal::Both)
|
||||
<span class="portal-pill pe"><span class="pdot"></span>presseecho</span>
|
||||
<span class="portal-pill bp"><span class="pdot"></span>businessportal24</span>
|
||||
@elseif ($company->portal === \App\Enums\Portal::Presseecho)
|
||||
<span class="portal-pill pe"><span class="pdot"></span>presseecho</span>
|
||||
@elseif ($company->portal === \App\Enums\Portal::Businessportal24)
|
||||
<span class="portal-pill bp"><span class="pdot"></span>businessportal24</span>
|
||||
@endif
|
||||
|
||||
<span class="role-pill {{ $company->panel_is_admin ? 'admin' : '' }}">
|
||||
{{ $company->panel_role_label }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-[18px] font-bold text-[color:var(--color-ink)] mt-1">
|
||||
{{ $company->press_releases_count }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
|
||||
<div class="text-[10.5px] uppercase tracking-[0.6px] font-semibold text-[color:var(--color-ink-3)]">
|
||||
{{ __('Pressekontakte') }}
|
||||
</div>
|
||||
<div class="text-[18px] font-bold text-[color:var(--color-ink)] mt-1">
|
||||
{{ $company->contacts_count }}
|
||||
|
||||
<div class="kpis">
|
||||
<div class="kpi">
|
||||
<span class="k">{{ $company->press_releases_count }}</span>
|
||||
<span class="l">{{ __('PMs') }}</span>
|
||||
</div>
|
||||
<div class="kpi">
|
||||
<span class="k">{{ $company->contacts_count }}</span>
|
||||
<span class="l">{{ __('Kontakte') }}</span>
|
||||
</div>
|
||||
<div class="kpi">
|
||||
<span class="k">
|
||||
{{ $company->panel_last_press_release_short }}
|
||||
</span>
|
||||
<span class="l">{{ __('letzte PM') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-5 pb-4 pt-3 border-t border-[color:var(--color-bg-rule)] flex justify-end">
|
||||
<flux:button size="sm" variant="ghost" icon="arrow-right" href="{{ route('me.press-kits.show', $company->id) }}" wire:navigate>
|
||||
<div class="flex items-center gap-2 pt-1">
|
||||
<a href="{{ route('me.press-kits.show', $company->id) }}" wire:navigate class="card-action primary" style="flex:1;">
|
||||
<flux:icon.arrow-right class="size-3" />
|
||||
{{ __('Firma öffnen') }}
|
||||
</flux:button>
|
||||
</a>
|
||||
<a href="{{ route('me.press-releases.create') }}" wire:navigate class="card-action">
|
||||
<flux:icon.plus class="size-3" />
|
||||
{{ __('Neue PM') }}
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
@empty
|
||||
<article class="panel md:col-span-2 xl:col-span-3">
|
||||
<div class="p-10 flex flex-col items-center justify-center text-center">
|
||||
<div class="w-14 h-14 rounded-[6px] flex items-center justify-center mb-3
|
||||
bg-[color:var(--color-hub-soft)] border border-[color:var(--color-hub-soft-2)] text-[color:var(--color-hub)]">
|
||||
<flux:icon.building-office class="size-6" />
|
||||
</div>
|
||||
<div class="text-[14px] font-semibold text-[color:var(--color-ink)] mb-1">
|
||||
{{ __('Keine Firmen gefunden') }}
|
||||
</div>
|
||||
<p class="text-[12px] text-[color:var(--color-ink-3)] max-w-md m-0">
|
||||
{{ __('Prüfen Sie die Suche oder wenden Sie sich an den Support, wenn eine Firma fehlen sollte.') }}
|
||||
</p>
|
||||
<flux:button class="mt-4" variant="primary" href="{{ route('me.profile') }}" wire:navigate>
|
||||
{{ __('Profil prüfen') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</article>
|
||||
@endforelse
|
||||
</section>
|
||||
@endforeach
|
||||
|
||||
{{ $pressKits->links() }}
|
||||
{{-- Add-Tile am Ende des Grids, nur auf der letzten Seite --}}
|
||||
@if ($pressKits->currentPage() === $pressKits->lastPage())
|
||||
<a href="{{ route('me.press-kits.create') }}" wire:navigate class="add-tile" data-testid="add-tile">
|
||||
<span class="ico">
|
||||
<flux:icon.plus class="size-5" />
|
||||
</span>
|
||||
<span class="lbl">{{ __('Neue Firma anlegen') }}</span>
|
||||
<span class="sub">
|
||||
{{ __('Stammdaten und Boilerplate. Die Anlage benötigt keine separate Freigabe.') }}
|
||||
</span>
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@else
|
||||
{{-- Listen-Ansicht --}}
|
||||
<div class="panel overflow-hidden" data-state="list">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="list">
|
||||
<colgroup>
|
||||
<col style="width:88px;" />
|
||||
<col />
|
||||
<col style="width:190px;" />
|
||||
<col style="width:140px;" />
|
||||
<col style="width:110px;" />
|
||||
<col style="width:80px;" />
|
||||
<col style="width:100px;" />
|
||||
<col style="width:130px;" />
|
||||
<col style="width:56px;" />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>{{ __('Firma') }}</th>
|
||||
<th>{{ __('Portal') }}</th>
|
||||
<th>{{ __('Rolle') }}</th>
|
||||
<th>{{ __('Status') }}</th>
|
||||
<th style="text-align:right;">{{ __('PMs') }}</th>
|
||||
<th style="text-align:right;">{{ __('Kontakte') }}</th>
|
||||
<th>{{ __('Letzte PM') }}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach ($pressKits as $company)
|
||||
<tr wire:key="firm-row-{{ $company->id }}" data-testid="firm-row-{{ $company->id }}">
|
||||
<td>
|
||||
<span class="mini-logo {{ $company->panel_logo_url ? '' : $company->panel_logo_variant }}">
|
||||
@if ($company->panel_logo_url)
|
||||
<img src="{{ $company->panel_logo_url }}" alt="{{ $company->name }}" loading="lazy" />
|
||||
@else
|
||||
{{ $company->panel_logo_initials }}
|
||||
@endif
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ route('me.press-kits.show', $company->id) }}" wire:navigate class="row-title">
|
||||
{{ $company->name }}
|
||||
</a>
|
||||
@if (filled($company->panel_meta_line))
|
||||
<div class="row-sub">{{ $company->panel_meta_line }}</div>
|
||||
@endif
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex flex-wrap items-center gap-1">
|
||||
@if ($company->portal === \App\Enums\Portal::Both)
|
||||
<span class="portal-pill pe"><span class="pdot"></span>presseecho</span>
|
||||
<span class="portal-pill bp"><span class="pdot"></span>businessportal24</span>
|
||||
@elseif ($company->portal === \App\Enums\Portal::Presseecho)
|
||||
<span class="portal-pill pe"><span class="pdot"></span>presseecho</span>
|
||||
@elseif ($company->portal === \App\Enums\Portal::Businessportal24)
|
||||
<span class="portal-pill bp"><span class="pdot"></span>businessportal24</span>
|
||||
@endif
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="role-pill {{ $company->panel_is_admin ? 'admin' : '' }}">
|
||||
{{ $company->panel_role_label }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
@if ($company->is_active)
|
||||
<span class="badge ok dot">{{ __('Aktiv') }}</span>
|
||||
@else
|
||||
<span class="badge err dot">{{ __('Inaktiv') }}</span>
|
||||
@endif
|
||||
</td>
|
||||
<td style="text-align:right;">
|
||||
<span class="row-num">{{ $company->press_releases_count }}</span>
|
||||
</td>
|
||||
<td style="text-align:right;">
|
||||
<span class="row-num">{{ $company->contacts_count }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="row-num">
|
||||
{{ $company->panel_last_press_release_date }}
|
||||
</span>
|
||||
</td>
|
||||
<td style="text-align:right;">
|
||||
<div class="firm-list-actions flex items-center justify-end gap-1">
|
||||
<flux:button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon="eye"
|
||||
href="{{ route('me.press-kits.show', $company->id) }}"
|
||||
wire:navigate
|
||||
aria-label="{{ __('Firma öffnen') }}"
|
||||
title="{{ __('Firma öffnen') }}"
|
||||
/>
|
||||
<flux:button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon="document-plus"
|
||||
href="{{ route('me.press-releases.create') }}"
|
||||
wire:navigate
|
||||
aria-label="{{ __('Neue Pressemitteilung') }}"
|
||||
title="{{ __('Neue Pressemitteilung') }}"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
</article>
|
||||
|
||||
{{-- Pagination --}}
|
||||
@if ($pressKits->hasPages())
|
||||
<div class="px-1">
|
||||
{{ $pressKits->links('components.portal.pagination', ['scrollTo' => '[data-state-host]']) }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- ============== ROLLEN-LEGENDE ============== --}}
|
||||
<article class="panel-warm p-5">
|
||||
<div class="grid items-start gap-6" style="grid-template-columns:auto 1fr;">
|
||||
<div class="min-w-[180px]">
|
||||
<div class="section-eyebrow">{{ __('Rollen pro Firma') }}</div>
|
||||
<p class="text-[12px] leading-[1.55] mt-3 m-0 max-w-[220px] text-[color:var(--color-ink-3)]">
|
||||
{{ __('Mehrere Personen können einer Firma zugeordnet sein. Die Rolle steuert, was im Backend möglich ist.') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4" style="grid-template-columns:repeat(3,1fr);">
|
||||
<div>
|
||||
<span class="role-pill admin" style="margin-bottom:8px;">{{ __('Owner') }}</span>
|
||||
<ul class="text-[11.5px] leading-[1.7] mt-2 list-none p-0 m-0 space-y-0.5 text-[color:var(--color-ink-2)]">
|
||||
<li>{{ __('Stammdaten & Boilerplate') }}</li>
|
||||
<li>{{ __('Pressekontakte verwalten') }}</li>
|
||||
<li>{{ __('PMs erstellen, einreichen, archivieren') }}</li>
|
||||
<li>{{ __('Weitere Mitglieder einladen') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<span class="role-pill admin" style="margin-bottom:8px;">{{ __('Verantwortlich') }}</span>
|
||||
<ul class="text-[11.5px] leading-[1.7] mt-2 list-none p-0 m-0 space-y-0.5 text-[color:var(--color-ink-2)]">
|
||||
<li>{{ __('Stammdaten & Boilerplate') }}</li>
|
||||
<li>{{ __('Pressekontakte verwalten') }}</li>
|
||||
<li>{{ __('PMs erstellen & einreichen') }}</li>
|
||||
<li class="text-[color:var(--color-ink-4)]">{{ __('keine Mitglieder-Verwaltung') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<span class="role-pill" style="margin-bottom:8px;">
|
||||
{{ __('Mitglied') }}
|
||||
<span class="text-[color:var(--color-ink-4)] font-normal">· {{ __('bald erweitert') }}</span>
|
||||
</span>
|
||||
<ul class="text-[11.5px] leading-[1.7] mt-2 list-none p-0 m-0 space-y-0.5 text-[color:var(--color-ink-2)]">
|
||||
<li>{{ __('PMs einsehen') }}</li>
|
||||
<li>{{ __('Stammdaten lesen') }}</li>
|
||||
<li class="text-[color:var(--color-ink-4)]">{{ __('keine Bearbeitung') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ use App\Models\Contact;
|
|||
use App\Models\PressRelease;
|
||||
use App\Services\Customer\CustomerCompanyContext;
|
||||
use App\Services\PressRelease\PressReleaseHtmlSanitizer;
|
||||
use Flux\Flux;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
|
@ -44,6 +45,12 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
|||
|
||||
public string $publishMode = 'now';
|
||||
|
||||
public ?string $scheduledAt = null;
|
||||
|
||||
public bool $useEmbargo = false;
|
||||
|
||||
public ?string $embargoAt = null;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
|
@ -72,6 +79,86 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
|||
unset($this->tags, $this->presubmitChecks);
|
||||
}
|
||||
|
||||
/**
|
||||
* Live-Re-Validation: sobald für ein Property bereits ein Error im Bag
|
||||
* liegt, wird es bei jeder Änderung neu geprüft. So verschwindet ein
|
||||
* roter Hinweis sofort, wenn der User das Feld korrekt ausfüllt — und
|
||||
* der User muss nicht erst auf „Entwurf speichern" klicken.
|
||||
*/
|
||||
public function updated(string $property): void
|
||||
{
|
||||
if (! $this->getErrorBag()->has($property)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->validateOnly($property, $this->formRules());
|
||||
} catch (\Illuminate\Validation\ValidationException) {
|
||||
// Field bleibt invalid — Error-Bag wird automatisch befüllt.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toast mit Sammelhinweis nach fehlgeschlagener Validierung.
|
||||
* Die einzelnen Feld-Errors werden weiterhin direkt am Input angezeigt,
|
||||
* der Toast dient als zusätzlicher Wegweiser, falls der erste Fehler
|
||||
* außerhalb des Viewports liegt.
|
||||
*/
|
||||
protected function notifyValidationError(?\Illuminate\Validation\ValidationException $exception = null): void
|
||||
{
|
||||
$count = $exception
|
||||
? array_sum(array_map('count', $exception->errors()))
|
||||
: count($this->getErrorBag()->all());
|
||||
|
||||
Flux::toast(
|
||||
heading: __('Bitte Eingaben prüfen'),
|
||||
text: $count > 1
|
||||
? __(':count Felder benötigen deine Aufmerksamkeit.', ['count' => $count])
|
||||
: __('Ein Feld benötigt deine Aufmerksamkeit.'),
|
||||
variant: 'danger',
|
||||
duration: 6000,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Single Source of Truth für die Validierungsregeln.
|
||||
*
|
||||
* @return array<string, array<int, mixed>>
|
||||
*/
|
||||
protected function formRules(): array
|
||||
{
|
||||
$rules = [
|
||||
'language' => ['required', Rule::in(['de', 'en'])],
|
||||
'companyId' => ['required', 'integer'],
|
||||
'categoryId' => ['required', 'integer', Rule::exists('categories', 'id')],
|
||||
'contactId' => ['nullable', 'integer'],
|
||||
'title' => ['required', 'string', 'min:5', 'max:255'],
|
||||
'subtitle' => ['nullable', 'string', 'max:255'],
|
||||
'text' => ['required', 'string', 'min:50'],
|
||||
'keywords' => ['nullable', 'string', 'max:255'],
|
||||
'backlinkUrl' => ['nullable', 'url', 'max:255'],
|
||||
'boilerplateOverride' => ['nullable', 'string', 'max:5000'],
|
||||
'publishMode' => ['required', Rule::in(['now', 'scheduled'])],
|
||||
];
|
||||
|
||||
// Termin-Pflicht nur, wenn der User explizit Scheduling gewählt hat.
|
||||
// Min. 5 Minuten in der Zukunft, damit der Background-Job (alle 5 Min)
|
||||
// die PM verlässlich rechtzeitig fängt.
|
||||
if ($this->publishMode === 'scheduled') {
|
||||
$rules['scheduledAt'] = ['required', 'date', 'after:'.now()->addMinutes(5)->toIso8601String()];
|
||||
} else {
|
||||
$rules['scheduledAt'] = ['nullable'];
|
||||
}
|
||||
|
||||
if ($this->useEmbargo) {
|
||||
$rules['embargoAt'] = ['required', 'date', 'after:'.now()->toIso8601String()];
|
||||
} else {
|
||||
$rules['embargoAt'] = ['nullable'];
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
public function addTag(string $tag): void
|
||||
{
|
||||
$tag = trim($tag);
|
||||
|
|
@ -110,35 +197,36 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
|||
|
||||
public function save(string $submitStatus = 'draft'): void
|
||||
{
|
||||
$this->validate([
|
||||
'language' => ['required', Rule::in(['de', 'en'])],
|
||||
'companyId' => ['required', 'integer'],
|
||||
'categoryId' => ['required', 'integer', Rule::exists('categories', 'id')],
|
||||
'contactId' => ['required', 'integer'],
|
||||
'title' => ['required', 'string', 'min:5', 'max:255'],
|
||||
'subtitle' => ['nullable', 'string', 'max:255'],
|
||||
'text' => ['required', 'string', 'min:50'],
|
||||
'keywords' => ['nullable', 'string', 'max:255'],
|
||||
'backlinkUrl' => ['nullable', 'url', 'max:255'],
|
||||
'boilerplateOverride' => ['nullable', 'string', 'max:5000'],
|
||||
]);
|
||||
try {
|
||||
$this->validate($this->formRules());
|
||||
} catch (\Illuminate\Validation\ValidationException $e) {
|
||||
$this->notifyValidationError($e);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
$company = $this->selectedCompany();
|
||||
|
||||
if (! $company) {
|
||||
$this->addError('companyId', __('Die gewählte Firma ist nicht Ihrem Account zugeordnet.'));
|
||||
$this->notifyValidationError();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$contact = null;
|
||||
|
||||
if ($this->contactId) {
|
||||
$contact = $this->companyContact((int) $this->contactId, (int) $company->id);
|
||||
|
||||
if (! $contact) {
|
||||
$this->addError('contactId', __('Der gewählte Pressekontakt gehört nicht zu dieser Firma.'));
|
||||
$this->notifyValidationError();
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$this->portal = $company->portal?->value ?? Portal::Presseecho->value;
|
||||
|
||||
|
|
@ -167,14 +255,28 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
|||
: null,
|
||||
'keywords' => $this->keywords ?: null,
|
||||
'backlink_url' => $this->backlinkUrl ?: null,
|
||||
'scheduled_at' => $this->publishMode === 'scheduled' && $this->scheduledAt
|
||||
? \Carbon\Carbon::parse($this->scheduledAt)
|
||||
: null,
|
||||
'embargo_at' => $this->useEmbargo && $this->embargoAt
|
||||
? \Carbon\Carbon::parse($this->embargoAt)
|
||||
: null,
|
||||
'status' => $status->value,
|
||||
]);
|
||||
|
||||
if ($contact) {
|
||||
$pr->contacts()->sync([$contact->id]);
|
||||
}
|
||||
|
||||
session()->flash('success', $status === PressReleaseStatus::Review
|
||||
? __('Pressemitteilung zur Prüfung eingereicht.')
|
||||
: __('Entwurf gespeichert.'));
|
||||
Flux::toast(
|
||||
heading: $status === PressReleaseStatus::Review
|
||||
? __('Eingereicht')
|
||||
: __('Entwurf gespeichert'),
|
||||
text: $status === PressReleaseStatus::Review
|
||||
? __('Die Redaktion prüft die Pressemitteilung typischerweise innerhalb von 24 h.')
|
||||
: __('Deine Änderungen sind gesichert. Du kannst die PM jederzeit weiter bearbeiten.'),
|
||||
variant: 'success',
|
||||
);
|
||||
|
||||
$this->redirect(route('me.press-releases.show', $pr->id), navigate: true);
|
||||
}
|
||||
|
|
@ -246,9 +348,9 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
|||
],
|
||||
[
|
||||
'key' => 'contact',
|
||||
'status' => $this->contactId ? 'ok' : 'err',
|
||||
'status' => $this->contactId ? 'ok' : 'warn',
|
||||
'label' => __('Pressekontakt zugeordnet'),
|
||||
'sub' => $this->contactId ? '' : __('Mindestens ein Kontakt erforderlich'),
|
||||
'sub' => $this->contactId ? '' : __('empfohlen — Journalisten greifen direkt zum Hörer'),
|
||||
],
|
||||
[
|
||||
'key' => 'tags',
|
||||
|
|
@ -365,7 +467,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
|||
</header>
|
||||
|
||||
{{-- ============== 2-COLUMN GRID ============== --}}
|
||||
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr),360px]">
|
||||
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_360px]">
|
||||
|
||||
{{-- =================== LINKS: SCHREIBFLÄCHE =================== --}}
|
||||
<div class="space-y-6 min-w-0">
|
||||
|
|
@ -513,7 +615,10 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
|||
</div>
|
||||
</section>
|
||||
|
||||
{{-- 6) ANHÄNGE (nach Speichern verfügbar) --}}
|
||||
{{-- 6) ANHÄNGE — TEMPORÄR DEAKTIVIERT
|
||||
Datei-Uploads erfordern eine vollständige Sicherheitsprüfung
|
||||
(Virus-/Malware-Scan, MIME-Validierung, Storage-Quoten).
|
||||
Wird in einer späteren Phase aktiviert.
|
||||
<section class="panel">
|
||||
<div class="p-5">
|
||||
<div class="flex items-center justify-between mb-3 gap-4">
|
||||
|
|
@ -529,6 +634,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
|||
</div>
|
||||
</div>
|
||||
</section>
|
||||
--}}
|
||||
|
||||
{{-- 7) BOILERPLATE --}}
|
||||
<section class="panel">
|
||||
|
|
@ -655,14 +761,46 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
|||
</div>
|
||||
</article>
|
||||
|
||||
{{-- Portal (Read-only) --}}
|
||||
{{-- Kategorie (Pflichtfeld - prominent direkt unter Status) --}}
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">
|
||||
{{ __('Kategorie') }}
|
||||
<span class="text-[color:var(--color-err)]">*</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<flux:field>
|
||||
<flux:select wire:model.live="categoryId">
|
||||
<option value="">{{ __('Bitte wählen…') }}</option>
|
||||
@foreach ($categories as $cat)
|
||||
<option value="{{ $cat->id }}">{{ $cat->translations->firstWhere('locale', 'de')?->name ?? $cat->id }}</option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
<flux:error name="categoryId" />
|
||||
<flux:description>{{ __('Pflichtfeld — steuert, in welcher Rubrik die PM erscheint.') }}</flux:description>
|
||||
</flux:field>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{{-- Portal (Read-only, Badge in Portal-Farbe) --}}
|
||||
@php
|
||||
$portalPillClass = 'portal-pill';
|
||||
if ($portal === 'presseecho') {
|
||||
$portalPillClass = 'portal-pill pe';
|
||||
} elseif ($portal === 'businessportal24') {
|
||||
$portalPillClass = 'portal-pill bp';
|
||||
}
|
||||
@endphp
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Portal') }}</span>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="badge hub dot">{{ $selectedPortalLabel }}</span>
|
||||
<span class="{{ $portalPillClass }}">
|
||||
<span class="pdot"></span>{{ $selectedPortalLabel }}
|
||||
</span>
|
||||
<span class="text-[11px] text-[color:var(--color-ink-4)]">
|
||||
{{ __('automatisch aus der Firma') }}
|
||||
</span>
|
||||
|
|
@ -691,7 +829,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
|||
@endif
|
||||
@else
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Kontakt für diese PM') }} <span class="text-[color:var(--color-err)]">*</span></flux:label>
|
||||
<flux:label>{{ __('Kontakt für diese PM') }}</flux:label>
|
||||
<flux:select wire:model.live="contactId">
|
||||
<option value="">{{ __('Bitte wählen…') }}</option>
|
||||
@foreach ($selectedCompanyContacts as $contact)
|
||||
|
|
@ -708,6 +846,12 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
|||
<flux:error name="contactId" />
|
||||
</flux:field>
|
||||
|
||||
@if (! $contactId)
|
||||
<div class="flex items-start gap-2 text-[11.5px] text-[color:var(--color-warn)]">
|
||||
<flux:icon name="exclamation-triangle" variant="mini" class="size-4 flex-shrink-0 mt-0.5" />
|
||||
<span>{{ __('Noch kein Pressekontakt ausgewählt. Empfohlen, aber nicht zwingend.') }}</span>
|
||||
</div>
|
||||
@else
|
||||
@php($activeContact = $selectedCompanyContacts->firstWhere('id', (int) $contactId))
|
||||
@if ($activeContact && empty($activeContact->phone))
|
||||
<div class="flex items-start gap-2 text-[11.5px] text-[color:var(--color-warn)]">
|
||||
|
|
@ -716,10 +860,11 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
|||
</div>
|
||||
@endif
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{{-- Themen-Tags + Kategorie --}}
|
||||
{{-- Themen-Tags --}}
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Themen-Tags') }}</span>
|
||||
|
|
@ -769,20 +914,8 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
|||
</div>
|
||||
@endif
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Kategorie') }} <span class="text-[color:var(--color-err)]">*</span></flux:label>
|
||||
<flux:select wire:model.live="categoryId">
|
||||
<option value="">{{ __('Bitte wählen…') }}</option>
|
||||
@foreach ($categories as $cat)
|
||||
@php($catName = $cat->translations->firstWhere('locale', 'de')?->name ?? $cat->id)
|
||||
<option value="{{ $cat->id }}">{{ $catName }}</option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
<flux:error name="categoryId" />
|
||||
</flux:field>
|
||||
|
||||
<p class="text-[10.5px] text-[color:var(--color-ink-4)] m-0 leading-[1.45]">
|
||||
{{ __('Tags helfen bei SEO und Auffindbarkeit. Die Kategorie steuert, in welcher Rubrik die PM erscheint.') }}
|
||||
{{ __('Tags helfen bei SEO und Auffindbarkeit.') }}
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
|
|
@ -792,7 +925,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
|||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Veröffentlichung') }}</span>
|
||||
</div>
|
||||
<div class="p-5 space-y-2">
|
||||
<div class="p-5 space-y-3">
|
||||
<label class="pr-pub-opt {{ $publishMode === 'now' ? 'is-checked' : '' }}">
|
||||
<input type="radio" wire:model.live="publishMode" value="now" class="sr-only" />
|
||||
<span class="dot-out"></span>
|
||||
|
|
@ -805,18 +938,53 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
|||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<span class="pr-pub-opt is-disabled">
|
||||
<label class="pr-pub-opt {{ $publishMode === 'scheduled' ? 'is-checked' : '' }}">
|
||||
<input type="radio" wire:model.live="publishMode" value="scheduled" class="sr-only" />
|
||||
<span class="dot-out"></span>
|
||||
<span class="flex-1">
|
||||
<span class="text-[12.5px] font-semibold text-[color:var(--color-ink-2)] leading-tight flex items-center gap-2">
|
||||
<span class="text-[12.5px] font-semibold text-[color:var(--color-hub)] leading-tight">
|
||||
{{ __('Geplanter Termin') }}
|
||||
<span class="pr-bald-badge">{{ __('bald') }}</span>
|
||||
</span>
|
||||
<span class="text-[11px] text-[color:var(--color-ink-3)] block mt-0.5 leading-tight">
|
||||
{{ __('Datum + Uhrzeit, automatische Veröffentlichung') }}
|
||||
</span>
|
||||
{{ __('Datum + Uhrzeit — wird automatisch zum Termin veröffentlicht') }}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
@if ($publishMode === 'scheduled')
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Veröffentlichungstermin') }}</flux:label>
|
||||
<flux:input
|
||||
wire:model.live="scheduledAt"
|
||||
type="datetime-local"
|
||||
:min="now()->addMinutes(5)->format('Y-m-d\\TH:i')"
|
||||
/>
|
||||
<flux:description>{{ __('Frühestens 5 Min. in der Zukunft.') }}</flux:description>
|
||||
<flux:error name="scheduledAt" />
|
||||
</flux:field>
|
||||
@endif
|
||||
|
||||
<div class="border-t pt-3" style="border-color: var(--color-line);">
|
||||
<flux:switch
|
||||
wire:model.live="useEmbargo"
|
||||
:label="__('Sperrfrist (Embargo) setzen')"
|
||||
/>
|
||||
<p class="text-[11px] text-[color:var(--color-ink-3)] mt-1 leading-tight">
|
||||
{{ __('Die PM ist intern frei, aber öffentlich erst ab gewähltem Zeitpunkt sichtbar.') }}
|
||||
</p>
|
||||
|
||||
@if ($useEmbargo)
|
||||
<flux:field class="mt-3">
|
||||
<flux:label>{{ __('Sperrfrist bis') }}</flux:label>
|
||||
<flux:input
|
||||
wire:model.live="embargoAt"
|
||||
type="datetime-local"
|
||||
:min="now()->format('Y-m-d\\TH:i')"
|
||||
/>
|
||||
<flux:error name="embargoAt" />
|
||||
</flux:field>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
|
|
@ -851,7 +1019,6 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
|||
</div>
|
||||
<ul class="text-[11.5px] text-[color:var(--color-accent-deep)] leading-[1.55] list-none p-0 m-0 space-y-1">
|
||||
<li>· {{ __('KI-Titel-Optimierung & KI-Lektorat live') }}</li>
|
||||
<li>· {{ __('Geplante Veröffentlichung / Scheduling') }}</li>
|
||||
<li>· {{ __('Versionshistorie & Kommentare') }}</li>
|
||||
<li>· {{ __('Portal-Vorschau (presseecho vs. BP24)') }}</li>
|
||||
</ul>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -6,6 +6,7 @@ use App\Models\PressRelease;
|
|||
use App\Services\Customer\CustomerCompanyContext;
|
||||
use App\Services\PressRelease\BlacklistViolationException;
|
||||
use App\Services\PressRelease\PressReleaseService;
|
||||
use Flux\Flux;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Title;
|
||||
|
|
@ -86,11 +87,20 @@ new #[Layout('components.layouts.app'), Title('Meine Pressemitteilungen')] class
|
|||
|
||||
try {
|
||||
app(PressReleaseService::class)->submitForReview($pr);
|
||||
session()->flash('success', __('Pressemitteilung zur Prüfung eingereicht.'));
|
||||
Flux::toast(
|
||||
heading: __('Eingereicht'),
|
||||
text: __('Pressemitteilung zur Prüfung eingereicht.'),
|
||||
variant: 'success',
|
||||
);
|
||||
} catch (BlacklistViolationException $e) {
|
||||
session()->flash('error', __('Pressemitteilung wurde automatisch abgelehnt: unzulässiges Wort ":word".', ['word' => $e->word]));
|
||||
Flux::toast(
|
||||
heading: __('Automatisch abgelehnt'),
|
||||
text: __('Unzulässiges Wort gefunden: ":word".', ['word' => $e->word]),
|
||||
variant: 'danger',
|
||||
duration: 8000,
|
||||
);
|
||||
} catch (\LogicException $e) {
|
||||
session()->flash('error', $e->getMessage());
|
||||
Flux::toast(text: $e->getMessage(), variant: 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -158,19 +168,7 @@ new #[Layout('components.layouts.app'), Title('Meine Pressemitteilungen')] class
|
|||
|
||||
<div class="space-y-8">
|
||||
|
||||
{{-- ============== FLASH ============== --}}
|
||||
@if (session('success'))
|
||||
<div class="px-4 py-3 rounded-[5px] border-l-[3px] text-[12.5px]"
|
||||
style="border-color: var(--color-ok); background: color-mix(in oklab, var(--color-ok) 10%, var(--color-bg)); color: var(--color-ink);">
|
||||
{{ session('success') }}
|
||||
</div>
|
||||
@endif
|
||||
@if (session('error'))
|
||||
<div class="px-4 py-3 rounded-[5px] border-l-[3px] text-[12.5px]"
|
||||
style="border-color: var(--color-err); background: color-mix(in oklab, var(--color-err) 12%, var(--color-bg)); color: var(--color-ink);">
|
||||
{{ session('error') }}
|
||||
</div>
|
||||
@endif
|
||||
{{-- Flash-Banner ersetzt durch <flux:toast /> im Layout. --}}
|
||||
|
||||
{{-- ============== PAGE HEADER ============== --}}
|
||||
<header class="grid items-end gap-8" style="grid-template-columns:1fr auto;">
|
||||
|
|
@ -504,6 +502,18 @@ new #[Layout('components.layouts.app'), Title('Meine Pressemitteilungen')] class
|
|||
<div class="text-[10.5px] text-[color:var(--color-ink-4)] mt-0.5">
|
||||
{{ $dateSubLabel }} · {{ $primaryDate?->format('H:i') }}
|
||||
</div>
|
||||
@if ($status === 'review' && $pr->scheduled_at && $pr->scheduled_at->isFuture())
|
||||
<div class="mt-1 inline-flex items-center gap-1 text-[10.5px] font-mono tabular-nums text-[color:var(--color-accent-deep)]">
|
||||
<flux:icon.calendar variant="micro" class="size-3" />
|
||||
<span>{{ __('geplant') }} · {{ $pr->scheduled_at->format('d.m. H:i') }}</span>
|
||||
</div>
|
||||
@endif
|
||||
@if ($pr->embargo_at && $pr->embargo_at->isFuture())
|
||||
<div class="mt-1 inline-flex items-center gap-1 text-[10.5px] font-mono tabular-nums text-[color:var(--color-accent-deep)]">
|
||||
<flux:icon.lock-closed variant="micro" class="size-3" />
|
||||
<span>{{ __('Embargo bis') }} {{ $pr->embargo_at->format('d.m.') }}</span>
|
||||
</div>
|
||||
@endif
|
||||
</flux:table.cell>
|
||||
|
||||
<flux:table.cell>
|
||||
|
|
@ -520,7 +530,7 @@ new #[Layout('components.layouts.app'), Title('Meine Pressemitteilungen')] class
|
|||
@endforeach
|
||||
</flux:table>
|
||||
<div class="border-t border-[color:var(--color-bg-rule)] p-4">
|
||||
{{ $pressReleases->links() }}
|
||||
{{ $pressReleases->links('components.portal.pagination') }}
|
||||
</div>
|
||||
@elseif ($hasAnyPR && $search !== '')
|
||||
{{-- Empty: Suche ohne Treffer --}}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ use App\Models\PressRelease;
|
|||
use App\Services\Auth\MagicLinkGenerator;
|
||||
use App\Services\PressRelease\BlacklistViolationException;
|
||||
use App\Services\PressRelease\PressReleaseService;
|
||||
use Flux\Flux;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Attributes\Title;
|
||||
|
|
@ -34,12 +35,21 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
try {
|
||||
app(PressReleaseService::class)->submitForReview($pr);
|
||||
} catch (BlacklistViolationException $e) {
|
||||
session()->flash('error', __('Pressemitteilung wurde automatisch abgelehnt: unzulässiges Wort ":word".', ['word' => $e->word]));
|
||||
Flux::toast(
|
||||
heading: __('Automatisch abgelehnt'),
|
||||
text: __('Unzulässiges Wort gefunden: ":word". Bitte überarbeiten.', ['word' => $e->word]),
|
||||
variant: 'danger',
|
||||
duration: 8000,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
session()->flash('success', __('Pressemitteilung zur Prüfung eingereicht.'));
|
||||
Flux::toast(
|
||||
heading: __('Eingereicht'),
|
||||
text: __('Die Redaktion prüft die Pressemitteilung typischerweise innerhalb von 24 h.'),
|
||||
variant: 'success',
|
||||
);
|
||||
}
|
||||
|
||||
public function generateShareLink(MagicLinkGenerator $generator): void
|
||||
|
|
@ -52,7 +62,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
$this->shareUrl = $share['url'];
|
||||
$this->shareExpiresAt = $share['expires_at']->format('d.m.Y H:i');
|
||||
|
||||
session()->flash('success', __('Vorschau-Link wurde erzeugt.'));
|
||||
Flux::toast(text: __('Vorschau-Link wurde erzeugt.'), variant: 'success');
|
||||
}
|
||||
|
||||
public function with(): array
|
||||
|
|
@ -114,18 +124,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
};
|
||||
@endphp
|
||||
|
||||
@if (session('success'))
|
||||
<div class="px-4 py-3 rounded-[5px] border text-[12.5px]
|
||||
bg-[color:var(--color-ok-soft)] border-[color:var(--color-ok)]/30 text-[color:var(--color-gain-deep)]">
|
||||
{{ session('success') }}
|
||||
</div>
|
||||
@endif
|
||||
@if (session('error'))
|
||||
<div class="px-4 py-3 rounded-[5px] border text-[12.5px]
|
||||
bg-[color:var(--color-err-soft)] border-[color:var(--color-err)]/30 text-[color:var(--color-loss)]">
|
||||
{{ session('error') }}
|
||||
</div>
|
||||
@endif
|
||||
{{-- Flash-Banner ersetzt durch <flux:toast /> im Layout. --}}
|
||||
|
||||
{{-- ============== PAGE HEADER ============== --}}
|
||||
<header class="grid items-end gap-8" style="grid-template-columns:1fr auto;">
|
||||
|
|
@ -139,6 +138,11 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
<h1 class="text-[30px] font-bold tracking-[-0.6px] leading-[1.15] m-0 text-[color:var(--color-ink)]">
|
||||
{{ $pr->title }}
|
||||
</h1>
|
||||
@if ($pr->subtitle)
|
||||
<p class="text-[18px] font-medium tracking-[-0.2px] leading-[1.35] mt-2 m-0 max-w-[720px] text-[color:var(--color-ink-2)]">
|
||||
{{ $pr->subtitle }}
|
||||
</p>
|
||||
@endif
|
||||
<p class="text-[13px] leading-[1.55] mt-2 m-0 max-w-[720px] text-[color:var(--color-ink-2)]">
|
||||
{{ $pr->company?->name ?? '–' }}
|
||||
<span class="text-[color:var(--color-bg-rule)] mx-1">·</span>
|
||||
|
|
@ -333,7 +337,30 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
{{ number_format($pr->hits, 0, ',', '.') }}
|
||||
</div>
|
||||
</div>
|
||||
@if ($pr->scheduled_at)
|
||||
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
|
||||
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Geplante Veröffentlichung') }}</div>
|
||||
<div class="text-[13px] font-semibold text-[color:var(--color-ink)] mt-1">
|
||||
{{ $pr->scheduled_at->format('d.m.Y H:i') }}
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@if ($pr->embargo_at)
|
||||
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
|
||||
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Sperrfrist bis') }}</div>
|
||||
<div class="text-[13px] font-semibold text-[color:var(--color-ink)] mt-1">
|
||||
{{ $pr->embargo_at->format('d.m.Y H:i') }}
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($pr->no_export)
|
||||
<div class="mt-3 flex items-center gap-2 text-[12px] text-[color:var(--color-ink-3)]">
|
||||
<flux:icon.no-symbol variant="micro" class="size-3.5" />
|
||||
<span>{{ __('Kein Export aktiv (PM wird nicht über Feeds verteilt).') }}</span>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="my-4 border-t border-[color:var(--color-bg-rule)]"></div>
|
||||
|
||||
|
|
@ -406,4 +433,22 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
@endif
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{{-- ============== BOILERPLATE-OVERRIDE ============== --}}
|
||||
@if ($pr->boilerplate_override)
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Eigener Abbinder (Boilerplate)') }}</span>
|
||||
<span class="badge hub">{{ __('Override') }}</span>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<p class="text-[12px] text-[color:var(--color-ink-3)] mt-0 mb-3">
|
||||
{{ __('Dieser Text wird für diese Pressemitteilung anstelle des Standard-Abbinders der Firma verwendet.') }}
|
||||
</p>
|
||||
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-4 text-[13px] leading-[1.6] text-[color:var(--color-ink-2)] whitespace-pre-line">
|
||||
{{ $pr->boilerplate_override }}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
@endif
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,20 +1,14 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Company;
|
||||
use App\Models\Profile;
|
||||
use App\Models\User;
|
||||
use App\Services\Image\ImageService;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Volt\Component;
|
||||
use Livewire\WithFileUploads;
|
||||
|
||||
new #[Layout('components.layouts.app'), Title('Mein Profil')] class extends Component
|
||||
{
|
||||
use WithFileUploads;
|
||||
|
||||
public string $name = '';
|
||||
|
||||
public string $language = 'de';
|
||||
|
|
@ -53,26 +47,6 @@ new #[Layout('components.layouts.app'), Title('Mein Profil')] class extends Comp
|
|||
|
||||
public string $billingCountryCode = 'DE';
|
||||
|
||||
public ?int $editableCompanyId = null;
|
||||
|
||||
public string $companyName = '';
|
||||
|
||||
public string $companyAddress = '';
|
||||
|
||||
public string $companyEmail = '';
|
||||
|
||||
public string $companyPhone = '';
|
||||
|
||||
public string $companyWebsite = '';
|
||||
|
||||
public string $companyCountryCode = 'DE';
|
||||
|
||||
public bool $companyDisableFooterCode = false;
|
||||
|
||||
public $companyLogo = null;
|
||||
|
||||
public bool $removeCompanyLogo = false;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
|
@ -81,7 +55,7 @@ new #[Layout('components.layouts.app'), Title('Mein Profil')] class extends Comp
|
|||
$this->name = (string) $user->name;
|
||||
$this->language = $user->language ?? 'de';
|
||||
|
||||
$this->salutationKey = (string) ($profile->salutation_key ?? 'none');
|
||||
$this->salutationKey = (string) ($profile?->salutation_key ?? 'none');
|
||||
$this->firstName = (string) ($profile?->first_name ?? '');
|
||||
$this->lastName = (string) ($profile?->last_name ?? '');
|
||||
$this->title = (string) ($profile?->title ?? '');
|
||||
|
|
@ -94,20 +68,12 @@ new #[Layout('components.layouts.app'), Title('Mein Profil')] class extends Comp
|
|||
$this->taxIdNumber = (string) ($profile?->tax_id_number ?? '');
|
||||
|
||||
$billingAddress = $user->billingAddress;
|
||||
$this->billingName = (string) ($billingAddress?->name ?? $user->name);
|
||||
$this->billingName = (string) ($billingAddress?->name ?? '');
|
||||
$this->billingAddress1 = (string) ($billingAddress?->address1 ?? '');
|
||||
$this->billingAddress2 = (string) ($billingAddress?->address2 ?? '');
|
||||
$this->billingPostalCode = (string) ($billingAddress?->postal_code ?? '');
|
||||
$this->billingCity = (string) ($billingAddress?->city ?? '');
|
||||
$this->billingCountryCode = (string) ($billingAddress?->country_code ?? 'DE');
|
||||
|
||||
$this->loadEditableCompany();
|
||||
}
|
||||
|
||||
public function selectCompany(int $companyId): void
|
||||
{
|
||||
$this->editableCompanyId = $companyId;
|
||||
$this->loadEditableCompany();
|
||||
}
|
||||
|
||||
public function saveProfile(): void
|
||||
|
|
@ -184,139 +150,20 @@ new #[Layout('components.layouts.app'), Title('Mein Profil')] class extends Comp
|
|||
session()->flash('profile-status', __('Profil gespeichert.'));
|
||||
}
|
||||
|
||||
public function saveCompany(ImageService $imageService): void
|
||||
{
|
||||
if (! $this->editableCompanyId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$company = $this->resolveEditableCompany($this->editableCompanyId);
|
||||
|
||||
if (! $company) {
|
||||
throw ValidationException::withMessages([
|
||||
'companyName' => __('Diese Firma kann von Ihnen nicht bearbeitet werden.'),
|
||||
]);
|
||||
}
|
||||
|
||||
$this->authorize('update', $company);
|
||||
|
||||
$validated = $this->validate([
|
||||
'companyName' => ['required', 'string', 'max:255'],
|
||||
'companyAddress' => ['nullable', 'string', 'max:1000'],
|
||||
'companyEmail' => ['nullable', 'email', 'max:190'],
|
||||
'companyPhone' => ['nullable', 'string', 'max:40'],
|
||||
'companyWebsite' => ['nullable', 'url', 'max:190'],
|
||||
'companyCountryCode' => ['nullable', 'string', 'size:2', Rule::in(array_keys((array) config('countries.items', [])))],
|
||||
'companyLogo' => ['nullable', 'image', 'max:'.(int) (ImageService::MAX_LOGO_BYTES / 1024)],
|
||||
]);
|
||||
|
||||
$company->fill([
|
||||
'name' => $validated['companyName'],
|
||||
'address' => $validated['companyAddress'] ?: null,
|
||||
'email' => $validated['companyEmail'] ?: null,
|
||||
'phone' => $validated['companyPhone'] ?: null,
|
||||
'website' => $validated['companyWebsite'] ?: null,
|
||||
'country_code' => $validated['companyCountryCode'] ?: null,
|
||||
'disable_footer_code' => $this->companyDisableFooterCode,
|
||||
]);
|
||||
|
||||
if ($this->removeCompanyLogo) {
|
||||
$imageService->deleteCompanyLogo($company->logo_path, $company->logo_variants);
|
||||
$company->logo_path = null;
|
||||
$company->logo_variants = null;
|
||||
}
|
||||
|
||||
if ($this->companyLogo) {
|
||||
$imageService->deleteCompanyLogo($company->logo_path, $company->logo_variants);
|
||||
|
||||
$stored = $imageService->storeCompanyLogo(
|
||||
$this->companyLogo,
|
||||
$company->portal?->value ?? 'presseecho',
|
||||
$company->id,
|
||||
);
|
||||
|
||||
$company->logo_path = $stored['path'];
|
||||
$company->logo_variants = $stored['variants'];
|
||||
}
|
||||
|
||||
$company->save();
|
||||
|
||||
$this->companyLogo = null;
|
||||
$this->removeCompanyLogo = false;
|
||||
|
||||
session()->flash('company-status', __('Firmendaten gespeichert.'));
|
||||
}
|
||||
|
||||
public function with(): array
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
$companies = $user->companies()
|
||||
->withPivot('role')
|
||||
->orderBy('name')
|
||||
->get(['companies.id', 'companies.name', 'companies.portal', 'companies.owner_user_id']);
|
||||
|
||||
return [
|
||||
'user' => $user,
|
||||
'companies' => $companies,
|
||||
'salutations' => collect((array) config('salutations.items', []))
|
||||
->map(fn (array $labels) => $labels[$user->language] ?? $labels['de'] ?? '')
|
||||
->all(),
|
||||
'countries' => (array) config('countries.items', []),
|
||||
'editableCompany' => $this->editableCompanyId
|
||||
? $this->resolveEditableCompany($this->editableCompanyId)
|
||||
: null,
|
||||
'billingComplete' => $this->billingIsComplete(),
|
||||
];
|
||||
}
|
||||
|
||||
private function loadEditableCompany(): void
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = auth()->user();
|
||||
|
||||
$editable = Company::query()
|
||||
->where(function ($query) use ($user): void {
|
||||
$query->where('owner_user_id', $user->id)
|
||||
->orWhereHas('users', fn ($q) => $q->whereKey($user->id)
|
||||
->whereIn('company_user.role', ['owner', 'responsible']));
|
||||
})
|
||||
->orderBy('name');
|
||||
|
||||
$company = $this->editableCompanyId
|
||||
? $editable->whereKey($this->editableCompanyId)->first()
|
||||
: $editable->first();
|
||||
|
||||
if (! $company) {
|
||||
$this->editableCompanyId = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->editableCompanyId = $company->id;
|
||||
$this->companyName = (string) $company->name;
|
||||
$this->companyAddress = (string) ($company->address ?? '');
|
||||
$this->companyEmail = (string) ($company->email ?? '');
|
||||
$this->companyPhone = (string) ($company->phone ?? '');
|
||||
$this->companyWebsite = (string) ($company->website ?? '');
|
||||
$this->companyCountryCode = (string) ($company->country_code ?? 'DE');
|
||||
$this->companyDisableFooterCode = (bool) $company->disable_footer_code;
|
||||
}
|
||||
|
||||
private function resolveEditableCompany(int $companyId): ?Company
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = auth()->user();
|
||||
|
||||
return Company::query()
|
||||
->where('id', $companyId)
|
||||
->where(function ($query) use ($user): void {
|
||||
$query->where('owner_user_id', $user->id)
|
||||
->orWhereHas('users', fn ($q) => $q->whereKey($user->id)
|
||||
->whereIn('company_user.role', ['owner', 'responsible']));
|
||||
})
|
||||
->first();
|
||||
}
|
||||
|
||||
public function billingHasInput(): bool
|
||||
{
|
||||
return filled($this->billingName)
|
||||
|
|
@ -347,10 +194,15 @@ new #[Layout('components.layouts.app'), Title('Mein Profil')] class extends Comp
|
|||
<h1 class="text-[30px] font-bold tracking-[-0.6px] leading-[1.15] m-0 text-[color:var(--color-ink)]">
|
||||
{{ __('Mein Profil') }}
|
||||
</h1>
|
||||
<p class="text-[13px] leading-[1.55] mt-2 m-0 max-w-[640px] text-[color:var(--color-ink-2)]">
|
||||
{{ __('Hier pflegen Sie Ihre persönlichen Konto- und Profildaten. Firmendaten verwalten Sie direkt in der jeweiligen Firma.') }}
|
||||
<p class="text-[13px] leading-[1.55] mt-2 m-0 max-w-[680px] text-[color:var(--color-ink-2)]">
|
||||
{{ __('Hier verwalten Sie Ihre Rechnungsadresse und persönlichen Profileinstellungen. Firmendaten liegen separat in der Firmenverwaltung.') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<flux:button size="sm" variant="ghost" icon="building-office" href="{{ route('me.press-kits.index') }}" wire:navigate>
|
||||
{{ __('Firmen verwalten') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (session('profile-status'))
|
||||
|
|
@ -362,60 +214,42 @@ new #[Layout('components.layouts.app'), Title('Mein Profil')] class extends Comp
|
|||
@endif
|
||||
|
||||
<form wire:submit="saveProfile" class="space-y-6">
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Konto') }}</span>
|
||||
</div>
|
||||
<div class="p-5 space-y-4">
|
||||
<flux:input wire:model="name" :label="__('Name')" required />
|
||||
<flux:input value="{{ $user->email }}" :label="__('E-Mail')" disabled :description="__('Änderung über Konto-Sicherheit möglich.')" />
|
||||
<flux:select wire:model="language" :label="__('Sprache')">
|
||||
<option value="de">Deutsch</option>
|
||||
<option value="en">English</option>
|
||||
</flux:select>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel" id="profil">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Profil') }}</span>
|
||||
</div>
|
||||
<div class="p-5 grid gap-4 sm:grid-cols-2">
|
||||
<flux:select wire:model="salutationKey" :label="__('Anrede')">
|
||||
@foreach ($salutations as $key => $label)
|
||||
<option value="{{ $key }}">{{ $label }}</option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
<flux:input wire:model="title" :label="__('Titel')" placeholder="Dr." />
|
||||
<flux:input wire:model="firstName" :label="__('Vorname')" />
|
||||
<flux:input wire:model="lastName" :label="__('Nachname')" />
|
||||
<flux:input wire:model="phone" :label="__('Telefon')" />
|
||||
<flux:input wire:model="backlinkUrl" :label="__('Backlink-URL')" placeholder="https://..." />
|
||||
<flux:checkbox wire:model="showStats" :label="__('Statistiken in Pressemitteilungen anzeigen')" class="sm:col-span-2" />
|
||||
<flux:checkbox wire:model="disableFooterCode" :label="__('Footer-Code in Pressemitteilungen deaktivieren')" class="sm:col-span-2" />
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<article class="panel" id="rechnungsadresse">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Rechnungsadresse') }}</span>
|
||||
@if ($billingComplete)
|
||||
<span class="badge ok dot">{{ __('vollständig') }}</span>
|
||||
@else
|
||||
<span class="badge warn dot">{{ __('unvollständig') }}</span>
|
||||
@endif
|
||||
</div>
|
||||
<div class="p-5 space-y-4">
|
||||
<p class="text-[12.5px] text-[color:var(--color-ink-3)] m-0">
|
||||
{{ __('Diese Angaben werden für künftige Rechnungen verwendet. Eine vollständige Rechnungsadresse benötigt Name, Adresse, PLZ, Ort und Land.') }}
|
||||
<div class="p-5 grid gap-6 lg:grid-cols-[0.8fr_1.2fr]">
|
||||
<div class="space-y-3">
|
||||
<p class="text-[13px] leading-[1.55] text-[color:var(--color-ink-2)] m-0">
|
||||
{{ __('Diese Adresse ist die maßgebliche Grundlage für Rechnungen und künftige Buchungen.') }}
|
||||
</p>
|
||||
<p class="text-[12.5px] leading-[1.55] text-[color:var(--color-ink-3)] m-0">
|
||||
{{ __('Pflichtangaben sind Rechnungsname, Adresse, PLZ, Ort und Land. Die USt-ID ist optional.') }}
|
||||
</p>
|
||||
|
||||
@if (! $this->billingIsComplete())
|
||||
@if (! $billingComplete)
|
||||
<div class="px-4 py-3 rounded-[5px] border text-[12.5px] flex items-start gap-3
|
||||
bg-[color:var(--color-warn-soft)] border-[color:var(--color-warn)]/30 text-[color:var(--color-ink-2)]">
|
||||
<flux:icon.exclamation-triangle class="size-[16px] flex-shrink-0 mt-0.5 text-[color:var(--color-accent-deep)]" />
|
||||
<div class="flex-1">
|
||||
{{ __('Rechnungsadresse noch unvollständig. Bitte ergänzen Sie die Pflichtangaben, bevor neue Buchungen sauber abgerechnet werden können.') }}
|
||||
{{ __('Bitte ergänzen Sie die Rechnungsadresse, damit neue Buchungen sauber abgerechnet werden können.') }}
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="px-4 py-3 rounded-[5px] border text-[12.5px] flex items-start gap-3
|
||||
bg-[color:var(--color-ok-soft)] border-[color:var(--color-ok)]/30 text-[color:var(--color-gain-deep)]">
|
||||
<flux:icon.check-circle class="size-[16px] flex-shrink-0 mt-0.5" />
|
||||
<div class="flex-1">
|
||||
{{ __('Ihre Rechnungsadresse ist vollständig hinterlegt.') }}
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<flux:input wire:model="billingName" :label="__('Rechnungsname')" class="sm:col-span-2" />
|
||||
|
|
@ -432,52 +266,86 @@ new #[Layout('components.layouts.app'), Title('Mein Profil')] class extends Comp
|
|||
<flux:error name="billingName" class="sm:col-span-2" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-5 py-4 border-t border-[color:var(--color-bg-rule)] flex justify-end">
|
||||
<flux:button type="submit" variant="primary">
|
||||
{{ __('Rechnungsadresse speichern') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
<article class="panel" id="profil">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Profileinstellungen') }}</span>
|
||||
</div>
|
||||
<div class="p-5 grid gap-4 sm:grid-cols-2">
|
||||
<flux:input wire:model="name" :label="__('Anzeigename')" required class="sm:col-span-2" />
|
||||
<flux:select wire:model="salutationKey" :label="__('Anrede')">
|
||||
@foreach ($salutations as $key => $label)
|
||||
<option value="{{ $key }}">{{ $label }}</option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
<flux:input wire:model="title" :label="__('Titel')" placeholder="Dr." />
|
||||
<flux:input wire:model="firstName" :label="__('Vorname')" />
|
||||
<flux:input wire:model="lastName" :label="__('Nachname')" />
|
||||
<flux:input wire:model="phone" :label="__('Telefon')" />
|
||||
<flux:input wire:model="backlinkUrl" :label="__('Backlink-URL')" placeholder="https://..." />
|
||||
<flux:textarea wire:model="address" :label="__('Adresse')" class="sm:col-span-2" />
|
||||
<flux:select wire:model="countryCode" :label="__('Land')" class="sm:col-span-2">
|
||||
@foreach ($countries as $code => $name)
|
||||
<option value="{{ $code }}">{{ $name }}</option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
</div>
|
||||
<div class="px-5 py-4 border-t border-[color:var(--color-bg-rule)] flex justify-end">
|
||||
<flux:button type="submit" variant="primary">
|
||||
{{ __('Profil speichern') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Aktionen') }}</span>
|
||||
<span class="section-eyebrow">{{ __('Konto & Sicherheit') }}</span>
|
||||
</div>
|
||||
<div class="p-5 flex justify-end">
|
||||
<flux:button type="submit" variant="primary">{{ __('Profil speichern') }}</flux:button>
|
||||
<div class="p-5 space-y-4">
|
||||
<flux:input value="{{ $user->email }}" :label="__('E-Mail')" disabled :description="__('Änderung über Konto-Sicherheit möglich.')" />
|
||||
<flux:select wire:model="language" :label="__('Sprache')">
|
||||
<option value="de">Deutsch</option>
|
||||
<option value="en">English</option>
|
||||
</flux:select>
|
||||
<div class="pt-3 border-t border-[color:var(--color-bg-rule)]">
|
||||
<flux:button size="sm" variant="ghost" icon="shield-check" href="{{ route('me.security') }}" wire:navigate>
|
||||
{{ __('Konto-Sicherheit öffnen') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Einstellungen') }}</span>
|
||||
</div>
|
||||
<div class="p-5 grid gap-4 md:grid-cols-2">
|
||||
<flux:switch
|
||||
wire:model="showStats"
|
||||
align="right"
|
||||
:label="__('Statistiken anzeigen')"
|
||||
:description="__('Statistiken und Kennzahlen in Ihren Pressemitteilungen anzeigen.')"
|
||||
/>
|
||||
<flux:switch
|
||||
wire:model="disableFooterCode"
|
||||
align="right"
|
||||
:label="__('Footer-Code deaktivieren')"
|
||||
:description="__('Automatische Footer-Codes in Pressemitteilungen für dieses Profil deaktivieren.')"
|
||||
/>
|
||||
</div>
|
||||
<div class="px-5 py-4 border-t border-[color:var(--color-bg-rule)] flex justify-end">
|
||||
<flux:button type="submit" variant="primary">
|
||||
{{ __('Einstellungen speichern') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</article>
|
||||
</form>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Zugeordnete Firmen') }}</span>
|
||||
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
|
||||
{{ $companies->count() }} {{ __('Einträge') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@forelse ($companies as $company)
|
||||
<div class="flex flex-col gap-2 px-5 py-3 border-b border-[color:var(--color-bg-rule)] last:border-0 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="space-y-1 min-w-0">
|
||||
<p class="text-[13px] font-semibold text-[color:var(--color-ink)] m-0">{{ $company->name }}</p>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="badge hub">{{ $company->portal?->label() ?? '–' }}</span>
|
||||
<span class="badge hub">{{ $company->pivot->role ?? 'member' }}</span>
|
||||
@if ($company->owner_user_id === $user->id)
|
||||
<span class="badge ok">{{ __('Eigentümer') }}</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@if ($company->owner_user_id === $user->id || in_array($company->pivot->role, ['owner', 'responsible'], true))
|
||||
<flux:button size="sm" variant="ghost" icon="arrow-right" href="{{ route('me.press-kits.show', $company->id) }}" wire:navigate>
|
||||
{{ __('Firma verwalten') }}
|
||||
</flux:button>
|
||||
@else
|
||||
<flux:button size="sm" variant="ghost" icon="arrow-right" href="{{ route('me.press-kits.show', $company->id) }}" wire:navigate>
|
||||
{{ __('Firma öffnen') }}
|
||||
</flux:button>
|
||||
@endif
|
||||
</div>
|
||||
@empty
|
||||
<div class="p-5 text-[12.5px] text-[color:var(--color-ink-3)]">
|
||||
{{ __('Keine Firmen zugeordnet. Bitte wenden Sie sich an den Administrator.') }}
|
||||
</div>
|
||||
@endforelse
|
||||
</article>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -7,8 +7,7 @@
|
|||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||
|
||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||
<link href="https://fonts.bunny.net/css?family=instrument-sans:400,500,600" rel="stylesheet" />
|
||||
@include('partials.local-fonts')
|
||||
|
||||
@vite(['resources/css/portal.css', 'resources/js/app.js'], 'build/portal')
|
||||
@livewireStyles
|
||||
|
|
|
|||
|
|
@ -7,10 +7,9 @@
|
|||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||
|
||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||
{{-- Hub × FluxUI Phase 1: Inter Tight + JetBrains Mono + Source Serif 4
|
||||
(Source Serif 4 nur für brand-mark in Headern, deshalb mitgeladen). --}}
|
||||
<link href="https://fonts.bunny.net/css?family=inter-tight:400,500,600,700|jetbrains-mono:400,500,600|source-serif-4:400,500,600,700" rel="stylesheet" />
|
||||
@include('partials.local-fonts')
|
||||
|
||||
{{-- Phase 1 Refinement: NUR portal.css einbinden — KEIN resources/js/app.js.
|
||||
app.js startet Alpine via `Alpine.start()`, aber @fluxScripts (am Ende
|
||||
|
|
|
|||
3
resources/views/partials/local-fonts.blade.php
Normal file
3
resources/views/partials/local-fonts.blade.php
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<link href="{{ asset('fonts/inter-tight/font.css') }}" rel="stylesheet">
|
||||
<link href="{{ asset('fonts/source-serif-4/font.css') }}" rel="stylesheet">
|
||||
<link href="{{ asset('fonts/jetbrains-mono/font.css') }}" rel="stylesheet">
|
||||
|
|
@ -12,7 +12,7 @@
|
|||
<link rel="icon" href="{{ asset(\App\Helpers\ThemeHelper::getFaviconPath()) }}">
|
||||
|
||||
<!-- Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||
@include('partials.local-fonts')
|
||||
|
||||
@php
|
||||
$font = \App\Helpers\ThemeHelper::getFont();
|
||||
|
|
@ -25,12 +25,6 @@
|
|||
<!-- Additional Styles -->
|
||||
@stack('styles')
|
||||
|
||||
<!-- Domain-spezifische Fonts -->
|
||||
@if ($font === 'Montserrat')
|
||||
<link href="https://fonts.bunny.net/css?family=montserrat:400,500,600,700" rel="stylesheet" />
|
||||
@else
|
||||
<link href="https://fonts.bunny.net/css?family=montserrat:400,500,600,700" rel="stylesheet" />
|
||||
@endif
|
||||
</head>
|
||||
|
||||
<body class="antialiased">
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
<link rel="icon" href="{{ asset(\App\Helpers\ThemeHelper::getFaviconPath()) }}">
|
||||
|
||||
<!-- Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||
@include('partials.local-fonts')
|
||||
|
||||
@php
|
||||
$font = \App\Helpers\ThemeHelper::getFont();
|
||||
|
|
@ -22,11 +22,6 @@
|
|||
|
||||
@vite([\App\Helpers\ThemeHelper::getThemeCssPath(), 'resources/js/app.js'], $domainConfig['assets_dir'] ?? 'build/web')
|
||||
|
||||
@if (in_array(($theme ?? null), ['businessportal24', 'presseecho', 'pressekonto'], true))
|
||||
<link rel="preconnect" href="https://fonts.bunny.net" crossorigin>
|
||||
<link href="https://fonts.bunny.net/css?family=inter-tight:400,500,600,700|source-serif-4:400,500,600,700|jetbrains-mono:400,500,600" rel="stylesheet" />
|
||||
@endif
|
||||
|
||||
<!-- Sticky Header Script -->
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
|
|
@ -71,10 +66,6 @@
|
|||
<!-- Additional Styles -->
|
||||
@stack('styles')
|
||||
|
||||
<!-- Domain-spezifische Fonts -->
|
||||
@if (! in_array(($theme ?? null), ['businessportal24', 'presseecho', 'pressekonto'], true))
|
||||
<link href="https://fonts.bunny.net/css?family=montserrat:400,500,600,700" rel="stylesheet" />
|
||||
@endif
|
||||
</head>
|
||||
|
||||
<body class="antialiased" style="background-color: hsl(var(--background)); color: hsl(var(--foreground));">
|
||||
|
|
|
|||
|
|
@ -20,8 +20,8 @@ Route::get('dashboard', DashboardController::class)
|
|||
// Nutzer-eigene Einstellungen (kein Admin erforderlich)
|
||||
// ========================================
|
||||
Route::middleware(['auth', 'verified'])->group(function () {
|
||||
Route::redirect('settings', 'settings/profile');
|
||||
Volt::route('settings/profile', 'settings.profile')->name('settings.profile');
|
||||
Route::redirect('settings', '/admin/me/profile');
|
||||
Route::redirect('settings/profile', '/admin/me/profile')->name('settings.profile');
|
||||
Volt::route('settings/password', 'settings.password')->name('settings.password');
|
||||
Volt::route('settings/appearance', 'settings.appearance')->name('settings.appearance');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<?php
|
||||
|
||||
use App\Console\Commands\PublishScheduledPressReleases;
|
||||
use App\Console\Commands\PurgeExpiredPressReleaseDrafts;
|
||||
use App\Console\Commands\PurgeMagicLinks;
|
||||
use Illuminate\Foundation\Inspiring;
|
||||
|
|
@ -27,3 +28,14 @@ Schedule::command(PurgeExpiredPressReleaseDrafts::class, ['--days=180'])
|
|||
->at('04:00')
|
||||
->withoutOverlapping()
|
||||
->runInBackground();
|
||||
|
||||
// ========================================
|
||||
// Geplante PM-Veröffentlichung
|
||||
// ========================================
|
||||
|
||||
// PM mit scheduled_at <= now & Status review automatisch veröffentlichen.
|
||||
// Läuft alle 5 Min — passt zum FormRule "scheduled_at min. 5 Min in Zukunft".
|
||||
Schedule::command(PublishScheduledPressReleases::class)
|
||||
->everyFiveMinutes()
|
||||
->withoutOverlapping()
|
||||
->runInBackground();
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ Route::middleware(['auth', 'verified', EnsureUserIsCustomer::class, LogSlowAdmin
|
|||
Volt::route('press-releases/{id}/edit', 'customer.press-releases.edit')->name('press-releases.edit');
|
||||
|
||||
Volt::route('firmen', 'customer.press-kits.index')->name('press-kits.index');
|
||||
Volt::route('firmen/anlegen', 'customer.press-kits.create')->name('press-kits.create');
|
||||
Volt::route('firmen/{id}', 'customer.press-kits.show')->name('press-kits.show');
|
||||
Route::redirect('pressemappen', '/admin/me/firmen', 301);
|
||||
Route::get('pressemappen/{id}', fn (string $id) => redirect("/admin/me/firmen/{$id}", 301))
|
||||
|
|
|
|||
|
|
@ -102,10 +102,70 @@ test('admin opens generated legacy invoice pdf inline', function () {
|
|||
LivewireVolt::test('admin.invoices.index')
|
||||
->assertSee('Öffnen');
|
||||
|
||||
$this->get(route('admin.legacy-invoices.pdf', $invoice))
|
||||
$response = $this->get(route('admin.legacy-invoices.pdf', $invoice));
|
||||
|
||||
$response
|
||||
->assertSuccessful()
|
||||
->assertHeader('content-type', 'application/pdf')
|
||||
->assertHeader('content-disposition', 'inline; filename="Presseecho-RNr-PE-2003.pdf"');
|
||||
|
||||
expect($response->content())
|
||||
->toContain('adametz.media')
|
||||
->toContain('Rechnungsbetrag')
|
||||
->toContain('/F2 21 Tf');
|
||||
|
||||
expect($invoice->refresh()->pdf_generated_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('admin opens legacy invoice pdf with stern layout for non media invoices', function () {
|
||||
/** @var TestCase $this */
|
||||
$invoice = LegacyInvoice::query()->create([
|
||||
'legacy_portal' => 'businessportal24',
|
||||
'legacy_id' => 2004,
|
||||
'user_id' => null,
|
||||
'legacy_user_id' => 504,
|
||||
'number' => 'BP-2004',
|
||||
'amount_cents' => 11900,
|
||||
'total_cents' => 11900,
|
||||
'status' => 'open',
|
||||
'invoice_date' => now()->subMonth(),
|
||||
'due_date' => now()->addDays(14),
|
||||
'payment_method' => 'invoice',
|
||||
'raw_snapshot' => [
|
||||
'number' => 'BP-2004',
|
||||
'is_media' => false,
|
||||
'service_period_begin_date' => now()->subYear()->toDateString(),
|
||||
'service_period_end_date' => now()->toDateString(),
|
||||
],
|
||||
'pdf_payload' => [
|
||||
'invoice' => [
|
||||
'is_media' => false,
|
||||
'service_period_begin_date' => now()->subYear()->toDateString(),
|
||||
'service_period_end_date' => now()->toDateString(),
|
||||
],
|
||||
'billing_address' => [
|
||||
'name' => 'Stern Archiv GmbH',
|
||||
'address' => 'Archivstrasse 2',
|
||||
'postal_code' => '10719',
|
||||
'city' => 'Berlin',
|
||||
'country_name' => 'Deutschland',
|
||||
],
|
||||
'payment_option_translation' => [
|
||||
'name' => 'Legacy Pressepaket',
|
||||
],
|
||||
],
|
||||
'imported_at' => now(),
|
||||
]);
|
||||
|
||||
$response = $this->get(route('admin.legacy-invoices.pdf', $invoice));
|
||||
|
||||
$response
|
||||
->assertSuccessful()
|
||||
->assertHeader('content-type', 'application/pdf')
|
||||
->assertHeader('content-disposition', 'inline; filename="Businessportal24-RNr-BP-2004.pdf"');
|
||||
|
||||
expect($response->content())
|
||||
->toContain('Stern Consulting GmbH')
|
||||
->toContain('Hypo Vereinsbank')
|
||||
->toContain('/F2 19 Tf');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -95,8 +95,7 @@ test('admin can change archived press release back to another status', function
|
|||
LivewireVolt::test('admin.press-releases.edit', ['id' => $pressRelease->id])
|
||||
->set('targetStatus', PressReleaseStatus::Draft->value)
|
||||
->call('changeStatus')
|
||||
->assertHasNoErrors()
|
||||
->assertSee('Status wurde auf');
|
||||
->assertHasNoErrors();
|
||||
|
||||
$pressRelease->refresh();
|
||||
|
||||
|
|
|
|||
217
tests/Feature/Admin/AdminPressReleaseFormFieldsTest.php
Normal file
217
tests/Feature/Admin/AdminPressReleaseFormFieldsTest.php
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\Company;
|
||||
use App\Models\Contact;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Livewire\Volt\Volt as LivewireVolt;
|
||||
use Tests\TestCase;
|
||||
|
||||
beforeEach(function (): void {
|
||||
/** @var TestCase $this */
|
||||
$this->seed(RolesAndPermissionsSeeder::class);
|
||||
});
|
||||
|
||||
function makeFieldsAdmin(): User
|
||||
{
|
||||
$admin = User::factory()->create(['is_active' => true]);
|
||||
$admin->assignRole('admin');
|
||||
|
||||
return $admin;
|
||||
}
|
||||
|
||||
test('admin create persistiert subtitle und boilerplate_override', function () {
|
||||
/** @var TestCase $this */
|
||||
$admin = makeFieldsAdmin();
|
||||
$company = Company::factory()->presseecho()->create([
|
||||
'boilerplate' => 'Firmen-Boilerplate (default).',
|
||||
]);
|
||||
$category = Category::factory()->create();
|
||||
$this->actingAs($admin);
|
||||
|
||||
LivewireVolt::test('admin.press-releases.create')
|
||||
->set('companyId', $company->id)
|
||||
->set('portal', $company->portal->value)
|
||||
->set('categoryId', $category->id)
|
||||
->set('title', 'PM mit Subtitle und Boilerplate')
|
||||
->set('subtitle', 'Eine knackige Dachzeile als Untertitel.')
|
||||
->set('text', str_repeat('Inhalt eines Tests mit ausreichend Länge. ', 5))
|
||||
->set('useBoilerplateOverride', true)
|
||||
->set('boilerplateOverride', 'Override-Boilerplate nur für diese PM.')
|
||||
->call('save')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$pr = PressRelease::query()->latest('id')->firstOrFail();
|
||||
|
||||
expect($pr->subtitle)->toBe('Eine knackige Dachzeile als Untertitel.');
|
||||
expect($pr->boilerplate_override)->toBe('Override-Boilerplate nur für diese PM.');
|
||||
});
|
||||
|
||||
test('admin create syncht ausgewählten Pressekontakt mit der PM', function () {
|
||||
/** @var TestCase $this */
|
||||
$admin = makeFieldsAdmin();
|
||||
$company = Company::factory()->presseecho()->create();
|
||||
$contact = Contact::factory()->for($company)->create([
|
||||
'first_name' => 'Max',
|
||||
'last_name' => 'Mustermann',
|
||||
'portal' => $company->portal->value,
|
||||
]);
|
||||
$category = Category::factory()->create();
|
||||
$this->actingAs($admin);
|
||||
|
||||
LivewireVolt::test('admin.press-releases.create')
|
||||
->set('companyId', $company->id)
|
||||
->set('portal', $company->portal->value)
|
||||
->set('categoryId', $category->id)
|
||||
->set('title', 'PM mit Kontakt')
|
||||
->set('text', str_repeat('Inhalt eines Tests. ', 5))
|
||||
->set('contactId', $contact->id)
|
||||
->call('save')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$pr = PressRelease::query()->latest('id')->firstOrFail();
|
||||
|
||||
expect($pr->contacts()->pluck('contacts.id')->all())->toBe([$contact->id]);
|
||||
});
|
||||
|
||||
test('admin create setzt default-Kontakt beim Firma-Wechsel', function () {
|
||||
/** @var TestCase $this */
|
||||
$admin = makeFieldsAdmin();
|
||||
$company = Company::factory()->presseecho()->create();
|
||||
$contact = Contact::factory()->for($company)->create([
|
||||
'first_name' => 'Anna',
|
||||
'last_name' => 'Aaron',
|
||||
'portal' => $company->portal->value,
|
||||
]);
|
||||
$this->actingAs($admin);
|
||||
|
||||
LivewireVolt::test('admin.press-releases.create')
|
||||
->set('companyId', $company->id)
|
||||
->assertSet('contactId', $contact->id);
|
||||
});
|
||||
|
||||
test('admin create addTag und removeTag schreiben keywords kommagetrennt', function () {
|
||||
/** @var TestCase $this */
|
||||
$admin = makeFieldsAdmin();
|
||||
$this->actingAs($admin);
|
||||
|
||||
$component = LivewireVolt::test('admin.press-releases.create')
|
||||
->call('addTag', 'Innovation')
|
||||
->call('addTag', 'Mittelstand')
|
||||
->call('addTag', 'Innovation')
|
||||
->assertSet('keywords', 'Innovation, Mittelstand');
|
||||
|
||||
$component->call('removeTag', 'Innovation')
|
||||
->assertSet('keywords', 'Mittelstand');
|
||||
});
|
||||
|
||||
test('admin create lehnt zu langen Subtitle ab', function () {
|
||||
/** @var TestCase $this */
|
||||
$admin = makeFieldsAdmin();
|
||||
$company = Company::factory()->presseecho()->create();
|
||||
$category = Category::factory()->create();
|
||||
$this->actingAs($admin);
|
||||
|
||||
LivewireVolt::test('admin.press-releases.create')
|
||||
->set('companyId', $company->id)
|
||||
->set('portal', $company->portal->value)
|
||||
->set('categoryId', $category->id)
|
||||
->set('title', 'Gültiger Titel hier')
|
||||
->set('subtitle', str_repeat('a', 300))
|
||||
->set('text', str_repeat('Inhalt eines Tests. ', 5))
|
||||
->call('save')
|
||||
->assertHasErrors(['subtitle']);
|
||||
});
|
||||
|
||||
test('admin edit hydratisiert subtitle, boilerplate_override und Kontakt', function () {
|
||||
/** @var TestCase $this */
|
||||
$admin = makeFieldsAdmin();
|
||||
$company = Company::factory()->presseecho()->create();
|
||||
$contact = Contact::factory()->for($company)->create([
|
||||
'first_name' => 'Pia',
|
||||
'last_name' => 'Presse',
|
||||
'portal' => $company->portal->value,
|
||||
]);
|
||||
|
||||
$pr = PressRelease::factory()->create([
|
||||
'company_id' => $company->id,
|
||||
'subtitle' => 'Hydrierter Untertitel.',
|
||||
'boilerplate_override' => 'Hydrierte Override-Boilerplate.',
|
||||
]);
|
||||
$pr->contacts()->sync([$contact->id]);
|
||||
|
||||
$this->actingAs($admin);
|
||||
|
||||
LivewireVolt::test('admin.press-releases.edit', ['id' => $pr->id])
|
||||
->assertSet('subtitle', 'Hydrierter Untertitel.')
|
||||
->assertSet('boilerplateOverride', 'Hydrierte Override-Boilerplate.')
|
||||
->assertSet('useBoilerplateOverride', true)
|
||||
->assertSet('contactId', $contact->id);
|
||||
});
|
||||
|
||||
test('admin edit speichert subtitle, boilerplate_override und Kontakt', function () {
|
||||
/** @var TestCase $this */
|
||||
$admin = makeFieldsAdmin();
|
||||
$company = Company::factory()->presseecho()->create();
|
||||
$contact = Contact::factory()->for($company)->create([
|
||||
'portal' => $company->portal->value,
|
||||
]);
|
||||
|
||||
$pr = PressRelease::factory()->create([
|
||||
'company_id' => $company->id,
|
||||
]);
|
||||
|
||||
$this->actingAs($admin);
|
||||
|
||||
LivewireVolt::test('admin.press-releases.edit', ['id' => $pr->id])
|
||||
->set('subtitle', 'Neuer Untertitel beim Bearbeiten.')
|
||||
->set('useBoilerplateOverride', true)
|
||||
->set('boilerplateOverride', 'Neue Override-Boilerplate beim Bearbeiten.')
|
||||
->set('contactId', $contact->id)
|
||||
->call('save')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$pr->refresh();
|
||||
expect($pr->subtitle)->toBe('Neuer Untertitel beim Bearbeiten.');
|
||||
expect($pr->boilerplate_override)->toBe('Neue Override-Boilerplate beim Bearbeiten.');
|
||||
expect($pr->contacts()->pluck('contacts.id')->all())->toBe([$contact->id]);
|
||||
});
|
||||
|
||||
test('admin edit zeigt Pre-Submit-Check-Berechnungen', function () {
|
||||
/** @var TestCase $this */
|
||||
$admin = makeFieldsAdmin();
|
||||
$company = Company::factory()->presseecho()->create();
|
||||
$category = Category::factory()->create();
|
||||
|
||||
$pr = PressRelease::factory()->create([
|
||||
'company_id' => $company->id,
|
||||
'category_id' => $category->id,
|
||||
'title' => 'Vollständiger Titel mit ausreichend Zeichen für ok-Status',
|
||||
'text' => str_repeat('Inhalt eines Tests mit ausreichend Länge. ', 25),
|
||||
]);
|
||||
|
||||
$this->actingAs($admin);
|
||||
|
||||
LivewireVolt::test('admin.press-releases.edit', ['id' => $pr->id])
|
||||
->assertSee('Pre-Submit-Check')
|
||||
->assertSee('Titel vorhanden')
|
||||
->assertSee('Mindestlänge Fließtext erreicht')
|
||||
->assertSee('Firma zugeordnet')
|
||||
->assertSee('Kategorie gewählt')
|
||||
->assertSee('Pressekontakt zugeordnet')
|
||||
->assertSee('Themen-Tags vergeben');
|
||||
});
|
||||
|
||||
test('admin edit zeigt Untertitel-Feld', function () {
|
||||
/** @var TestCase $this */
|
||||
$admin = makeFieldsAdmin();
|
||||
$pr = PressRelease::factory()->create();
|
||||
|
||||
$this->actingAs($admin);
|
||||
|
||||
LivewireVolt::test('admin.press-releases.edit', ['id' => $pr->id])
|
||||
->assertSee('Untertitel')
|
||||
->assertSee('Themen-Tags');
|
||||
});
|
||||
141
tests/Feature/Admin/AdminPressReleaseSchedulingTest.php
Normal file
141
tests/Feature/Admin/AdminPressReleaseSchedulingTest.php
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\Company;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Livewire\Volt\Volt as LivewireVolt;
|
||||
use Tests\TestCase;
|
||||
|
||||
beforeEach(function (): void {
|
||||
/** @var TestCase $this */
|
||||
$this->seed(RolesAndPermissionsSeeder::class);
|
||||
});
|
||||
|
||||
function makeAdmin(): User
|
||||
{
|
||||
$admin = User::factory()->create(['is_active' => true]);
|
||||
$admin->assignRole('admin');
|
||||
|
||||
return $admin;
|
||||
}
|
||||
|
||||
test('admin create form persistiert scheduled_at und embargo_at', function () {
|
||||
/** @var TestCase $this */
|
||||
Carbon::setTestNow('2026-06-01 10:00:00');
|
||||
|
||||
$admin = makeAdmin();
|
||||
$company = Company::factory()->presseecho()->create();
|
||||
$category = Category::factory()->create();
|
||||
$this->actingAs($admin);
|
||||
|
||||
LivewireVolt::test('admin.press-releases.create')
|
||||
->set('title', 'Admin Scheduled PM')
|
||||
->set('text', str_repeat('Inhalt eines Tests. ', 5))
|
||||
->set('companyId', $company->id)
|
||||
->set('categoryId', $category->id)
|
||||
->set('portal', $company->portal->value)
|
||||
->set('publishMode', 'scheduled')
|
||||
->set('scheduledAt', '2026-06-05T14:30')
|
||||
->set('useEmbargo', true)
|
||||
->set('embargoAt', '2026-06-10T08:00')
|
||||
->call('save')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$pr = PressRelease::query()->latest('id')->firstOrFail();
|
||||
|
||||
expect($pr->scheduled_at?->toDateTimeString())->toBe('2026-06-05 14:30:00');
|
||||
expect($pr->embargo_at?->toDateTimeString())->toBe('2026-06-10 08:00:00');
|
||||
});
|
||||
|
||||
test('admin create form lehnt scheduled_at in der Vergangenheit ab', function () {
|
||||
/** @var TestCase $this */
|
||||
Carbon::setTestNow('2026-06-01 10:00:00');
|
||||
|
||||
$admin = makeAdmin();
|
||||
$company = Company::factory()->presseecho()->create();
|
||||
$category = Category::factory()->create();
|
||||
$this->actingAs($admin);
|
||||
|
||||
LivewireVolt::test('admin.press-releases.create')
|
||||
->set('title', 'Past Date PM')
|
||||
->set('text', str_repeat('Inhalt eines Tests. ', 5))
|
||||
->set('companyId', $company->id)
|
||||
->set('categoryId', $category->id)
|
||||
->set('portal', $company->portal->value)
|
||||
->set('publishMode', 'scheduled')
|
||||
->set('scheduledAt', '2026-05-15T10:00')
|
||||
->call('save')
|
||||
->assertHasErrors(['scheduledAt']);
|
||||
});
|
||||
|
||||
test('admin edit hydriert scheduled_at und embargo_at', function () {
|
||||
/** @var TestCase $this */
|
||||
Carbon::setTestNow('2026-06-01 10:00:00');
|
||||
|
||||
$admin = makeAdmin();
|
||||
$this->actingAs($admin);
|
||||
|
||||
$pr = PressRelease::factory()->create([
|
||||
'scheduled_at' => '2026-06-05 14:30:00',
|
||||
'embargo_at' => '2026-06-10 08:00:00',
|
||||
]);
|
||||
|
||||
LivewireVolt::test('admin.press-releases.edit', ['id' => $pr->id])
|
||||
->assertSet('publishMode', 'scheduled')
|
||||
->assertSet('scheduledAt', '2026-06-05T14:30')
|
||||
->assertSet('useEmbargo', true)
|
||||
->assertSet('embargoAt', '2026-06-10T08:00');
|
||||
});
|
||||
|
||||
test('admin edit persistiert scheduled_at und embargo_at', function () {
|
||||
/** @var TestCase $this */
|
||||
Carbon::setTestNow('2026-06-01 10:00:00');
|
||||
|
||||
$admin = makeAdmin();
|
||||
$this->actingAs($admin);
|
||||
|
||||
$pr = PressRelease::factory()->create([
|
||||
'scheduled_at' => null,
|
||||
'embargo_at' => null,
|
||||
]);
|
||||
|
||||
LivewireVolt::test('admin.press-releases.edit', ['id' => $pr->id])
|
||||
->set('publishMode', 'scheduled')
|
||||
->set('scheduledAt', '2026-06-08T09:00')
|
||||
->set('useEmbargo', true)
|
||||
->set('embargoAt', '2026-06-12T12:00')
|
||||
->call('save')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$pr->refresh();
|
||||
|
||||
expect($pr->scheduled_at?->toDateTimeString())->toBe('2026-06-08 09:00:00');
|
||||
expect($pr->embargo_at?->toDateTimeString())->toBe('2026-06-12 12:00:00');
|
||||
});
|
||||
|
||||
test('admin publishMode now clears scheduled_at on save', function () {
|
||||
/** @var TestCase $this */
|
||||
Carbon::setTestNow('2026-06-01 10:00:00');
|
||||
|
||||
$admin = makeAdmin();
|
||||
$this->actingAs($admin);
|
||||
|
||||
$pr = PressRelease::factory()->create([
|
||||
'scheduled_at' => '2026-06-05 14:30:00',
|
||||
'embargo_at' => '2026-06-10 08:00:00',
|
||||
]);
|
||||
|
||||
LivewireVolt::test('admin.press-releases.edit', ['id' => $pr->id])
|
||||
->set('publishMode', 'now')
|
||||
->set('useEmbargo', false)
|
||||
->call('save')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$pr->refresh();
|
||||
|
||||
expect($pr->scheduled_at)->toBeNull();
|
||||
expect($pr->embargo_at)->toBeNull();
|
||||
});
|
||||
124
tests/Feature/Admin/AdminPressReleaseShowTest.php
Normal file
124
tests/Feature/Admin/AdminPressReleaseShowTest.php
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Models\Company;
|
||||
use App\Models\Contact;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\PressReleaseStatusLog;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Livewire\Volt\Volt as LivewireVolt;
|
||||
use Tests\TestCase;
|
||||
|
||||
beforeEach(function (): void {
|
||||
/** @var TestCase $this */
|
||||
$this->seed(RolesAndPermissionsSeeder::class);
|
||||
});
|
||||
|
||||
function makeAdminForShow(): User
|
||||
{
|
||||
$admin = User::factory()->create(['is_active' => true]);
|
||||
$admin->assignRole('admin');
|
||||
|
||||
return $admin;
|
||||
}
|
||||
|
||||
test('admin show rendert Rejection-Banner mit letzter Begründung', function () {
|
||||
/** @var TestCase $this */
|
||||
$admin = makeAdminForShow();
|
||||
$this->actingAs($admin);
|
||||
|
||||
$pr = PressRelease::factory()->create([
|
||||
'status' => PressReleaseStatus::Rejected->value,
|
||||
]);
|
||||
PressReleaseStatusLog::query()->create([
|
||||
'press_release_id' => $pr->id,
|
||||
'changed_by_user_id' => $admin->id,
|
||||
'from_status' => PressReleaseStatus::Review->value,
|
||||
'to_status' => PressReleaseStatus::Rejected->value,
|
||||
'reason' => 'Werbliche Sprache, bitte überarbeiten.',
|
||||
'source' => 'admin',
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
LivewireVolt::test('admin.press-releases.show', ['id' => $pr->id])
|
||||
->assertSee('Diese Pressemitteilung wurde abgelehnt')
|
||||
->assertSee('Werbliche Sprache, bitte überarbeiten.');
|
||||
});
|
||||
|
||||
test('admin show zeigt zugeordnete Pressekontakte', function () {
|
||||
/** @var TestCase $this */
|
||||
$admin = makeAdminForShow();
|
||||
$this->actingAs($admin);
|
||||
|
||||
$company = Company::factory()->presseecho()->create();
|
||||
$contact = Contact::factory()->for($company)->create([
|
||||
'first_name' => 'Max',
|
||||
'last_name' => 'Mustermann',
|
||||
'responsibility' => 'Pressesprecher',
|
||||
'email' => 'presse@example.test',
|
||||
'portal' => $company->portal->value,
|
||||
]);
|
||||
|
||||
$pr = PressRelease::factory()->create(['company_id' => $company->id]);
|
||||
$pr->contacts()->sync([$contact->id]);
|
||||
|
||||
LivewireVolt::test('admin.press-releases.show', ['id' => $pr->id])
|
||||
->assertSee('Zugeordnete Pressekontakte')
|
||||
->assertSee('Max Mustermann')
|
||||
->assertSee('Pressesprecher')
|
||||
->assertSee('presse@example.test');
|
||||
});
|
||||
|
||||
test('admin show zeigt Hinweis bei fehlenden Kontakten', function () {
|
||||
/** @var TestCase $this */
|
||||
$admin = makeAdminForShow();
|
||||
$this->actingAs($admin);
|
||||
|
||||
$pr = PressRelease::factory()->create();
|
||||
|
||||
LivewireVolt::test('admin.press-releases.show', ['id' => $pr->id])
|
||||
->assertSee('Dieser Pressemitteilung ist kein Pressekontakt zugeordnet.');
|
||||
});
|
||||
|
||||
test('admin show zeigt Scheduling-Termin im Review-Workflow', function () {
|
||||
/** @var TestCase $this */
|
||||
$admin = makeAdminForShow();
|
||||
$this->actingAs($admin);
|
||||
|
||||
$pr = PressRelease::factory()->create([
|
||||
'status' => PressReleaseStatus::Review->value,
|
||||
'scheduled_at' => '2026-06-15 10:00:00',
|
||||
]);
|
||||
|
||||
LivewireVolt::test('admin.press-releases.show', ['id' => $pr->id])
|
||||
->assertSee('Geplante Veröffentlichung')
|
||||
->assertSee('15.06.2026 10:00');
|
||||
});
|
||||
|
||||
test('admin show zeigt Embargo-Info im Published-Workflow', function () {
|
||||
/** @var TestCase $this */
|
||||
$admin = makeAdminForShow();
|
||||
$this->actingAs($admin);
|
||||
|
||||
$pr = PressRelease::factory()->create([
|
||||
'status' => PressReleaseStatus::Published->value,
|
||||
'published_at' => '2026-06-01 10:00:00',
|
||||
'embargo_at' => now()->addDays(10),
|
||||
]);
|
||||
|
||||
LivewireVolt::test('admin.press-releases.show', ['id' => $pr->id])
|
||||
->assertSee('Sperrfrist bis');
|
||||
});
|
||||
|
||||
test('admin show zeigt Autor im Status-Verlauf-Grid', function () {
|
||||
/** @var TestCase $this */
|
||||
$admin = makeAdminForShow();
|
||||
$this->actingAs($admin);
|
||||
|
||||
$author = User::factory()->create(['name' => 'Anna Autorin']);
|
||||
$pr = PressRelease::factory()->create(['user_id' => $author->id]);
|
||||
|
||||
LivewireVolt::test('admin.press-releases.show', ['id' => $pr->id])
|
||||
->assertSee('Anna Autorin');
|
||||
});
|
||||
|
|
@ -7,6 +7,7 @@ use App\Models\Profile;
|
|||
use App\Models\User;
|
||||
use App\Models\UserFilterPreset;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Volt\Volt as LivewireVolt;
|
||||
use Tests\TestCase;
|
||||
|
|
@ -555,6 +556,61 @@ test('admin can open user details modal from users index and see company link st
|
|||
->assertSet('contactLookup', '');
|
||||
});
|
||||
|
||||
test('admin users index free text search matches names and email parts', function () {
|
||||
/** @var TestCase $this */
|
||||
$this->seed(RolesAndPermissionsSeeder::class);
|
||||
|
||||
$admin = User::factory()->create();
|
||||
$admin->assignRole('admin');
|
||||
|
||||
User::factory()->create([
|
||||
'name' => 'Seabstian Einrock',
|
||||
'email' => 'info@connectar.de',
|
||||
])->assignRole('customer');
|
||||
|
||||
User::factory()->create([
|
||||
'name' => 'Barbara Barr',
|
||||
'email' => 'barbara@example.com',
|
||||
])->assignRole('customer');
|
||||
|
||||
$this->actingAs($admin);
|
||||
|
||||
LivewireVolt::test('admin.users')
|
||||
->set('search', 'Seabstian')
|
||||
->assertSee('info@connectar.de')
|
||||
->assertDontSee('barbara@example.com')
|
||||
->set('search', 'Einrock')
|
||||
->assertSee('info@connectar.de')
|
||||
->assertDontSee('barbara@example.com')
|
||||
->set('search', 'connectar.de')
|
||||
->assertSee('Seabstian Einrock')
|
||||
->assertDontSee('Barbara Barr')
|
||||
->set('search', 'Seabstian Einrock info@connectar.de')
|
||||
->assertSee('info@connectar.de')
|
||||
->assertDontSee('barbara@example.com')
|
||||
->set('search', 'Barr')
|
||||
->assertSee('barbara@example.com')
|
||||
->assertDontSee('info@connectar.de');
|
||||
});
|
||||
|
||||
test('admin users index uses full count pagination', function () {
|
||||
/** @var TestCase $this */
|
||||
$this->seed(RolesAndPermissionsSeeder::class);
|
||||
|
||||
$admin = User::factory()->create();
|
||||
$admin->assignRole('admin');
|
||||
|
||||
User::factory()->count(51)->create();
|
||||
|
||||
$this->actingAs($admin);
|
||||
|
||||
LivewireVolt::test('admin.users')
|
||||
->assertViewHas('users', fn ($users): bool => $users instanceof LengthAwarePaginator
|
||||
&& $users->perPage() === 50
|
||||
&& $users->total() === 52
|
||||
&& $users->lastPage() === 2);
|
||||
});
|
||||
|
||||
test('admin users index supports workflow filters and quality badges', function () {
|
||||
/** @var TestCase $this */
|
||||
$this->seed(RolesAndPermissionsSeeder::class);
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@ use Database\Seeders\RolesAndPermissionsSeeder;
|
|||
use Livewire\Volt\Volt as LivewireVolt;
|
||||
use Tests\TestCase;
|
||||
|
||||
beforeEach(function () {
|
||||
beforeEach(function (): void {
|
||||
/** @var TestCase $this */
|
||||
$this->seed(RolesAndPermissionsSeeder::class);
|
||||
});
|
||||
|
||||
|
|
@ -75,6 +76,31 @@ test('press release list and KPI counts render when data exists', function () {
|
|||
->assertSee('Alle anzeigen');
|
||||
});
|
||||
|
||||
test('company dashboard preview is limited to the ten newest companies', function () {
|
||||
/** @var TestCase $this */
|
||||
$customer = User::factory()->create(['is_active' => true]);
|
||||
$customer->assignRole('customer');
|
||||
|
||||
for ($i = 1; $i <= 12; $i++) {
|
||||
Company::factory()->create([
|
||||
'owner_user_id' => $customer->id,
|
||||
'name' => sprintf('Dashboard Firma %02d', $i),
|
||||
'created_at' => now()->subDays(13 - $i),
|
||||
]);
|
||||
}
|
||||
|
||||
$this->actingAs($customer);
|
||||
|
||||
LivewireVolt::test('customer.dashboard')
|
||||
->assertSee('12 zugeordnet')
|
||||
->assertSee('Dashboard Firma 12')
|
||||
->assertSee('Dashboard Firma 03')
|
||||
->assertDontSee('Dashboard Firma 02')
|
||||
->assertDontSee('Dashboard Firma 01')
|
||||
->assertSee('Die zehn neuesten Firmen werden hier als Vorschau angezeigt.')
|
||||
->assertSee(route('me.press-kits.index'), false);
|
||||
});
|
||||
|
||||
test('profile completeness hint with percentage appears for partial profiles', function () {
|
||||
/** @var TestCase $this */
|
||||
$customer = User::factory()->create(['is_active' => true]);
|
||||
|
|
|
|||
|
|
@ -53,6 +53,28 @@ test('customer company switcher links to selected company detail', function () {
|
|||
->assertSee(route('me.press-kits.show', $company->id, absolute: false));
|
||||
});
|
||||
|
||||
test('customer company switcher limits rendered company options', function () {
|
||||
/** @var TestCase $this */
|
||||
$customer = User::factory()->create(['is_active' => true]);
|
||||
|
||||
for ($i = 1; $i <= 55; $i++) {
|
||||
Company::factory()->presseecho()->create([
|
||||
'name' => sprintf('Switcher Firma %02d', $i),
|
||||
'owner_user_id' => $customer->id,
|
||||
'created_at' => now()->subDays(56 - $i),
|
||||
]);
|
||||
}
|
||||
|
||||
$this->actingAs($customer);
|
||||
|
||||
LivewireVolt::test('customer.company-switcher')
|
||||
->assertViewHas('companies', fn ($companies) => $companies->count() === 50)
|
||||
->assertSee('Switcher Firma 55')
|
||||
->assertSee('Switcher Firma 06')
|
||||
->assertDontSee('Switcher Firma 05')
|
||||
->assertSee('Weitere Firmen über');
|
||||
});
|
||||
|
||||
test('customer press release list is filtered by selected company context', function () {
|
||||
/** @var TestCase $this */
|
||||
$customer = User::factory()->create(['is_active' => true]);
|
||||
|
|
|
|||
105
tests/Feature/CustomerPressKitCreatePhase8eTest.php
Normal file
105
tests/Feature/CustomerPressKitCreatePhase8eTest.php
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\Portal;
|
||||
use App\Models\Company;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Livewire\Volt\Volt as LivewireVolt;
|
||||
use Tests\TestCase;
|
||||
|
||||
beforeEach(function (): void {
|
||||
/** @var TestCase $this */
|
||||
$this->seed(RolesAndPermissionsSeeder::class);
|
||||
});
|
||||
|
||||
function makeCustomerForCreate(): User
|
||||
{
|
||||
$customer = User::factory()->create(['is_active' => true]);
|
||||
$customer->assignRole('customer');
|
||||
|
||||
return $customer;
|
||||
}
|
||||
|
||||
test('Create-Route ist für eingeloggte Customer erreichbar', function () {
|
||||
/** @var TestCase $this */
|
||||
$customer = makeCustomerForCreate();
|
||||
|
||||
$this->actingAs($customer)
|
||||
->get('/admin/me/firmen/anlegen')
|
||||
->assertOk()
|
||||
->assertSeeText('Neue Firma anlegen')
|
||||
->assertSeeText('Firmenname');
|
||||
});
|
||||
|
||||
test('Create-Route ist für Gäste nicht zugänglich', function () {
|
||||
/** @var TestCase $this */
|
||||
$this->get('/admin/me/firmen/anlegen')
|
||||
->assertRedirect();
|
||||
});
|
||||
|
||||
test('save erstellt eine Firma und ordnet sie dem User als Owner zu', function () {
|
||||
/** @var TestCase $this */
|
||||
$customer = makeCustomerForCreate();
|
||||
|
||||
LivewireVolt::actingAs($customer)
|
||||
->test('customer.press-kits.create')
|
||||
->set('name', 'Neue Brauerei AG')
|
||||
->set('portal', 'presseecho')
|
||||
->set('type', 'company')
|
||||
->set('email', 'kontakt@brauerei.de')
|
||||
->call('save')
|
||||
->assertHasNoErrors()
|
||||
->assertRedirect();
|
||||
|
||||
/** @var Company $company */
|
||||
$company = Company::query()->withoutGlobalScopes()->where('name', 'Neue Brauerei AG')->first();
|
||||
|
||||
expect($company)->not->toBeNull()
|
||||
->and($company->owner_user_id)->toBe($customer->id)
|
||||
->and($company->portal->value)->toBe('presseecho')
|
||||
->and($company->email)->toBe('kontakt@brauerei.de')
|
||||
->and($company->is_active)->toBeTrue();
|
||||
|
||||
expect($customer->companies()->where('companies.id', $company->id)->wherePivot('role', 'owner')->exists())
|
||||
->toBeTrue();
|
||||
});
|
||||
|
||||
test('save validiert Pflichtfelder Name und Portal', function () {
|
||||
/** @var TestCase $this */
|
||||
$customer = makeCustomerForCreate();
|
||||
|
||||
LivewireVolt::actingAs($customer)
|
||||
->test('customer.press-kits.create')
|
||||
->set('name', '')
|
||||
->set('portal', '')
|
||||
->call('save')
|
||||
->assertHasErrors(['name' => 'required', 'portal' => 'required']);
|
||||
});
|
||||
|
||||
test('save lehnt unbekannte Portal-Werte ab', function () {
|
||||
/** @var TestCase $this */
|
||||
$customer = makeCustomerForCreate();
|
||||
|
||||
LivewireVolt::actingAs($customer)
|
||||
->test('customer.press-kits.create')
|
||||
->set('name', 'Test GmbH')
|
||||
->set('portal', 'invalid')
|
||||
->call('save')
|
||||
->assertHasErrors(['portal']);
|
||||
});
|
||||
|
||||
test('save akzeptiert Portal Both', function () {
|
||||
/** @var TestCase $this */
|
||||
$customer = makeCustomerForCreate();
|
||||
|
||||
LivewireVolt::actingAs($customer)
|
||||
->test('customer.press-kits.create')
|
||||
->set('name', 'Beide Portale GmbH')
|
||||
->set('portal', 'both')
|
||||
->set('type', 'company')
|
||||
->call('save')
|
||||
->assertHasNoErrors();
|
||||
|
||||
expect(Company::query()->withoutGlobalScopes()->where('name', 'Beide Portale GmbH')->value('portal'))
|
||||
->toBe(Portal::Both);
|
||||
});
|
||||
309
tests/Feature/CustomerPressKitIndexPhase8eTest.php
Normal file
309
tests/Feature/CustomerPressKitIndexPhase8eTest.php
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Company;
|
||||
use App\Models\Contact;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Livewire\Volt\Volt as LivewireVolt;
|
||||
use Tests\TestCase;
|
||||
|
||||
beforeEach(function (): void {
|
||||
/** @var TestCase $this */
|
||||
$this->seed(RolesAndPermissionsSeeder::class);
|
||||
});
|
||||
|
||||
/**
|
||||
* @return array{customer: User}
|
||||
*/
|
||||
function makeCustomerForPressKitsIndex(): array
|
||||
{
|
||||
$customer = User::factory()->create(['is_active' => true]);
|
||||
$customer->assignRole('customer');
|
||||
|
||||
return ['customer' => $customer];
|
||||
}
|
||||
|
||||
test('Index zeigt Counter-Strip mit Firmen, aktiven, PMs und Kontakten', function () {
|
||||
/** @var TestCase $this */
|
||||
['customer' => $customer] = makeCustomerForPressKitsIndex();
|
||||
|
||||
$activeOne = Company::factory()->presseecho()->create();
|
||||
$customer->companies()->attach($activeOne->id, ['role' => 'owner']);
|
||||
Contact::factory()->count(2)->for($activeOne)->create(['portal' => $activeOne->portal->value]);
|
||||
PressRelease::factory()->count(3)->for($activeOne)->create([
|
||||
'user_id' => $customer->id,
|
||||
'portal' => $activeOne->portal->value,
|
||||
'status' => 'draft',
|
||||
]);
|
||||
|
||||
$activeTwo = Company::factory()->businessportal24()->create();
|
||||
$customer->companies()->attach($activeTwo->id, ['role' => 'responsible']);
|
||||
Contact::factory()->for($activeTwo)->create(['portal' => $activeTwo->portal->value]);
|
||||
|
||||
$inactive = Company::factory()->presseecho()->inactive()->create();
|
||||
$customer->companies()->attach($inactive->id, ['role' => 'member']);
|
||||
|
||||
$this->actingAs($customer)
|
||||
->get('/admin/me/firmen')
|
||||
->assertOk()
|
||||
->assertSeeInOrder(['<b>3</b>', 'Firmen'], false)
|
||||
->assertSeeInOrder(['<b>2</b>', 'aktiv'], false)
|
||||
->assertSeeInOrder(['<b>3</b>', 'Pressemitteilungen gesamt'], false)
|
||||
->assertSeeInOrder(['<b>3</b>', 'Pressekontakte hinterlegt'], false);
|
||||
});
|
||||
|
||||
test('Saved-View Aktiv filtert nur is_active = true Firmen', function () {
|
||||
/** @var TestCase $this */
|
||||
['customer' => $customer] = makeCustomerForPressKitsIndex();
|
||||
|
||||
$active = Company::factory()->presseecho()->create(['name' => 'AlphaAktiv GmbH']);
|
||||
$customer->companies()->attach($active->id, ['role' => 'owner']);
|
||||
|
||||
$inactive = Company::factory()->presseecho()->inactive()->create(['name' => 'ZetaInaktiv GmbH']);
|
||||
$customer->companies()->attach($inactive->id, ['role' => 'owner']);
|
||||
|
||||
LivewireVolt::actingAs($customer)
|
||||
->test('customer.press-kits.index')
|
||||
->call('setSavedView', 'active')
|
||||
->assertSet('savedView', 'active')
|
||||
->assertSee('AlphaAktiv GmbH')
|
||||
->assertDontSee('ZetaInaktiv GmbH');
|
||||
});
|
||||
|
||||
test('Saved-View Inaktiv filtert nur is_active = false Firmen', function () {
|
||||
/** @var TestCase $this */
|
||||
['customer' => $customer] = makeCustomerForPressKitsIndex();
|
||||
|
||||
$active = Company::factory()->presseecho()->create(['name' => 'AlphaAktiv GmbH']);
|
||||
$customer->companies()->attach($active->id, ['role' => 'owner']);
|
||||
|
||||
$inactive = Company::factory()->presseecho()->inactive()->create(['name' => 'ZetaInaktiv GmbH']);
|
||||
$customer->companies()->attach($inactive->id, ['role' => 'owner']);
|
||||
|
||||
LivewireVolt::actingAs($customer)
|
||||
->test('customer.press-kits.index')
|
||||
->call('setSavedView', 'inactive')
|
||||
->assertSee('ZetaInaktiv GmbH')
|
||||
->assertDontSee('AlphaAktiv GmbH');
|
||||
});
|
||||
|
||||
test('Saved-View Geteilt zeigt nur Firmen, bei denen User nicht Owner ist', function () {
|
||||
/** @var TestCase $this */
|
||||
['customer' => $customer] = makeCustomerForPressKitsIndex();
|
||||
$owner = User::factory()->create();
|
||||
|
||||
$ownCompany = Company::factory()->presseecho()->create([
|
||||
'name' => 'EigeneFirma GmbH',
|
||||
'owner_user_id' => $customer->id,
|
||||
]);
|
||||
|
||||
$sharedCompany = Company::factory()->presseecho()->create([
|
||||
'name' => 'GeteilteFirma GmbH',
|
||||
'owner_user_id' => $owner->id,
|
||||
]);
|
||||
$customer->companies()->attach($sharedCompany->id, ['role' => 'responsible']);
|
||||
|
||||
LivewireVolt::actingAs($customer)
|
||||
->test('customer.press-kits.index')
|
||||
->call('setSavedView', 'shared')
|
||||
->assertSee('GeteilteFirma GmbH')
|
||||
->assertDontSee('EigeneFirma GmbH');
|
||||
});
|
||||
|
||||
test('Portal-Filter zeigt nur Firmen des gewählten Portals (oder both)', function () {
|
||||
/** @var TestCase $this */
|
||||
['customer' => $customer] = makeCustomerForPressKitsIndex();
|
||||
|
||||
$pe = Company::factory()->presseecho()->create(['name' => 'PresseechoCo GmbH']);
|
||||
$bp = Company::factory()->businessportal24()->create(['name' => 'BusinessportalCo GmbH']);
|
||||
$customer->companies()->attach([
|
||||
$pe->id => ['role' => 'owner'],
|
||||
$bp->id => ['role' => 'owner'],
|
||||
]);
|
||||
|
||||
LivewireVolt::actingAs($customer)
|
||||
->test('customer.press-kits.index')
|
||||
->call('setPortalFilter', 'presseecho')
|
||||
->assertSee('PresseechoCo GmbH')
|
||||
->assertDontSee('BusinessportalCo GmbH');
|
||||
});
|
||||
|
||||
test('Rollen-Filter Owner zeigt nur Firmen, in denen User Owner ist', function () {
|
||||
/** @var TestCase $this */
|
||||
['customer' => $customer] = makeCustomerForPressKitsIndex();
|
||||
$otherOwner = User::factory()->create();
|
||||
|
||||
$owned = Company::factory()->presseecho()->create([
|
||||
'name' => 'EigeneFirma GmbH',
|
||||
'owner_user_id' => $customer->id,
|
||||
]);
|
||||
|
||||
$memberCompany = Company::factory()->presseecho()->create([
|
||||
'name' => 'MitgliedFirma GmbH',
|
||||
'owner_user_id' => $otherOwner->id,
|
||||
]);
|
||||
$customer->companies()->attach($memberCompany->id, ['role' => 'member']);
|
||||
|
||||
LivewireVolt::actingAs($customer)
|
||||
->test('customer.press-kits.index')
|
||||
->call('setRoleFilter', 'owner')
|
||||
->assertSee('EigeneFirma GmbH')
|
||||
->assertDontSee('MitgliedFirma GmbH');
|
||||
});
|
||||
|
||||
test('Suche filtert auf Firmennamen', function () {
|
||||
/** @var TestCase $this */
|
||||
['customer' => $customer] = makeCustomerForPressKitsIndex();
|
||||
|
||||
$alpha = Company::factory()->presseecho()->create(['name' => 'Alpha Brauerei AG']);
|
||||
$beta = Company::factory()->presseecho()->create(['name' => 'Beta Verlag GmbH']);
|
||||
$customer->companies()->attach([
|
||||
$alpha->id => ['role' => 'owner'],
|
||||
$beta->id => ['role' => 'owner'],
|
||||
]);
|
||||
|
||||
LivewireVolt::actingAs($customer)
|
||||
->test('customer.press-kits.index')
|
||||
->set('search', 'Brauerei')
|
||||
->assertSee('Alpha Brauerei AG')
|
||||
->assertDontSee('Beta Verlag GmbH');
|
||||
});
|
||||
|
||||
test('View-Mode kann auf list umgeschaltet werden', function () {
|
||||
/** @var TestCase $this */
|
||||
['customer' => $customer] = makeCustomerForPressKitsIndex();
|
||||
$company = Company::factory()->presseecho()->create(['name' => 'FirmaA GmbH']);
|
||||
$customer->companies()->attach($company->id, ['role' => 'owner']);
|
||||
|
||||
LivewireVolt::actingAs($customer)
|
||||
->test('customer.press-kits.index')
|
||||
->call('setViewMode', 'list')
|
||||
->assertSet('viewMode', 'list')
|
||||
->assertSee('FirmaA GmbH')
|
||||
->assertSee('cursor-pointer', false)
|
||||
->assertSee('firm-list-actions', false);
|
||||
});
|
||||
|
||||
test('Empty-State noch-keine-Firma wird ohne Filter und ohne Firmen gezeigt', function () {
|
||||
/** @var TestCase $this */
|
||||
['customer' => $customer] = makeCustomerForPressKitsIndex();
|
||||
|
||||
$this->actingAs($customer)
|
||||
->get('/admin/me/firmen')
|
||||
->assertOk()
|
||||
->assertSeeText('Noch keine Firma angelegt');
|
||||
});
|
||||
|
||||
test('Empty-State Filter-ohne-Treffer wird bei aktiven Filtern ohne Match gezeigt', function () {
|
||||
/** @var TestCase $this */
|
||||
['customer' => $customer] = makeCustomerForPressKitsIndex();
|
||||
$company = Company::factory()->presseecho()->create(['name' => 'Alpha GmbH']);
|
||||
$customer->companies()->attach($company->id, ['role' => 'owner']);
|
||||
|
||||
LivewireVolt::actingAs($customer)
|
||||
->test('customer.press-kits.index')
|
||||
->set('search', 'Zeta')
|
||||
->assertSee('Keine Firmen mit diesen Filtern');
|
||||
});
|
||||
|
||||
test('Filter zurücksetzen leert alle Filter und Suche', function () {
|
||||
/** @var TestCase $this */
|
||||
['customer' => $customer] = makeCustomerForPressKitsIndex();
|
||||
$company = Company::factory()->presseecho()->create(['name' => 'Alpha GmbH']);
|
||||
$customer->companies()->attach($company->id, ['role' => 'owner']);
|
||||
|
||||
LivewireVolt::actingAs($customer)
|
||||
->test('customer.press-kits.index')
|
||||
->set('search', 'Zeta')
|
||||
->call('setSavedView', 'inactive')
|
||||
->call('setPortalFilter', 'presseecho')
|
||||
->call('setRoleFilter', 'member')
|
||||
->call('resetFilters')
|
||||
->assertSet('search', '')
|
||||
->assertSet('savedView', 'all')
|
||||
->assertSet('portalFilter', '')
|
||||
->assertSet('roleFilter', 'all')
|
||||
->assertSee('Alpha GmbH');
|
||||
});
|
||||
|
||||
test('Add-Tile wird nur auf der letzten Seite des Card-Grids gerendert', function () {
|
||||
/** @var TestCase $this */
|
||||
['customer' => $customer] = makeCustomerForPressKitsIndex();
|
||||
$company = Company::factory()->presseecho()->create(['name' => 'Alpha GmbH']);
|
||||
$customer->companies()->attach($company->id, ['role' => 'owner']);
|
||||
|
||||
LivewireVolt::actingAs($customer)
|
||||
->test('customer.press-kits.index')
|
||||
->assertSee('Neue Firma anlegen');
|
||||
});
|
||||
|
||||
test('Firmenübersicht paginiert mit 50 Firmen pro Seite', function () {
|
||||
/** @var TestCase $this */
|
||||
['customer' => $customer] = makeCustomerForPressKitsIndex();
|
||||
|
||||
for ($i = 1; $i <= 51; $i++) {
|
||||
Company::factory()->presseecho()->create([
|
||||
'name' => sprintf('Paginiert Firma %02d', $i),
|
||||
'owner_user_id' => $customer->id,
|
||||
]);
|
||||
}
|
||||
|
||||
LivewireVolt::actingAs($customer)
|
||||
->test('customer.press-kits.index')
|
||||
->assertViewHas('pressKits', fn ($pressKits) => $pressKits->perPage() === 50 && $pressKits->count() === 50)
|
||||
->assertSee('portal-pagination', false)
|
||||
->assertSee('aria-current="page"', false)
|
||||
->assertSee('cursor-pointer', false)
|
||||
->assertSee('Paginiert Firma 01')
|
||||
->assertSee('Paginiert Firma 50')
|
||||
->assertDontSee('Paginiert Firma 51');
|
||||
});
|
||||
|
||||
test('Karte zeigt Status Aktiv, Portal-Pills und KPIs', function () {
|
||||
/** @var TestCase $this */
|
||||
['customer' => $customer] = makeCustomerForPressKitsIndex();
|
||||
$company = Company::factory()->presseecho()->create([
|
||||
'name' => 'Brauerei AG',
|
||||
'is_active' => true,
|
||||
]);
|
||||
$customer->companies()->attach($company->id, ['role' => 'owner']);
|
||||
Contact::factory()->count(2)->for($company)->create(['portal' => $company->portal->value]);
|
||||
PressRelease::factory()->count(4)->for($company)->create([
|
||||
'user_id' => $customer->id,
|
||||
'portal' => $company->portal->value,
|
||||
'status' => 'draft',
|
||||
]);
|
||||
|
||||
$this->actingAs($customer)
|
||||
->get('/admin/me/firmen')
|
||||
->assertOk()
|
||||
->assertSeeText('Aktiv')
|
||||
->assertSeeText('Brauerei AG')
|
||||
->assertSee('presseecho')
|
||||
->assertSeeText('PMs');
|
||||
});
|
||||
|
||||
test('Rollen-Legende wird unterhalb der Liste angezeigt', function () {
|
||||
/** @var TestCase $this */
|
||||
['customer' => $customer] = makeCustomerForPressKitsIndex();
|
||||
|
||||
$this->actingAs($customer)
|
||||
->get('/admin/me/firmen')
|
||||
->assertOk()
|
||||
->assertSeeText('Rollen pro Firma');
|
||||
});
|
||||
|
||||
test('Performance-Indexe für die Firmenübersicht sind vorhanden', function () {
|
||||
$companyIndexes = collect(Schema::getIndexes('companies'))->pluck('name');
|
||||
$pressReleaseIndexes = collect(Schema::getIndexes('press_releases'))->pluck('name');
|
||||
|
||||
expect($companyIndexes)
|
||||
->toContain('companies_owner_name_id_idx')
|
||||
->toContain('companies_owner_active_name_id_idx')
|
||||
->and($pressReleaseIndexes)
|
||||
->toContain('press_releases_company_published_idx')
|
||||
->toContain('press_releases_user_created_id_idx')
|
||||
->toContain('press_releases_user_status_created_idx');
|
||||
});
|
||||
|
|
@ -97,7 +97,7 @@ test('save with all required fields persists the press release and syncs contact
|
|||
expect($pr->contacts->first()->id)->toBe($contact->id);
|
||||
});
|
||||
|
||||
test('save without a contact id fails validation', function () {
|
||||
test('save without a contact id succeeds and leaves contacts empty', function () {
|
||||
/** @var TestCase $this */
|
||||
$customer = User::factory()->create(['is_active' => true]);
|
||||
$customer->assignRole('customer');
|
||||
|
|
@ -113,7 +113,28 @@ test('save without a contact id fails validation', function () {
|
|||
->set('text', str_repeat('x', 60))
|
||||
->set('categoryId', $category->id)
|
||||
->call('save', 'draft')
|
||||
->assertHasErrors(['contactId']);
|
||||
->assertHasNoErrors(['contactId']);
|
||||
|
||||
$pr = PressRelease::query()->where('title', 'Titel mit genug Zeichen')->firstOrFail();
|
||||
expect($pr->contacts()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('presubmit check for missing contact is warning, not error', function () {
|
||||
/** @var TestCase $this */
|
||||
$customer = User::factory()->create(['is_active' => true]);
|
||||
$customer->assignRole('customer');
|
||||
$company = Company::factory()->presseecho()->create();
|
||||
$customer->companies()->attach($company->id, ['role' => 'owner']);
|
||||
|
||||
$this->actingAs($customer);
|
||||
|
||||
$component = LivewireVolt::test('customer.press-releases.create')
|
||||
->set('contactId', null);
|
||||
|
||||
$checks = collect($component->instance()->presubmitChecks);
|
||||
$contactCheck = $checks->firstWhere('key', 'contact');
|
||||
|
||||
expect($contactCheck['status'])->toBe('warn');
|
||||
});
|
||||
|
||||
test('boilerplate override is null when toggle is off even if text is filled', function () {
|
||||
|
|
|
|||
190
tests/Feature/CustomerPressReleaseEditPhase7Test.php
Normal file
190
tests/Feature/CustomerPressReleaseEditPhase7Test.php
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\Company;
|
||||
use App\Models\Contact;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Livewire\Volt\Volt as LivewireVolt;
|
||||
use Tests\TestCase;
|
||||
|
||||
beforeEach(function (): void {
|
||||
/** @var TestCase $this */
|
||||
$this->seed(RolesAndPermissionsSeeder::class);
|
||||
});
|
||||
|
||||
function makeCustomerWithPressRelease(array $prAttributes = []): array
|
||||
{
|
||||
$customer = User::factory()->create(['is_active' => true]);
|
||||
$customer->assignRole('customer');
|
||||
|
||||
$company = Company::factory()->presseecho()->create();
|
||||
$customer->companies()->attach($company->id, ['role' => 'owner']);
|
||||
|
||||
$contact = Contact::factory()->for($company)->create([
|
||||
'portal' => $company->portal->value,
|
||||
]);
|
||||
$category = Category::factory()->create();
|
||||
|
||||
$pr = PressRelease::factory()->create(array_merge([
|
||||
'user_id' => $customer->id,
|
||||
'company_id' => $company->id,
|
||||
'category_id' => $category->id,
|
||||
'portal' => $company->portal->value,
|
||||
'status' => 'draft',
|
||||
], $prAttributes));
|
||||
|
||||
$pr->contacts()->sync([$contact->id]);
|
||||
|
||||
return compact('customer', 'company', 'contact', 'category', 'pr');
|
||||
}
|
||||
|
||||
test('mount loads all Phase 7 fields and pivot contact', function () {
|
||||
/** @var TestCase $this */
|
||||
['customer' => $customer, 'pr' => $pr, 'contact' => $contact] = makeCustomerWithPressRelease([
|
||||
'subtitle' => 'Untertitel der PM',
|
||||
'boilerplate_override' => 'Spezielle Boilerplate.',
|
||||
'keywords' => 'Test, Brauerei',
|
||||
]);
|
||||
|
||||
$this->actingAs($customer);
|
||||
|
||||
LivewireVolt::test('customer.press-releases.edit', ['id' => $pr->id])
|
||||
->assertSet('subtitle', 'Untertitel der PM')
|
||||
->assertSet('boilerplateOverride', 'Spezielle Boilerplate.')
|
||||
->assertSet('useBoilerplateOverride', true)
|
||||
->assertSet('keywords', 'Test, Brauerei')
|
||||
->assertSet('contactId', $contact->id)
|
||||
->assertSet('currentStatus', 'draft');
|
||||
});
|
||||
|
||||
test('mount falls back to first company contact when no pivot exists', function () {
|
||||
/** @var TestCase $this */
|
||||
['customer' => $customer, 'pr' => $pr, 'contact' => $contact] = makeCustomerWithPressRelease();
|
||||
|
||||
$pr->contacts()->detach();
|
||||
|
||||
$this->actingAs($customer);
|
||||
|
||||
LivewireVolt::test('customer.press-releases.edit', ['id' => $pr->id])
|
||||
->assertSet('contactId', $contact->id);
|
||||
});
|
||||
|
||||
test('save persists all new Phase 7 fields and syncs contact', function () {
|
||||
/** @var TestCase $this */
|
||||
['customer' => $customer, 'pr' => $pr] = makeCustomerWithPressRelease();
|
||||
$newContact = Contact::factory()->for($pr->company)->create([
|
||||
'portal' => $pr->company->portal->value,
|
||||
]);
|
||||
|
||||
$this->actingAs($customer);
|
||||
|
||||
LivewireVolt::test('customer.press-releases.edit', ['id' => $pr->id])
|
||||
->set('title', 'Aktualisierter Titel mit genug Zeichen')
|
||||
->set('subtitle', 'Neue Subline')
|
||||
->set('text', str_repeat('Aktualisierter Fließtext mit etwas Inhalt. ', 5))
|
||||
->set('keywords', 'Neu, Frisch')
|
||||
->set('contactId', $newContact->id)
|
||||
->set('useBoilerplateOverride', true)
|
||||
->set('boilerplateOverride', 'Override-Text für diese PM.')
|
||||
->call('save')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$pr->refresh();
|
||||
|
||||
expect($pr->title)->toBe('Aktualisierter Titel mit genug Zeichen');
|
||||
expect($pr->subtitle)->toBe('Neue Subline');
|
||||
expect($pr->boilerplate_override)->toBe('Override-Text für diese PM.');
|
||||
expect($pr->keywords)->toBe('Neu, Frisch');
|
||||
expect($pr->contacts->pluck('id')->all())->toBe([$newContact->id]);
|
||||
});
|
||||
|
||||
test('save without contact id succeeds and detaches existing contact', function () {
|
||||
/** @var TestCase $this */
|
||||
['customer' => $customer, 'pr' => $pr] = makeCustomerWithPressRelease();
|
||||
|
||||
$this->actingAs($customer);
|
||||
|
||||
LivewireVolt::test('customer.press-releases.edit', ['id' => $pr->id])
|
||||
->set('contactId', null)
|
||||
->call('save')
|
||||
->assertHasNoErrors(['contactId']);
|
||||
|
||||
expect($pr->fresh()->contacts()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('save nulls boilerplate_override when toggle is turned off', function () {
|
||||
/** @var TestCase $this */
|
||||
['customer' => $customer, 'pr' => $pr] = makeCustomerWithPressRelease([
|
||||
'boilerplate_override' => 'Alter Override-Text',
|
||||
]);
|
||||
|
||||
$this->actingAs($customer);
|
||||
|
||||
LivewireVolt::test('customer.press-releases.edit', ['id' => $pr->id])
|
||||
->assertSet('useBoilerplateOverride', true)
|
||||
->set('useBoilerplateOverride', false)
|
||||
->call('save')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$pr->refresh();
|
||||
|
||||
expect($pr->boilerplate_override)->toBeNull();
|
||||
});
|
||||
|
||||
test('changing the company resets the contact to the new company default', function () {
|
||||
/** @var TestCase $this */
|
||||
['customer' => $customer, 'pr' => $pr] = makeCustomerWithPressRelease();
|
||||
|
||||
$secondCompany = Company::factory()->presseecho()->create();
|
||||
$customer->companies()->attach($secondCompany->id, ['role' => 'owner']);
|
||||
$secondContact = Contact::factory()->for($secondCompany)->create([
|
||||
'portal' => $secondCompany->portal->value,
|
||||
]);
|
||||
|
||||
$this->actingAs($customer);
|
||||
|
||||
LivewireVolt::test('customer.press-releases.edit', ['id' => $pr->id])
|
||||
->set('companyId', $secondCompany->id)
|
||||
->assertSet('contactId', $secondContact->id);
|
||||
});
|
||||
|
||||
test('rejected press releases can be edited and re-submitted', function () {
|
||||
/** @var TestCase $this */
|
||||
['customer' => $customer, 'pr' => $pr] = makeCustomerWithPressRelease([
|
||||
'status' => 'rejected',
|
||||
]);
|
||||
|
||||
$this->actingAs($customer);
|
||||
|
||||
LivewireVolt::test('customer.press-releases.edit', ['id' => $pr->id])
|
||||
->assertSet('currentStatus', 'rejected');
|
||||
});
|
||||
|
||||
test('foreign press release returns 403 on mount', function () {
|
||||
/** @var TestCase $this */
|
||||
['pr' => $pr] = makeCustomerWithPressRelease();
|
||||
$stranger = User::factory()->create(['is_active' => true]);
|
||||
$stranger->assignRole('customer');
|
||||
|
||||
$this->actingAs($stranger);
|
||||
|
||||
$this->get(route('me.press-releases.edit', $pr->id))
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
test('addTag and removeTag work in edit form', function () {
|
||||
/** @var TestCase $this */
|
||||
['customer' => $customer, 'pr' => $pr] = makeCustomerWithPressRelease([
|
||||
'keywords' => 'Eins',
|
||||
]);
|
||||
|
||||
$this->actingAs($customer);
|
||||
|
||||
LivewireVolt::test('customer.press-releases.edit', ['id' => $pr->id])
|
||||
->call('addTag', 'Zwei')
|
||||
->assertSet('keywords', 'Eins, Zwei')
|
||||
->call('removeTag', 'Eins')
|
||||
->assertSet('keywords', 'Zwei');
|
||||
});
|
||||
135
tests/Feature/CustomerPressReleaseSchedulingFormTest.php
Normal file
135
tests/Feature/CustomerPressReleaseSchedulingFormTest.php
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\Company;
|
||||
use App\Models\Contact;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Livewire\Volt\Volt as LivewireVolt;
|
||||
use Tests\TestCase;
|
||||
|
||||
beforeEach(function (): void {
|
||||
/** @var TestCase $this */
|
||||
$this->seed(RolesAndPermissionsSeeder::class);
|
||||
});
|
||||
|
||||
function makeSchedulingCustomer(): array
|
||||
{
|
||||
$customer = User::factory()->create(['is_active' => true]);
|
||||
$customer->assignRole('customer');
|
||||
|
||||
$company = Company::factory()->presseecho()->create();
|
||||
$customer->companies()->attach($company->id, ['role' => 'owner']);
|
||||
|
||||
$contact = Contact::factory()->for($company)->create([
|
||||
'portal' => $company->portal->value,
|
||||
]);
|
||||
$category = Category::factory()->create();
|
||||
|
||||
return compact('customer', 'company', 'contact', 'category');
|
||||
}
|
||||
|
||||
test('create form persistiert scheduled_at und embargo_at', function () {
|
||||
/** @var TestCase $this */
|
||||
Carbon::setTestNow('2026-06-01 10:00:00');
|
||||
|
||||
['customer' => $customer, 'category' => $category] = makeSchedulingCustomer();
|
||||
$this->actingAs($customer);
|
||||
|
||||
LivewireVolt::test('customer.press-releases.create')
|
||||
->set('title', 'Phase 7F Scheduling Demo')
|
||||
->set('text', str_repeat('Inhalt eines Tests. ', 5))
|
||||
->set('categoryId', $category->id)
|
||||
->set('publishMode', 'scheduled')
|
||||
->set('scheduledAt', '2026-06-05T14:30')
|
||||
->set('useEmbargo', true)
|
||||
->set('embargoAt', '2026-06-10T08:00')
|
||||
->call('save')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$pr = PressRelease::query()->latest('id')->firstOrFail();
|
||||
|
||||
expect($pr->scheduled_at?->toDateTimeString())->toBe('2026-06-05 14:30:00');
|
||||
expect($pr->embargo_at?->toDateTimeString())->toBe('2026-06-10 08:00:00');
|
||||
});
|
||||
|
||||
test('create form lehnt scheduled_at in der Vergangenheit ab', function () {
|
||||
/** @var TestCase $this */
|
||||
Carbon::setTestNow('2026-06-01 10:00:00');
|
||||
|
||||
['customer' => $customer, 'category' => $category] = makeSchedulingCustomer();
|
||||
$this->actingAs($customer);
|
||||
|
||||
LivewireVolt::test('customer.press-releases.create')
|
||||
->set('title', 'Vergangene Veröffentlichung')
|
||||
->set('text', str_repeat('Inhalt eines Tests. ', 5))
|
||||
->set('categoryId', $category->id)
|
||||
->set('publishMode', 'scheduled')
|
||||
->set('scheduledAt', '2026-05-30T10:00')
|
||||
->call('save')
|
||||
->assertHasErrors(['scheduledAt']);
|
||||
});
|
||||
|
||||
test('create form lehnt embargo_at in der Vergangenheit ab', function () {
|
||||
/** @var TestCase $this */
|
||||
Carbon::setTestNow('2026-06-01 10:00:00');
|
||||
|
||||
['customer' => $customer, 'category' => $category] = makeSchedulingCustomer();
|
||||
$this->actingAs($customer);
|
||||
|
||||
LivewireVolt::test('customer.press-releases.create')
|
||||
->set('title', 'Vergangenes Embargo')
|
||||
->set('text', str_repeat('Inhalt eines Tests. ', 5))
|
||||
->set('categoryId', $category->id)
|
||||
->set('useEmbargo', true)
|
||||
->set('embargoAt', '2026-05-30T10:00')
|
||||
->call('save')
|
||||
->assertHasErrors(['embargoAt']);
|
||||
});
|
||||
|
||||
test('publishMode now setzt scheduled_at auf null beim Save', function () {
|
||||
/** @var TestCase $this */
|
||||
['customer' => $customer, 'category' => $category] = makeSchedulingCustomer();
|
||||
$this->actingAs($customer);
|
||||
|
||||
LivewireVolt::test('customer.press-releases.create')
|
||||
->set('title', 'Sofort veröffentlichen')
|
||||
->set('text', str_repeat('Inhalt eines Tests. ', 5))
|
||||
->set('categoryId', $category->id)
|
||||
->set('publishMode', 'now')
|
||||
->set('scheduledAt', '2026-12-31T12:00')
|
||||
->call('save')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$pr = PressRelease::query()->latest('id')->firstOrFail();
|
||||
|
||||
expect($pr->scheduled_at)->toBeNull();
|
||||
});
|
||||
|
||||
test('edit form hydriert scheduled_at und embargo_at', function () {
|
||||
/** @var TestCase $this */
|
||||
Carbon::setTestNow('2026-06-01 10:00:00');
|
||||
|
||||
['customer' => $customer, 'company' => $company, 'contact' => $contact, 'category' => $category] = makeSchedulingCustomer();
|
||||
|
||||
$pr = PressRelease::factory()->create([
|
||||
'user_id' => $customer->id,
|
||||
'company_id' => $company->id,
|
||||
'category_id' => $category->id,
|
||||
'portal' => $company->portal->value,
|
||||
'status' => 'draft',
|
||||
'scheduled_at' => '2026-06-05 14:30:00',
|
||||
'embargo_at' => '2026-06-10 08:00:00',
|
||||
]);
|
||||
$pr->contacts()->sync([$contact->id]);
|
||||
|
||||
$this->actingAs($customer);
|
||||
|
||||
LivewireVolt::test('customer.press-releases.edit', ['id' => $pr->id])
|
||||
->assertSet('publishMode', 'scheduled')
|
||||
->assertSet('scheduledAt', '2026-06-05T14:30')
|
||||
->assertSet('useEmbargo', true)
|
||||
->assertSet('embargoAt', '2026-06-10T08:00');
|
||||
});
|
||||
|
|
@ -10,9 +10,7 @@ use App\Models\PressRelease;
|
|||
use App\Models\PressReleaseStatusLog;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Volt\Volt as LivewireVolt;
|
||||
use Tests\TestCase;
|
||||
|
||||
|
|
@ -61,82 +59,43 @@ test('customer can update own profile fields', function () {
|
|||
expect($customer->billingAddress?->country_code)->toBe('DE');
|
||||
});
|
||||
|
||||
test('customer can update an owned company and upload a logo with variants', function () {
|
||||
test('customer profile keeps company management out of the profile page', function () {
|
||||
/** @var TestCase $this */
|
||||
Storage::fake('public');
|
||||
|
||||
$customer = User::factory()->create(['is_active' => true]);
|
||||
$company = Company::factory()->presseecho()->create([
|
||||
'owner_user_id' => $customer->id,
|
||||
'name' => 'Old Co',
|
||||
'name' => 'Nicht im Profil geladene Firma',
|
||||
]);
|
||||
$customer->companies()->attach($company->id, ['role' => 'owner']);
|
||||
|
||||
$logo = UploadedFile::fake()->image('logo.png', 800, 400);
|
||||
|
||||
$this->actingAs($customer);
|
||||
|
||||
LivewireVolt::test('customer.profile')
|
||||
->set('editableCompanyId', $company->id)
|
||||
->set('companyName', 'New Co GmbH')
|
||||
->set('companyEmail', 'contact@example.test')
|
||||
->set('companyWebsite', 'https://example.test')
|
||||
->set('companyCountryCode', 'CH')
|
||||
->set('companyLogo', $logo)
|
||||
->call('saveCompany')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$company->refresh();
|
||||
|
||||
expect($company->name)->toBe('New Co GmbH');
|
||||
expect($company->email)->toBe('contact@example.test');
|
||||
expect($company->country_code)->toBe('CH');
|
||||
expect($company->logo_path)->not->toBeNull();
|
||||
expect($company->logo_variants)->toHaveKey('sq');
|
||||
expect($company->logo_variants)->toHaveKey('wide');
|
||||
|
||||
expect(Storage::disk('public')->exists($company->logo_path))->toBeTrue();
|
||||
expect(Storage::disk('public')->exists($company->logo_variants['sq']))->toBeTrue();
|
||||
expect(Storage::disk('public')->exists($company->logo_variants['wide']))->toBeTrue();
|
||||
->assertSee('Rechnungsadresse')
|
||||
->assertSee('Profileinstellungen')
|
||||
->assertSee('Firmen verwalten')
|
||||
->assertDontSee('Zugeordnete Firmen')
|
||||
->assertDontSee('Nicht im Profil geladene Firma');
|
||||
});
|
||||
|
||||
test('customer cannot edit a company they do not own or co-manage', function () {
|
||||
test('customer can save profile settings without a billing address', function () {
|
||||
/** @var TestCase $this */
|
||||
$customer = User::factory()->create(['is_active' => true]);
|
||||
$otherCompany = Company::factory()->presseecho()->create([
|
||||
'owner_user_id' => User::factory()->create()->id,
|
||||
]);
|
||||
$customer->companies()->attach($otherCompany->id, ['role' => 'member']);
|
||||
|
||||
$this->actingAs($customer);
|
||||
|
||||
LivewireVolt::test('customer.profile')
|
||||
->set('editableCompanyId', $otherCompany->id)
|
||||
->set('companyName', 'Hijacked')
|
||||
->call('saveCompany');
|
||||
|
||||
$otherCompany->refresh();
|
||||
|
||||
expect($otherCompany->name)->not->toBe('Hijacked');
|
||||
});
|
||||
|
||||
test('customer can edit a company they own directly without pivot membership', function () {
|
||||
/** @var TestCase $this */
|
||||
$customer = User::factory()->create(['is_active' => true]);
|
||||
$company = Company::factory()->presseecho()->create([
|
||||
'owner_user_id' => $customer->id,
|
||||
'name' => 'Owner Company',
|
||||
$customer = User::factory()->create([
|
||||
'is_active' => true,
|
||||
'name' => 'Ohne Rechnung',
|
||||
]);
|
||||
|
||||
$this->actingAs($customer);
|
||||
|
||||
LivewireVolt::test('customer.profile')
|
||||
->set('editableCompanyId', $company->id)
|
||||
->set('companyName', 'Owner Company Updated')
|
||||
->call('saveCompany')
|
||||
->set('name', 'Nur Profil')
|
||||
->set('firstName', 'Nur')
|
||||
->set('lastName', 'Profil')
|
||||
->call('saveProfile')
|
||||
->assertHasNoErrors();
|
||||
|
||||
expect($company->refresh()->name)->toBe('Owner Company Updated');
|
||||
expect($customer->refresh()->name)->toBe('Nur Profil');
|
||||
expect($customer->billingAddress)->toBeNull();
|
||||
});
|
||||
|
||||
test('customer security page renders password and email forms', function () {
|
||||
|
|
@ -221,6 +180,7 @@ test('customer press releases derive portal from selected company', function ()
|
|||
$customer = User::factory()->create(['is_active' => true]);
|
||||
$company = Company::factory()->presseecho()->create();
|
||||
$customer->companies()->attach($company->id, ['role' => 'owner']);
|
||||
$contact = Contact::factory()->for($company)->create(['portal' => $company->portal->value]);
|
||||
|
||||
$this->actingAs($customer);
|
||||
|
||||
|
|
@ -228,6 +188,7 @@ test('customer press releases derive portal from selected company', function ()
|
|||
->set('companyId', $company->id)
|
||||
->set('portal', Portal::Businessportal24->value)
|
||||
->set('categoryId', Category::factory()->create()->id)
|
||||
->set('contactId', $contact->id)
|
||||
->set('title', 'Neue Meldung fuer Presseecho')
|
||||
->set('text', str_repeat('Dies ist ein ausreichend langer Testtext. ', 3))
|
||||
->call('save', 'draft')
|
||||
|
|
|
|||
44
tests/Feature/LocalFontsTest.php
Normal file
44
tests/Feature/LocalFontsTest.php
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
test('views load fonts from local assets only', function () {
|
||||
$externalFontHosts = [
|
||||
'fonts.bunny.net',
|
||||
'fonts.googleapis.com',
|
||||
'fonts.gstatic.com',
|
||||
];
|
||||
|
||||
foreach (File::allFiles(resource_path('views')) as $view) {
|
||||
$contents = File::get($view->getRealPath());
|
||||
|
||||
foreach ($externalFontHosts as $host) {
|
||||
$this->assertStringNotContainsString(
|
||||
$host,
|
||||
$contents,
|
||||
$view->getRelativePathname().' references '.$host,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$localFontLinks = view('partials.local-fonts')->render();
|
||||
|
||||
expect($localFontLinks)
|
||||
->toContain('fonts/inter-tight/font.css')
|
||||
->toContain('fonts/source-serif-4/font.css')
|
||||
->toContain('fonts/jetbrains-mono/font.css');
|
||||
});
|
||||
|
||||
test('local font stylesheets reference font files relative to their directories', function () {
|
||||
foreach (File::allFiles(public_path('fonts')) as $file) {
|
||||
if ($file->getExtension() !== 'css') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->assertStringNotContainsString(
|
||||
'../fonts/',
|
||||
File::get($file->getRealPath()),
|
||||
$file->getRelativePathname().' still points outside its font directory',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
@ -58,6 +58,24 @@ test('customer can access me dashboard but not admin dashboard', function () {
|
|||
$this->get(route('dashboard'))->assertForbidden();
|
||||
});
|
||||
|
||||
test('customer bookings page shows credit packages and add ons from pricing concept', function () {
|
||||
/** @var TestCase $this */
|
||||
$customer = User::factory()->create(['is_active' => true]);
|
||||
$customer->assignRole('customer');
|
||||
|
||||
$this->actingAs($customer)
|
||||
->get(route('me.bookings.index'))
|
||||
->assertSuccessful()
|
||||
->assertSee('Credit-Pakete')
|
||||
->assertSee('Standard')
|
||||
->assertSee('50')
|
||||
->assertSee('45 €')
|
||||
->assertSee('Pressetext-Optimierung')
|
||||
->assertSee('Top-Slot Startseite')
|
||||
->assertSee('Score 80+')
|
||||
->assertSee('Noch keine aktiven Buchungen');
|
||||
});
|
||||
|
||||
test('admin can access both panel dashboards', function () {
|
||||
/** @var TestCase $this */
|
||||
$admin = User::factory()->create(['is_active' => true]);
|
||||
|
|
|
|||
219
tests/Feature/PressReleaseAttachmentsManagerTest.php
Normal file
219
tests/Feature/PressReleaseAttachmentsManagerTest.php
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\Company;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\PressReleaseAttachment;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Filesystem\FilesystemAdapter;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Volt\Volt as LivewireVolt;
|
||||
use Tests\TestCase;
|
||||
|
||||
function publicDisk(): FilesystemAdapter
|
||||
{
|
||||
/** @var FilesystemAdapter $disk */
|
||||
$disk = Storage::disk('public');
|
||||
|
||||
return $disk;
|
||||
}
|
||||
|
||||
beforeEach(function (): void {
|
||||
/** @var TestCase $this */
|
||||
$this->seed(RolesAndPermissionsSeeder::class);
|
||||
Storage::fake('public');
|
||||
});
|
||||
|
||||
function makeDraftWithOwner(string $status = 'draft'): array
|
||||
{
|
||||
$owner = User::factory()->create(['is_active' => true]);
|
||||
$owner->assignRole('customer');
|
||||
|
||||
$company = Company::factory()->presseecho()->create();
|
||||
$owner->companies()->attach($company->id, ['role' => 'owner']);
|
||||
|
||||
$pr = PressRelease::factory()->create([
|
||||
'user_id' => $owner->id,
|
||||
'company_id' => $company->id,
|
||||
'category_id' => Category::factory()->create()->id,
|
||||
'portal' => $company->portal->value,
|
||||
'status' => $status,
|
||||
]);
|
||||
|
||||
return compact('owner', 'company', 'pr');
|
||||
}
|
||||
|
||||
test('owner can upload a PDF attachment', function () {
|
||||
/** @var TestCase $this */
|
||||
['owner' => $owner, 'pr' => $pr] = makeDraftWithOwner();
|
||||
|
||||
$this->actingAs($owner);
|
||||
|
||||
$file = UploadedFile::fake()->create('factsheet.pdf', 200, 'application/pdf');
|
||||
|
||||
LivewireVolt::test('components.press-release-attachments-manager', ['pressReleaseId' => $pr->id])
|
||||
->set('newFile', $file)
|
||||
->set('newTitle', 'Factsheet Q1')
|
||||
->set('newDescription', 'Wirtschaftliche Kennzahlen')
|
||||
->call('upload')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$att = $pr->attachments()->first();
|
||||
|
||||
expect($att)->not->toBeNull();
|
||||
expect($att->title)->toBe('Factsheet Q1');
|
||||
expect($att->description)->toBe('Wirtschaftliche Kennzahlen');
|
||||
expect($att->original_name)->toBe('factsheet.pdf');
|
||||
expect($att->mime)->toBe('application/pdf');
|
||||
expect($att->sort_order)->toBe(1);
|
||||
expect($att->disk)->toBe('public');
|
||||
publicDisk()->assertExists($att->path);
|
||||
});
|
||||
|
||||
test('upload rejects executable / disallowed file types', function () {
|
||||
/** @var TestCase $this */
|
||||
['owner' => $owner, 'pr' => $pr] = makeDraftWithOwner();
|
||||
|
||||
$this->actingAs($owner);
|
||||
|
||||
$bad = UploadedFile::fake()->create('shell.exe', 50, 'application/octet-stream');
|
||||
|
||||
LivewireVolt::test('components.press-release-attachments-manager', ['pressReleaseId' => $pr->id])
|
||||
->set('newFile', $bad)
|
||||
->call('upload')
|
||||
->assertHasErrors(['newFile']);
|
||||
|
||||
expect($pr->attachments()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('upload rejects oversized files', function () {
|
||||
/** @var TestCase $this */
|
||||
['owner' => $owner, 'pr' => $pr] = makeDraftWithOwner();
|
||||
|
||||
$this->actingAs($owner);
|
||||
|
||||
// 26 MB (limit ist 25 MB).
|
||||
$tooBig = UploadedFile::fake()->create('big.pdf', 26 * 1024, 'application/pdf');
|
||||
|
||||
LivewireVolt::test('components.press-release-attachments-manager', ['pressReleaseId' => $pr->id])
|
||||
->set('newFile', $tooBig)
|
||||
->call('upload')
|
||||
->assertHasErrors(['newFile']);
|
||||
|
||||
expect($pr->attachments()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('sort_order increments and moveUp / moveDown swap order', function () {
|
||||
/** @var TestCase $this */
|
||||
['owner' => $owner, 'pr' => $pr] = makeDraftWithOwner();
|
||||
|
||||
$a = PressReleaseAttachment::factory()->for($pr)->create(['sort_order' => 1, 'title' => 'A']);
|
||||
$b = PressReleaseAttachment::factory()->for($pr)->create(['sort_order' => 2, 'title' => 'B']);
|
||||
$c = PressReleaseAttachment::factory()->for($pr)->create(['sort_order' => 3, 'title' => 'C']);
|
||||
|
||||
$this->actingAs($owner);
|
||||
|
||||
LivewireVolt::test('components.press-release-attachments-manager', ['pressReleaseId' => $pr->id])
|
||||
->call('moveUp', $c->id);
|
||||
|
||||
expect($b->fresh()->sort_order)->toBe(3);
|
||||
expect($c->fresh()->sort_order)->toBe(2);
|
||||
|
||||
LivewireVolt::test('components.press-release-attachments-manager', ['pressReleaseId' => $pr->id])
|
||||
->call('moveDown', $a->id);
|
||||
|
||||
// Nach moveDown sind a und c gleich (jeweils sort=2 nach swap). Es wird der Vorgänger getauscht — also a tauscht mit c (new neighbour).
|
||||
// Wir prüfen pragmatisch: a steht nicht mehr auf 1.
|
||||
expect($a->fresh()->sort_order)->not->toBe(1);
|
||||
});
|
||||
|
||||
test('remove deletes the row and the file', function () {
|
||||
/** @var TestCase $this */
|
||||
['owner' => $owner, 'pr' => $pr] = makeDraftWithOwner();
|
||||
|
||||
$this->actingAs($owner);
|
||||
|
||||
$file = UploadedFile::fake()->create('toDelete.pdf', 50, 'application/pdf');
|
||||
|
||||
$component = LivewireVolt::test('components.press-release-attachments-manager', ['pressReleaseId' => $pr->id])
|
||||
->set('newFile', $file)
|
||||
->call('upload')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$att = $pr->attachments()->first();
|
||||
publicDisk()->assertExists($att->path);
|
||||
|
||||
$component->call('remove', $att->id);
|
||||
|
||||
expect($pr->attachments()->withTrashed()->find($att->id)?->trashed())->toBeTrue();
|
||||
publicDisk()->assertMissing($att->path);
|
||||
});
|
||||
|
||||
test('startEdit + updateAttachment update title and description', function () {
|
||||
/** @var TestCase $this */
|
||||
['owner' => $owner, 'pr' => $pr] = makeDraftWithOwner();
|
||||
$att = PressReleaseAttachment::factory()->for($pr)->create([
|
||||
'title' => 'Alt',
|
||||
'description' => 'Alte Beschreibung',
|
||||
]);
|
||||
|
||||
$this->actingAs($owner);
|
||||
|
||||
LivewireVolt::test('components.press-release-attachments-manager', ['pressReleaseId' => $pr->id])
|
||||
->call('startEdit', $att->id)
|
||||
->assertSet('editingId', $att->id)
|
||||
->assertSet('editTitle', 'Alt')
|
||||
->set('editTitle', 'Neuer Titel')
|
||||
->set('editDescription', 'Frische Beschreibung')
|
||||
->call('updateAttachment')
|
||||
->assertHasNoErrors()
|
||||
->assertSet('editingId', null);
|
||||
|
||||
$att->refresh();
|
||||
expect($att->title)->toBe('Neuer Titel');
|
||||
expect($att->description)->toBe('Frische Beschreibung');
|
||||
});
|
||||
|
||||
test('foreign customer cannot upload attachments', function () {
|
||||
/** @var TestCase $this */
|
||||
['pr' => $pr] = makeDraftWithOwner();
|
||||
|
||||
$stranger = User::factory()->create(['is_active' => true]);
|
||||
$stranger->assignRole('customer');
|
||||
|
||||
$this->actingAs($stranger);
|
||||
|
||||
$file = UploadedFile::fake()->create('any.pdf', 30, 'application/pdf');
|
||||
|
||||
try {
|
||||
LivewireVolt::test('components.press-release-attachments-manager', ['pressReleaseId' => $pr->id])
|
||||
->set('newFile', $file)
|
||||
->call('upload');
|
||||
} catch (AuthorizationException) {
|
||||
// Erwartet: Policy verhindert den Upload.
|
||||
}
|
||||
|
||||
expect($pr->attachments()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('attachments cannot be uploaded when PR is published', function () {
|
||||
/** @var TestCase $this */
|
||||
['owner' => $owner, 'pr' => $pr] = makeDraftWithOwner('published');
|
||||
|
||||
$this->actingAs($owner);
|
||||
|
||||
$file = UploadedFile::fake()->create('any.pdf', 30, 'application/pdf');
|
||||
|
||||
try {
|
||||
LivewireVolt::test('components.press-release-attachments-manager', ['pressReleaseId' => $pr->id])
|
||||
->set('newFile', $file)
|
||||
->call('upload');
|
||||
} catch (AuthorizationException) {
|
||||
// Erwartet: Customer darf an published PR nichts ändern.
|
||||
}
|
||||
|
||||
expect($pr->attachments()->count())->toBe(0);
|
||||
});
|
||||
167
tests/Feature/PressReleaseContactWarningPhase8cTest.php
Normal file
167
tests/Feature/PressReleaseContactWarningPhase8cTest.php
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Models\Category;
|
||||
use App\Models\Company;
|
||||
use App\Models\Contact;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Livewire\Volt\Volt as LivewireVolt;
|
||||
use Tests\TestCase;
|
||||
|
||||
beforeEach(function (): void {
|
||||
/** @var TestCase $this */
|
||||
$this->seed(RolesAndPermissionsSeeder::class);
|
||||
});
|
||||
|
||||
/**
|
||||
* @return array{customer: User, company: Company, contact: Contact, category: Category}
|
||||
*/
|
||||
function makeCustomerForContactWarning(): array
|
||||
{
|
||||
$customer = User::factory()->create(['is_active' => true]);
|
||||
$customer->assignRole('customer');
|
||||
|
||||
$company = Company::factory()->presseecho()->create();
|
||||
$customer->companies()->attach($company->id, ['role' => 'owner']);
|
||||
|
||||
$contact = Contact::factory()->for($company)->create([
|
||||
'portal' => $company->portal->value,
|
||||
'phone' => '+49 30 1234567',
|
||||
]);
|
||||
$category = Category::factory()->create();
|
||||
|
||||
return compact('customer', 'company', 'contact', 'category');
|
||||
}
|
||||
|
||||
function makeAdminForContactWarning(): User
|
||||
{
|
||||
$admin = User::factory()->create(['is_active' => true]);
|
||||
$admin->assignRole('admin');
|
||||
|
||||
return $admin;
|
||||
}
|
||||
|
||||
test('customer create zeigt Warn-Box, wenn kein Pressekontakt gewählt ist', function () {
|
||||
/** @var TestCase $this */
|
||||
['customer' => $customer, 'company' => $company] = makeCustomerForContactWarning();
|
||||
$this->actingAs($customer);
|
||||
|
||||
LivewireVolt::test('customer.press-releases.create')
|
||||
->set('companyId', $company->id)
|
||||
->set('contactId', null)
|
||||
->assertSee('Noch kein Pressekontakt ausgewählt');
|
||||
});
|
||||
|
||||
test('customer create zeigt Warn-Box NICHT, wenn ein Kontakt gewählt ist', function () {
|
||||
/** @var TestCase $this */
|
||||
['customer' => $customer, 'company' => $company, 'contact' => $contact] = makeCustomerForContactWarning();
|
||||
$this->actingAs($customer);
|
||||
|
||||
LivewireVolt::test('customer.press-releases.create')
|
||||
->set('companyId', $company->id)
|
||||
->set('contactId', $contact->id)
|
||||
->assertDontSee('Noch kein Pressekontakt ausgewählt');
|
||||
});
|
||||
|
||||
test('customer edit zeigt Warn-Box, wenn kein Pressekontakt gewählt ist', function () {
|
||||
/** @var TestCase $this */
|
||||
['customer' => $customer, 'company' => $company, 'category' => $category] = makeCustomerForContactWarning();
|
||||
$this->actingAs($customer);
|
||||
|
||||
$pr = PressRelease::factory()->create([
|
||||
'user_id' => $customer->id,
|
||||
'company_id' => $company->id,
|
||||
'category_id' => $category->id,
|
||||
'portal' => $company->portal->value,
|
||||
'status' => PressReleaseStatus::Draft->value,
|
||||
]);
|
||||
|
||||
LivewireVolt::test('customer.press-releases.edit', ['id' => $pr->id])
|
||||
->set('contactId', null)
|
||||
->assertSee('Noch kein Pressekontakt ausgewählt');
|
||||
});
|
||||
|
||||
test('customer edit zeigt Warn-Box NICHT, wenn ein Kontakt gewählt ist', function () {
|
||||
/** @var TestCase $this */
|
||||
['customer' => $customer, 'company' => $company, 'contact' => $contact, 'category' => $category] = makeCustomerForContactWarning();
|
||||
$this->actingAs($customer);
|
||||
|
||||
$pr = PressRelease::factory()->create([
|
||||
'user_id' => $customer->id,
|
||||
'company_id' => $company->id,
|
||||
'category_id' => $category->id,
|
||||
'portal' => $company->portal->value,
|
||||
'status' => PressReleaseStatus::Draft->value,
|
||||
]);
|
||||
$pr->contacts()->sync([$contact->id]);
|
||||
|
||||
LivewireVolt::test('customer.press-releases.edit', ['id' => $pr->id])
|
||||
->assertDontSee('Noch kein Pressekontakt ausgewählt');
|
||||
});
|
||||
|
||||
test('admin create zeigt Warn-Box, wenn kein Pressekontakt gewählt ist', function () {
|
||||
/** @var TestCase $this */
|
||||
$admin = makeAdminForContactWarning();
|
||||
$this->actingAs($admin);
|
||||
|
||||
$company = Company::factory()->presseecho()->create();
|
||||
Contact::factory()->for($company)->create([
|
||||
'portal' => $company->portal->value,
|
||||
'phone' => '+49 30 7654321',
|
||||
]);
|
||||
|
||||
LivewireVolt::test('admin.press-releases.create')
|
||||
->set('companyId', $company->id)
|
||||
->set('contactId', null)
|
||||
->assertSee('Noch kein Pressekontakt ausgewählt');
|
||||
});
|
||||
|
||||
test('admin edit zeigt Warn-Box, wenn kein Pressekontakt gewählt ist', function () {
|
||||
/** @var TestCase $this */
|
||||
$admin = makeAdminForContactWarning();
|
||||
$this->actingAs($admin);
|
||||
|
||||
$company = Company::factory()->presseecho()->create();
|
||||
Contact::factory()->for($company)->create([
|
||||
'portal' => $company->portal->value,
|
||||
'phone' => '+49 30 7654321',
|
||||
]);
|
||||
$category = Category::factory()->create();
|
||||
|
||||
$pr = PressRelease::factory()->create([
|
||||
'company_id' => $company->id,
|
||||
'category_id' => $category->id,
|
||||
'portal' => $company->portal->value,
|
||||
'status' => PressReleaseStatus::Draft->value,
|
||||
]);
|
||||
|
||||
LivewireVolt::test('admin.press-releases.edit', ['id' => $pr->id])
|
||||
->set('contactId', null)
|
||||
->assertSee('Noch kein Pressekontakt ausgewählt');
|
||||
});
|
||||
|
||||
test('admin edit zeigt Warn-Box NICHT, wenn ein Kontakt gewählt ist', function () {
|
||||
/** @var TestCase $this */
|
||||
$admin = makeAdminForContactWarning();
|
||||
$this->actingAs($admin);
|
||||
|
||||
$company = Company::factory()->presseecho()->create();
|
||||
$contact = Contact::factory()->for($company)->create([
|
||||
'portal' => $company->portal->value,
|
||||
'phone' => '+49 30 7654321',
|
||||
]);
|
||||
$category = Category::factory()->create();
|
||||
|
||||
$pr = PressRelease::factory()->create([
|
||||
'company_id' => $company->id,
|
||||
'category_id' => $category->id,
|
||||
'portal' => $company->portal->value,
|
||||
'status' => PressReleaseStatus::Draft->value,
|
||||
]);
|
||||
$pr->contacts()->sync([$contact->id]);
|
||||
|
||||
LivewireVolt::test('admin.press-releases.edit', ['id' => $pr->id])
|
||||
->assertDontSee('Noch kein Pressekontakt ausgewählt');
|
||||
});
|
||||
154
tests/Feature/PressReleaseIndexPhase8bTest.php
Normal file
154
tests/Feature/PressReleaseIndexPhase8bTest.php
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Models\Company;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Livewire\Volt\Volt as LivewireVolt;
|
||||
use Tests\TestCase;
|
||||
|
||||
beforeEach(function (): void {
|
||||
/** @var TestCase $this */
|
||||
$this->seed(RolesAndPermissionsSeeder::class);
|
||||
});
|
||||
|
||||
function makeCustomerForIndexPhase8b(): User
|
||||
{
|
||||
$customer = User::factory()->create(['is_active' => true]);
|
||||
$customer->assignRole('customer');
|
||||
|
||||
return $customer;
|
||||
}
|
||||
|
||||
function makeAdminForIndexPhase8b(): User
|
||||
{
|
||||
$admin = User::factory()->create(['is_active' => true]);
|
||||
$admin->assignRole('admin');
|
||||
|
||||
return $admin;
|
||||
}
|
||||
|
||||
test('customer list zeigt Scheduling-Sub-Zeile bei review-PMs mit scheduled_at', function () {
|
||||
/** @var TestCase $this */
|
||||
$customer = makeCustomerForIndexPhase8b();
|
||||
$this->actingAs($customer);
|
||||
|
||||
$company = Company::factory()->presseecho()->create();
|
||||
$customer->companies()->attach($company->id, ['role' => 'owner']);
|
||||
|
||||
PressRelease::factory()->create([
|
||||
'user_id' => $customer->id,
|
||||
'company_id' => $company->id,
|
||||
'portal' => $company->portal->value,
|
||||
'status' => PressReleaseStatus::Review->value,
|
||||
'scheduled_at' => now()->addDays(3)->setTime(14, 30),
|
||||
]);
|
||||
|
||||
LivewireVolt::test('customer.press-releases.index')
|
||||
->assertSee('geplant');
|
||||
});
|
||||
|
||||
test('customer list zeigt Embargo-Sub-Zeile bei PMs mit embargo_at in der Zukunft', function () {
|
||||
/** @var TestCase $this */
|
||||
$customer = makeCustomerForIndexPhase8b();
|
||||
$this->actingAs($customer);
|
||||
|
||||
$company = Company::factory()->presseecho()->create();
|
||||
$customer->companies()->attach($company->id, ['role' => 'owner']);
|
||||
|
||||
PressRelease::factory()->create([
|
||||
'user_id' => $customer->id,
|
||||
'company_id' => $company->id,
|
||||
'portal' => $company->portal->value,
|
||||
'status' => PressReleaseStatus::Published->value,
|
||||
'embargo_at' => now()->addDays(7),
|
||||
]);
|
||||
|
||||
LivewireVolt::test('customer.press-releases.index')
|
||||
->assertSee('Embargo bis');
|
||||
});
|
||||
|
||||
test('customer list zeigt KEINE Scheduling-Sub-Zeile wenn scheduled_at in der Vergangenheit', function () {
|
||||
/** @var TestCase $this */
|
||||
$customer = makeCustomerForIndexPhase8b();
|
||||
$this->actingAs($customer);
|
||||
|
||||
$company = Company::factory()->presseecho()->create();
|
||||
$customer->companies()->attach($company->id, ['role' => 'owner']);
|
||||
|
||||
PressRelease::factory()->create([
|
||||
'user_id' => $customer->id,
|
||||
'company_id' => $company->id,
|
||||
'portal' => $company->portal->value,
|
||||
'status' => PressReleaseStatus::Published->value,
|
||||
'scheduled_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
LivewireVolt::test('customer.press-releases.index')
|
||||
->assertDontSee('geplant ·');
|
||||
});
|
||||
|
||||
test('customer list zeigt KEINE Embargo-Sub-Zeile wenn embargo_at abgelaufen', function () {
|
||||
/** @var TestCase $this */
|
||||
$customer = makeCustomerForIndexPhase8b();
|
||||
$this->actingAs($customer);
|
||||
|
||||
$company = Company::factory()->presseecho()->create();
|
||||
$customer->companies()->attach($company->id, ['role' => 'owner']);
|
||||
|
||||
PressRelease::factory()->create([
|
||||
'user_id' => $customer->id,
|
||||
'company_id' => $company->id,
|
||||
'portal' => $company->portal->value,
|
||||
'status' => PressReleaseStatus::Published->value,
|
||||
'embargo_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
LivewireVolt::test('customer.press-releases.index')
|
||||
->assertDontSee('Embargo bis');
|
||||
});
|
||||
|
||||
test('admin list zeigt Scheduling-Sub-Zeile bei review-PMs mit scheduled_at', function () {
|
||||
/** @var TestCase $this */
|
||||
$admin = makeAdminForIndexPhase8b();
|
||||
$this->actingAs($admin);
|
||||
|
||||
PressRelease::factory()->create([
|
||||
'status' => PressReleaseStatus::Review->value,
|
||||
'scheduled_at' => now()->addDays(2)->setTime(10, 0),
|
||||
]);
|
||||
|
||||
LivewireVolt::test('admin.press-releases.index')
|
||||
->assertSee('geplant');
|
||||
});
|
||||
|
||||
test('admin list zeigt Embargo-Sub-Zeile bei PMs mit embargo_at in der Zukunft', function () {
|
||||
/** @var TestCase $this */
|
||||
$admin = makeAdminForIndexPhase8b();
|
||||
$this->actingAs($admin);
|
||||
|
||||
PressRelease::factory()->create([
|
||||
'status' => PressReleaseStatus::Published->value,
|
||||
'embargo_at' => now()->addDays(5),
|
||||
]);
|
||||
|
||||
LivewireVolt::test('admin.press-releases.index')
|
||||
->assertSee('Embargo bis');
|
||||
});
|
||||
|
||||
test('admin list zeigt KEINE Sub-Zeilen wenn weder scheduled_at noch embargo_at', function () {
|
||||
/** @var TestCase $this */
|
||||
$admin = makeAdminForIndexPhase8b();
|
||||
$this->actingAs($admin);
|
||||
|
||||
PressRelease::factory()->create([
|
||||
'status' => PressReleaseStatus::Draft->value,
|
||||
'scheduled_at' => null,
|
||||
'embargo_at' => null,
|
||||
]);
|
||||
|
||||
LivewireVolt::test('admin.press-releases.index')
|
||||
->assertDontSee('geplant ·')
|
||||
->assertDontSee('Embargo bis');
|
||||
});
|
||||
193
tests/Feature/PressReleaseSchedulingTest.php
Normal file
193
tests/Feature/PressReleaseSchedulingTest.php
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
<?php
|
||||
|
||||
use App\Console\Commands\PublishScheduledPressReleases;
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\PressReleaseStatusLog;
|
||||
use App\Services\PressRelease\PressReleaseService;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Tests\TestCase;
|
||||
|
||||
beforeEach(function (): void {
|
||||
/** @var TestCase $this */
|
||||
$this->seed(RolesAndPermissionsSeeder::class);
|
||||
Mail::fake();
|
||||
});
|
||||
|
||||
test('publish ohne scheduled_at und ohne embargo_at setzt published_at auf jetzt', function () {
|
||||
/** @var TestCase $this */
|
||||
Carbon::setTestNow('2026-06-01 10:00:00');
|
||||
|
||||
$pr = PressRelease::factory()->create([
|
||||
'status' => PressReleaseStatus::Review->value,
|
||||
'scheduled_at' => null,
|
||||
'embargo_at' => null,
|
||||
'published_at' => null,
|
||||
]);
|
||||
|
||||
app(PressReleaseService::class)->publish($pr);
|
||||
|
||||
expect($pr->fresh()->published_at?->toDateTimeString())->toBe('2026-06-01 10:00:00');
|
||||
});
|
||||
|
||||
test('publish mit scheduled_at in der Zukunft setzt published_at auf den Termin', function () {
|
||||
/** @var TestCase $this */
|
||||
Carbon::setTestNow('2026-06-01 10:00:00');
|
||||
|
||||
$pr = PressRelease::factory()->create([
|
||||
'status' => PressReleaseStatus::Review->value,
|
||||
'scheduled_at' => '2026-06-05 14:30:00',
|
||||
'embargo_at' => null,
|
||||
'published_at' => null,
|
||||
]);
|
||||
|
||||
app(PressReleaseService::class)->publish($pr);
|
||||
|
||||
expect($pr->fresh()->published_at?->toDateTimeString())->toBe('2026-06-05 14:30:00');
|
||||
});
|
||||
|
||||
test('publish mit embargo_at in der Zukunft verschiebt published_at auf das Embargo', function () {
|
||||
/** @var TestCase $this */
|
||||
Carbon::setTestNow('2026-06-01 10:00:00');
|
||||
|
||||
$pr = PressRelease::factory()->create([
|
||||
'status' => PressReleaseStatus::Review->value,
|
||||
'scheduled_at' => null,
|
||||
'embargo_at' => '2026-06-10 08:00:00',
|
||||
'published_at' => null,
|
||||
]);
|
||||
|
||||
app(PressReleaseService::class)->publish($pr);
|
||||
|
||||
expect($pr->fresh()->published_at?->toDateTimeString())->toBe('2026-06-10 08:00:00');
|
||||
});
|
||||
|
||||
test('publish mit scheduled_at und späterem embargo_at nimmt das Embargo', function () {
|
||||
/** @var TestCase $this */
|
||||
Carbon::setTestNow('2026-06-01 10:00:00');
|
||||
|
||||
$pr = PressRelease::factory()->create([
|
||||
'status' => PressReleaseStatus::Review->value,
|
||||
'scheduled_at' => '2026-06-05 14:30:00',
|
||||
'embargo_at' => '2026-06-10 08:00:00',
|
||||
'published_at' => null,
|
||||
]);
|
||||
|
||||
app(PressReleaseService::class)->publish($pr);
|
||||
|
||||
expect($pr->fresh()->published_at?->toDateTimeString())->toBe('2026-06-10 08:00:00');
|
||||
});
|
||||
|
||||
test('publish übernimmt bereits gesetztes published_at und überschreibt nicht', function () {
|
||||
/** @var TestCase $this */
|
||||
Carbon::setTestNow('2026-06-01 10:00:00');
|
||||
|
||||
$pr = PressRelease::factory()->create([
|
||||
'status' => PressReleaseStatus::Review->value,
|
||||
'scheduled_at' => '2026-06-05 14:30:00',
|
||||
'embargo_at' => null,
|
||||
'published_at' => '2025-12-01 00:00:00',
|
||||
]);
|
||||
|
||||
app(PressReleaseService::class)->publish($pr);
|
||||
|
||||
expect($pr->fresh()->published_at?->toDateTimeString())->toBe('2025-12-01 00:00:00');
|
||||
});
|
||||
|
||||
test('publish-Source landet als source im Status-Log', function () {
|
||||
/** @var TestCase $this */
|
||||
$pr = PressRelease::factory()->create([
|
||||
'status' => PressReleaseStatus::Review->value,
|
||||
'published_at' => null,
|
||||
]);
|
||||
|
||||
app(PressReleaseService::class)->publish($pr, source: 'scheduler');
|
||||
|
||||
$log = PressReleaseStatusLog::query()->latest('id')->firstOrFail();
|
||||
|
||||
expect($log->source)->toBe('scheduler');
|
||||
expect($log->to_status)->toBe(PressReleaseStatus::Published);
|
||||
});
|
||||
|
||||
test('Command publisht fällige Review-PMs mit scheduled_at <= now', function () {
|
||||
/** @var TestCase $this */
|
||||
Carbon::setTestNow('2026-06-01 12:00:00');
|
||||
|
||||
$due = PressRelease::factory()->create([
|
||||
'status' => PressReleaseStatus::Review->value,
|
||||
'scheduled_at' => '2026-06-01 11:55:00',
|
||||
'published_at' => null,
|
||||
]);
|
||||
|
||||
Artisan::call(PublishScheduledPressReleases::class);
|
||||
|
||||
$fresh = $due->fresh();
|
||||
expect($fresh->status)->toBe(PressReleaseStatus::Published);
|
||||
expect($fresh->published_at?->toDateTimeString())->toBe('2026-06-01 11:55:00');
|
||||
});
|
||||
|
||||
test('Command ignoriert PMs mit scheduled_at in der Zukunft', function () {
|
||||
/** @var TestCase $this */
|
||||
Carbon::setTestNow('2026-06-01 12:00:00');
|
||||
|
||||
$future = PressRelease::factory()->create([
|
||||
'status' => PressReleaseStatus::Review->value,
|
||||
'scheduled_at' => '2026-06-01 12:30:00',
|
||||
'published_at' => null,
|
||||
]);
|
||||
|
||||
Artisan::call(PublishScheduledPressReleases::class);
|
||||
|
||||
expect($future->fresh()->status)->toBe(PressReleaseStatus::Review);
|
||||
});
|
||||
|
||||
test('Command ignoriert PMs ohne scheduled_at', function () {
|
||||
/** @var TestCase $this */
|
||||
Carbon::setTestNow('2026-06-01 12:00:00');
|
||||
|
||||
$manual = PressRelease::factory()->create([
|
||||
'status' => PressReleaseStatus::Review->value,
|
||||
'scheduled_at' => null,
|
||||
]);
|
||||
|
||||
Artisan::call(PublishScheduledPressReleases::class);
|
||||
|
||||
expect($manual->fresh()->status)->toBe(PressReleaseStatus::Review);
|
||||
});
|
||||
|
||||
test('Command läuft mit dry-run ohne Statusänderung', function () {
|
||||
/** @var TestCase $this */
|
||||
Carbon::setTestNow('2026-06-01 12:00:00');
|
||||
|
||||
$due = PressRelease::factory()->create([
|
||||
'status' => PressReleaseStatus::Review->value,
|
||||
'scheduled_at' => '2026-06-01 11:50:00',
|
||||
'published_at' => null,
|
||||
]);
|
||||
|
||||
Artisan::call(PublishScheduledPressReleases::class, ['--dry-run' => true]);
|
||||
|
||||
expect($due->fresh()->status)->toBe(PressReleaseStatus::Review);
|
||||
});
|
||||
|
||||
test('Command publisht maximal --limit pro Lauf', function () {
|
||||
/** @var TestCase $this */
|
||||
Carbon::setTestNow('2026-06-01 12:00:00');
|
||||
|
||||
PressRelease::factory()->count(3)->state([
|
||||
'status' => PressReleaseStatus::Review->value,
|
||||
'scheduled_at' => '2026-06-01 11:50:00',
|
||||
'published_at' => null,
|
||||
])->create();
|
||||
|
||||
Artisan::call(PublishScheduledPressReleases::class, ['--limit' => 2]);
|
||||
|
||||
$publishedCount = PressRelease::withoutGlobalScopes()
|
||||
->where('status', PressReleaseStatus::Published->value)
|
||||
->count();
|
||||
|
||||
expect($publishedCount)->toBe(2);
|
||||
});
|
||||
168
tests/Feature/PressReleaseShowPhase8aTest.php
Normal file
168
tests/Feature/PressReleaseShowPhase8aTest.php
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Models\Category;
|
||||
use App\Models\Company;
|
||||
use App\Models\Contact;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Livewire\Volt\Volt as LivewireVolt;
|
||||
use Tests\TestCase;
|
||||
|
||||
beforeEach(function (): void {
|
||||
/** @var TestCase $this */
|
||||
$this->seed(RolesAndPermissionsSeeder::class);
|
||||
});
|
||||
|
||||
/**
|
||||
* @return array{customer: User, company: Company, contact: Contact, category: Category, pr: PressRelease}
|
||||
*/
|
||||
function makeCustomerForShowPhase8a(array $prAttributes = []): array
|
||||
{
|
||||
$customer = User::factory()->create(['is_active' => true]);
|
||||
$customer->assignRole('customer');
|
||||
|
||||
$company = Company::factory()->presseecho()->create();
|
||||
$customer->companies()->attach($company->id, ['role' => 'owner']);
|
||||
|
||||
$contact = Contact::factory()->for($company)->create([
|
||||
'portal' => $company->portal->value,
|
||||
]);
|
||||
$category = Category::factory()->create();
|
||||
|
||||
$pr = PressRelease::factory()->create(array_merge([
|
||||
'user_id' => $customer->id,
|
||||
'company_id' => $company->id,
|
||||
'category_id' => $category->id,
|
||||
'portal' => $company->portal->value,
|
||||
'status' => 'draft',
|
||||
], $prAttributes));
|
||||
|
||||
$pr->contacts()->sync([$contact->id]);
|
||||
|
||||
return compact('customer', 'company', 'contact', 'category', 'pr');
|
||||
}
|
||||
|
||||
function makeAdminForShowPhase8a(): User
|
||||
{
|
||||
$admin = User::factory()->create(['is_active' => true]);
|
||||
$admin->assignRole('admin');
|
||||
|
||||
return $admin;
|
||||
}
|
||||
|
||||
test('customer show zeigt den Untertitel direkt unter dem Titel', function () {
|
||||
/** @var TestCase $this */
|
||||
['customer' => $customer, 'pr' => $pr] = makeCustomerForShowPhase8a([
|
||||
'title' => 'Brauerei eröffnet zweiten Standort',
|
||||
'subtitle' => 'Ein Untertitel der Pressemitteilung',
|
||||
]);
|
||||
$this->actingAs($customer);
|
||||
|
||||
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
|
||||
->assertSee('Brauerei eröffnet zweiten Standort')
|
||||
->assertSee('Ein Untertitel der Pressemitteilung');
|
||||
});
|
||||
|
||||
test('customer show zeigt geplante Veröffentlichung wenn gesetzt', function () {
|
||||
/** @var TestCase $this */
|
||||
['customer' => $customer, 'pr' => $pr] = makeCustomerForShowPhase8a([
|
||||
'status' => PressReleaseStatus::Review->value,
|
||||
'scheduled_at' => '2026-07-01 09:30:00',
|
||||
]);
|
||||
$this->actingAs($customer);
|
||||
|
||||
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
|
||||
->assertSee('Geplante Veröffentlichung')
|
||||
->assertSee('01.07.2026 09:30');
|
||||
});
|
||||
|
||||
test('customer show zeigt Sperrfrist wenn embargo_at gesetzt', function () {
|
||||
/** @var TestCase $this */
|
||||
['customer' => $customer, 'pr' => $pr] = makeCustomerForShowPhase8a([
|
||||
'status' => PressReleaseStatus::Published->value,
|
||||
'embargo_at' => '2026-08-15 12:00:00',
|
||||
]);
|
||||
$this->actingAs($customer);
|
||||
|
||||
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
|
||||
->assertSee('Sperrfrist bis')
|
||||
->assertSee('15.08.2026 12:00');
|
||||
});
|
||||
|
||||
test('customer show zeigt Kein-Export-Hinweis wenn no_export aktiv', function () {
|
||||
/** @var TestCase $this */
|
||||
['customer' => $customer, 'pr' => $pr] = makeCustomerForShowPhase8a([
|
||||
'no_export' => true,
|
||||
]);
|
||||
$this->actingAs($customer);
|
||||
|
||||
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
|
||||
->assertSee('Kein Export aktiv');
|
||||
});
|
||||
|
||||
test('customer show zeigt Boilerplate-Override als eigene Card', function () {
|
||||
/** @var TestCase $this */
|
||||
['customer' => $customer, 'pr' => $pr] = makeCustomerForShowPhase8a([
|
||||
'boilerplate_override' => 'Über die Beispiel AG: Wir machen Bier seit 1850.',
|
||||
]);
|
||||
$this->actingAs($customer);
|
||||
|
||||
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
|
||||
->assertSee('Eigener Abbinder (Boilerplate)')
|
||||
->assertSee('Über die Beispiel AG: Wir machen Bier seit 1850.');
|
||||
});
|
||||
|
||||
test('customer show zeigt Boilerplate-Override nicht wenn leer', function () {
|
||||
/** @var TestCase $this */
|
||||
['customer' => $customer, 'pr' => $pr] = makeCustomerForShowPhase8a([
|
||||
'boilerplate_override' => null,
|
||||
]);
|
||||
$this->actingAs($customer);
|
||||
|
||||
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
|
||||
->assertDontSee('Eigener Abbinder (Boilerplate)');
|
||||
});
|
||||
|
||||
test('admin show zeigt den Untertitel direkt unter dem Titel', function () {
|
||||
/** @var TestCase $this */
|
||||
$admin = makeAdminForShowPhase8a();
|
||||
$this->actingAs($admin);
|
||||
|
||||
$pr = PressRelease::factory()->create([
|
||||
'title' => 'Großer Auftritt der Brauerei',
|
||||
'subtitle' => 'Pressekonferenz am Freitag',
|
||||
]);
|
||||
|
||||
LivewireVolt::test('admin.press-releases.show', ['id' => $pr->id])
|
||||
->assertSee('Großer Auftritt der Brauerei')
|
||||
->assertSee('Pressekonferenz am Freitag');
|
||||
});
|
||||
|
||||
test('admin show zeigt Boilerplate-Override als eigene Card', function () {
|
||||
/** @var TestCase $this */
|
||||
$admin = makeAdminForShowPhase8a();
|
||||
$this->actingAs($admin);
|
||||
|
||||
$pr = PressRelease::factory()->create([
|
||||
'boilerplate_override' => 'Über das Beispiel-Unternehmen: Mehr Infos folgen.',
|
||||
]);
|
||||
|
||||
LivewireVolt::test('admin.press-releases.show', ['id' => $pr->id])
|
||||
->assertSee('Eigener Abbinder (Boilerplate)')
|
||||
->assertSee('Über das Beispiel-Unternehmen: Mehr Infos folgen.');
|
||||
});
|
||||
|
||||
test('admin show zeigt Boilerplate-Override nicht wenn leer', function () {
|
||||
/** @var TestCase $this */
|
||||
$admin = makeAdminForShowPhase8a();
|
||||
$this->actingAs($admin);
|
||||
|
||||
$pr = PressRelease::factory()->create([
|
||||
'boilerplate_override' => null,
|
||||
]);
|
||||
|
||||
LivewireVolt::test('admin.press-releases.show', ['id' => $pr->id])
|
||||
->assertDontSee('Eigener Abbinder (Boilerplate)');
|
||||
});
|
||||
|
|
@ -23,7 +23,11 @@ const httpsConfig =
|
|||
export default defineConfig({
|
||||
plugins: [
|
||||
laravel({
|
||||
input: ["resources/css/portal.css", "resources/js/app.js"],
|
||||
input: [
|
||||
"resources/css/portal.css",
|
||||
"resources/js/app.js",
|
||||
"resources/js/portal-form-hooks.js",
|
||||
],
|
||||
refresh: ["resources/views/portal/**/*.blade.php"],
|
||||
}),
|
||||
tailwindcss({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue