22-05-2026 Optimierung der User und Admin Panels
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled

This commit is contained in:
Kevin Adametz 2026-05-22 11:18:59 +02:00
parent d2ba22c0cf
commit e8c47b7553
73 changed files with 10282 additions and 1546 deletions

1
.devcontainer/Untitled Normal file
View file

@ -0,0 +1 @@
.devcontainer

View 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;
}
}

View file

@ -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;
}
}

View file

@ -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)

View 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');
}
}

View file

@ -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]);

View file

@ -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);
}
};

View file

@ -5,7 +5,7 @@
> Bekommt deshalb eine eigene Phase außerhalb der bisherigen
> `hub-flux`-Roadmap (Phase 06 sind dort abgeschlossen).
**Status**: 🟡 in Planung · **Aufwand**: 23 Tage · **Risiko**: mittel
**Status**: ✅ abgeschlossen · **Aufwand**: 23 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).
---

View 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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">12</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 &amp; 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 &amp; 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 &amp; 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>

View file

@ -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+ */
}

View file

@ -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+ */
}

View file

@ -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+ */
}

View file

@ -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;
}
}
/* ============================================================

View 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);
}
});
});
});
});
})();

View file

@ -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>

View file

@ -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()

View 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

View file

@ -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')

View file

@ -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')

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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

View file

@ -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>

View file

@ -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">

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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' => '1925', '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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View 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>

View file

@ -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>

View file

@ -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>

View file

@ -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 --}}

View file

@ -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>

View file

@ -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>

View file

@ -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

View file

@ -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

View 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">

View file

@ -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">

View file

@ -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));">

View file

@ -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');
});

View file

@ -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();

View file

@ -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))

View file

@ -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');
});

View file

@ -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();

View 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');
});

View 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();
});

View 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');
});

View file

@ -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);

View file

@ -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]);

View file

@ -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]);

View 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);
});

View 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');
});

View file

@ -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 () {

View 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');
});

View 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');
});

View file

@ -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')

View 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',
);
}
});

View file

@ -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]);

View 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);
});

View 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');
});

View 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');
});

View 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);
});

View 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)');
});

View file

@ -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({