diff --git a/.devcontainer/Untitled b/.devcontainer/Untitled new file mode 100644 index 0000000..9bcd451 --- /dev/null +++ b/.devcontainer/Untitled @@ -0,0 +1 @@ +.devcontainer \ No newline at end of file diff --git a/app/Console/Commands/PublishScheduledPressReleases.php b/app/Console/Commands/PublishScheduledPressReleases.php new file mode 100644 index 0000000..fccdc6a --- /dev/null +++ b/app/Console/Commands/PublishScheduledPressReleases.php @@ -0,0 +1,108 @@ +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; + } +} diff --git a/app/Services/Billing/LegacyInvoicePdfRenderer.php b/app/Services/Billing/LegacyInvoicePdfRenderer.php index f2c653a..9d8118c 100644 --- a/app/Services/Billing/LegacyInvoicePdfRenderer.php +++ b/app/Services/Billing/LegacyInvoicePdfRenderer.php @@ -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 + * @return array{ + * invoice: LegacyInvoice, + * billing_address: array, + * invoice_data: array, + * 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 $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 $billingAddress + * @return list + */ + 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, - '', - 'Leistung: '.$serviceName.' auf '.$invoice->legacy_portal->label(), - $servicePeriodBegin === $servicePeriodEnd - ? 'Leistungsdatum: '.$servicePeriodBegin - : 'Leistungszeitraum: '.$servicePeriodBegin.' - '.$servicePeriodEnd, - 'Rechnungsnummer: '.($invoice->number ?? '#'.$invoice->legacy_id), - '', - 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), - '', - '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 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, - '', - 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 !== '')); + ]; + + return array_values(array_filter($lines, fn (?string $line): bool => filled($line))); + } + + /** + * @param array $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 $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 $data + * @return list + */ + 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:', + '', + 'Kontoinhaber: Stern Consulting GmbH', + 'IBAN: DE23100208900022865552', + 'BIC: HYVEDEMM488', + 'Bank: Hypo Vereinsbank', + '', + 'Als Verwendungszweck geben Sie bitte "Rechungsnummer '.$invoiceNumber.'" an!', + '', + '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:', + '', + 'Wichtig! Ab Januar 2024 gilt die neue Bankverbindung:', + '', + '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 + */ + 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 + */ + 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; + } } diff --git a/app/Services/Customer/CustomerCompanyContext.php b/app/Services/Customer/CustomerCompanyContext.php index 6158f23..1553828 100644 --- a/app/Services/Customer/CustomerCompanyContext.php +++ b/app/Services/Customer/CustomerCompanyContext.php @@ -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 + */ + 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 + */ + 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 + */ + 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) diff --git a/app/Services/PressRelease/PressReleaseAttachmentStorage.php b/app/Services/PressRelease/PressReleaseAttachmentStorage.php new file mode 100644 index 0000000..9290668 --- /dev/null +++ b/app/Services/PressRelease/PressReleaseAttachmentStorage.php @@ -0,0 +1,113 @@ + */ + public const ALLOWED_EXTENSIONS = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'zip', 'txt']; + + /** @var list */ + 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'); + } +} diff --git a/app/Services/PressRelease/PressReleaseService.php b/app/Services/PressRelease/PressReleaseService.php index e3c9a3c..b8eca22 100644 --- a/app/Services/PressRelease/PressReleaseService.php +++ b/app/Services/PressRelease/PressReleaseService.php @@ -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]); diff --git a/database/migrations/2026_05_21_180700_add_customer_press_kit_index_performance_indexes.php b/database/migrations/2026_05_21_180700_add_customer_press_kit_index_performance_indexes.php new file mode 100644 index 0000000..78dfae1 --- /dev/null +++ b/database/migrations/2026_05_21_180700_add_customer_press_kit_index_performance_indexes.php @@ -0,0 +1,73 @@ +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); + } +}; diff --git a/dev/frontend/hub-flux/19-PHASE-7-PRESS-RELEASE-FORM.md b/dev/frontend/hub-flux/19-PHASE-7-PRESS-RELEASE-FORM.md index 696f950..a727768 100644 --- a/dev/frontend/hub-flux/19-PHASE-7-PRESS-RELEASE-FORM.md +++ b/dev/frontend/hub-flux/19-PHASE-7-PRESS-RELEASE-FORM.md @@ -5,7 +5,7 @@ > Bekommt deshalb eine eigene Phase außerhalb der bisherigen > `hub-flux`-Roadmap (Phase 0–6 sind dort abgeschlossen). -**Status**: 🟡 in Planung · **Aufwand**: 2–3 Tage · **Risiko**: mittel +**Status**: ✅ abgeschlossen · **Aufwand**: 2–3 Tage · **Risiko**: mittel (Datenmodell-Erweiterung, Editor-Format-Migration, Composer-Dependency) --- @@ -187,25 +187,46 @@ Untertitel, Editor, Medien, Anhänge, Boilerplate. - [ ] Reorder-Test - [ ] Datei-Type-Validation grün -### 7F — Scheduling + Embargo (Schema vorhanden, UI aktivieren) +### 7F — Scheduling + Embargo ✅ -**Scope:** -- UI-Elemente aus 7C (Veröffentlichung-RadioGroup, - Embargo-Checkbox + Date-Picker) aktivieren -- `bald`-Badges entfernen -- Service-Logik in `PressReleaseService`: - - bei `submitForReview` mit `scheduled_at` → Status bleibt - `review`, beim `publish` durch Admin/Job wird `published_at` - auf `scheduled_at` gesetzt - - bei `embargo_at` → öffentliche Anzeige erst ab `embargo_at` -- Background-Job (`PublishScheduledPressReleases`) für die - Veröffentlichung um `scheduled_at` -- Test: Geplante PM wird nicht vor Termin öffentlich -- Test: Embargo-Filter im Portal-Index +**Was umgesetzt wurde:** +- **UI**: Customer Create + Edit haben eine zweite Radio-Option + „Geplanter Termin" mit `` 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). --- diff --git a/dev/frontend/tailwind_v3/User Firmen presseportale.html b/dev/frontend/tailwind_v3/User Firmen presseportale.html new file mode 100644 index 0000000..61eb953 --- /dev/null +++ b/dev/frontend/tailwind_v3/User Firmen presseportale.html @@ -0,0 +1,866 @@ + + + + + +presseportale.com — Meine Firmen + + + + + + + + + + + + + + +
+ +
+ + + + + +
+ + +
+
+
+ Hub + / + User Backend + / + Firmen +
+ + + + + presseecho + · + businessportal24 + + + + +
+ + + + + + ⌘K +
+ + + + + + + + Firma anlegen + +
+
+ + +
+ + +
+
+
+ User Backend + Mein Bereich · A · 03 +
+

Meine Firmen

+ +
+ 2 Firmen + + 2 aktiv + + 24 Pressemitteilungen gesamt + + 5 Pressekontakte hinterlegt +
+ +

+ Eine Firma ist der Container für Pressemitteilungen: Stammdaten, Boilerplate, Pressekontakte. + Anlage ohne separate Freigabe — die redaktionelle Prüfung erfolgt erst bei der Pressemitteilung. +

+
+ +
+ + + + Neue Firma anlegen + +
+
+ + + + + +
+
+ + + + + + + +
+ + + + + + / +
+ + + + +
+ + +
+
+
+ + +
+ + +
+
+ + +
+
+ +
+ Aktiv + +
+
+ +
+

Tegernseer Brauerei AG

+
Tegernsee · Brauerei & Getränke · 142 MA
+
+ +
+ presseecho + businessportal24 + Admin +
+ +
+
16PMs
+
2Kontakte
+
14.05.letzte PM
+
+ + +
+ + +
+
+ +
+ Aktiv + +
+
+ +
+

Mittelstandsverband Süd e. V.

+
München · Verband · 38 MA
+
+ +
+ presseecho + businessportal24 + Redakteur +
+ +
+
8PMs
+
3Kontakte
+
12.05.letzte PM
+
+ + +
+ + + + + + + Neue Firma anlegen + Stammdaten und Boilerplate. Die Anlage benötigt keine separate Freigabe. + + +
+
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FirmaPortalRolleStatusPMsKontakteLetzte PM
+ Tegernseer Brauerei AG +
Tegernsee · Brauerei & Getränke · 142 MA
+
+ presseecho + businessportal24 + AdminAktiv16214.05.2026 + +
+ Mittelstandsverband Süd e. V. +
München · Verband · 38 MA
+
+ presseecho + businessportal24 + RedakteurAktiv8312.05.2026 + +
+ +
+
+ 1–2 von 2 + · + +
+ +
+ + + +
+
+
+
+ + +
+
+
+
+ +
+

Noch keine Firma angelegt

+

+ Lege deine erste Firma an. Du kannst direkt im Anschluss eine Pressemitteilung darauf veröffentlichen — eine separate Freigabe der Firma ist nicht erforderlich. +

+
+ + + Erste Firma anlegen + + +
+
+
+
01
+
Stammdaten erfassen
+
+
+
02
+
Boilerplate schreiben
+
+
+
03
+
Pressekontakte zuordnen
+
+
+
+
+
+ + +
+
+
+
+ +
+

Keine Firmen mit diesen Filtern

+

Aktive Filter passen auf keine Einträge. Filter zurücksetzen oder weiter fassen.

+
+ + +
+
+
+
+ +
+ + +
+
+
+
Rollen pro Firma
+

+ Mehrere Personen können einer Firma zugeordnet sein. Rolle steuert, was im Backend möglich ist. +

+
+ +
+
+ Admin +
    +
  • Stammdaten & Boilerplate
  • +
  • Pressekontakte verwalten
  • +
  • PMs erstellen, einreichen, archivieren
  • +
  • Weitere Mitglieder einladen
  • +
+
+
+ Redakteur +
    +
  • PMs erstellen & einreichen
  • +
  • Stammdaten lesen
  • +
  • Boilerplate lesen / Vorschlag
  • +
  • keine Mitglieder-Verwaltung
  • +
+
+
+ Beobachter · bald +
    +
  • Read-only
  • +
  • Statistik & PMs einsehen
  • +
  • keine Bearbeitung
  • +
+
+
+
+
+ + + + +
+
+
+
+ + + + + + + +
+ + + + + diff --git a/public/fonts/inter-tight/font.css b/public/fonts/inter-tight/font.css index 4925ef7..bd2929e 100644 --- a/public/fonts/inter-tight/font.css +++ b/public/fonts/inter-tight/font.css @@ -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+ */ } \ No newline at end of file diff --git a/public/fonts/jetbrains-mono/font.css b/public/fonts/jetbrains-mono/font.css index 183e1c5..9923eeb 100644 --- a/public/fonts/jetbrains-mono/font.css +++ b/public/fonts/jetbrains-mono/font.css @@ -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+ */ } \ No newline at end of file diff --git a/public/fonts/source-serif-4/font.css b/public/fonts/source-serif-4/font.css index 3e6f17f..005bb26 100644 --- a/public/fonts/source-serif-4/font.css +++ b/public/fonts/source-serif-4/font.css @@ -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+ */ } \ No newline at end of file diff --git a/resources/css/shared/hub-components.css b/resources/css/shared/hub-components.css index f9e4ce7..d299b73 100644 --- a/resources/css/shared/hub-components.css +++ b/resources/css/shared/hub-components.css @@ -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; + } + } /* ============================================================ diff --git a/resources/js/portal-form-hooks.js b/resources/js/portal-form-hooks.js new file mode 100644 index 0000000..3e945f9 --- /dev/null +++ b/resources/js/portal-form-hooks.js @@ -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); + } + }); + }); + }); + }); +})(); diff --git a/resources/views/components/layouts/app/sidebar.blade.php b/resources/views/components/layouts/app/sidebar.blade.php index 679a17d..403dc90 100644 --- a/resources/views/components/layouts/app/sidebar.blade.php +++ b/resources/views/components/layouts/app/sidebar.blade.php @@ -10,340 +10,364 @@ Flash beim allerersten Aufruf ist akzeptiert. --}} request()->cookie('flux_appearance') === 'dark'])> - - @include('partials.head') - - - - - {{-- Brand-Block: Wortmarke + Hub-Eyebrow --}} - - - - -
- Publisher · Hub -
-
+ + @include('partials.head') + - @php - $user = auth()->user(); - $impersonation = app(\App\Actions\Admin\UserImpersonation::class); - $impersonator = $impersonation->impersonator(); - $isImpersonating = $impersonation->isActive(); - $canAdmin = ($user?->canAccessAdmin() ?? false) && ! $isImpersonating; - $canCustomer = $user?->canAccessCustomer() ?? false; - $reviewCount = $canAdmin - ? app(\App\Services\Admin\AdminPerformanceCache::class)->pressReleaseReviewCount() - : 0; - @endphp + + + - - {{-- Dashboard (Admin/Editor) --}} - @if($canAdmin) - - {{ __('Dashboard') }} - - @endif + {{-- Brand-Block: Wortmarke + Hub-Eyebrow --}} + + + + +
+ Publisher · Hub +
+
- {{-- Mein Bereich – sichtbar für alle Panel-User --}} - @if($canCustomer) - - - {{ __('Übersicht') }} - - - {{ __('Meine Pressemitteilungen') }} - - - {{ __('Firmen') }} - - - {{ __('Buchungen & Add-ons') }} - -
- {{ __('Statistiken') }} {{ __('später') }} -
-
+ @php + $user = auth()->user(); + $impersonation = app(\App\Actions\Admin\UserImpersonation::class); + $impersonator = $impersonation->impersonator(); + $isImpersonating = $impersonation->isActive(); + $canAdmin = ($user?->canAccessAdmin() ?? false) && !$isImpersonating; + $canCustomer = $user?->canAccessCustomer() ?? false; + $reviewCount = $canAdmin + ? app(\App\Services\Admin\AdminPerformanceCache::class)->pressReleaseReviewCount() + : 0; + @endphp - -
- {{ __('Credits & Tarif') }} {{ __('später') }} -
- - {{ __('Rechnungen') }} - -
- {{ __('Zahlungsarten') }} {{ __('später') }} -
-
- - - - {{ __('Profil') }} - - - {{ __('Sicherheit') }} - - - {{ __('API & Integrationen') }} - -
- {{ __('Benachrichtigungen') }} {{ __('später') }} -
-
- @endif - - {{-- Content Management (Admin/Editor) --}} - @if($canAdmin) - - - {{ __('Pressemitteilungen') }} - - - {{ __('Kategorien') }} - - - {{ __('Footer-Codes') }} - - - - {{-- CRM --}} - - - {{ __('Firmen') }} - - - {{ __('Kontakte') }} - - - - {{-- Billing --}} - - - {{ __('Legacy Rechnungen') }} - - - {{ __('Zahlungen') }} - - - {{ __('Gutscheine') }} - - - {{ __('Newsletter Sync') }} - - - - {{-- Administration --}} - - - {{ __('Voreinstellungen') }} - - - {{ __('Benutzer') }} - - - {{ __('Rollen & Rechte') }} - - - - {{-- Reports --}} - - - {{ __('Performance') }} - - - @endif - -
- - {{-- Portal-Filter für Admin-Benutzer (P2.6) --}} - @auth - @if($canAdmin) -
- -
- @endif - @endauth - - @if($impersonator) - {{-- Testmodus-Block im Hub-Stil (statt Amber-Warnfarbe). - Dunkles Hub-Blau-Panel mit Bernstein-Eyebrow, klare - CTA „Zurück zum Admin" als helle Pille. --}} -
-
-
-
-
- - - {{ __('Testmodus aktiv') }} - -
-

- {{ __('Angemeldet als') }} - {{ $user?->name }}.
- {{ __('Admin:') }} - {{ $impersonator->name }} -

-
- @csrf - -
-
-
+ + {{-- Dashboard (Admin/Editor) --}} + @if ($canAdmin) + + {{ __('Dashboard') }} + @endif - - - - + {{-- Mein Bereich – sichtbar für alle Panel-User --}} + @if ($canCustomer) + + + {{ __('Übersicht') }} + + + {{ __('Pressemitteilungen') }} + + + {{ __('Firmen') }} + + + {{ __('Buchungen & Add-ons') }} + +
+ {{ __('Statistiken') }} {{ __('später') }} +
+
- - -
-
- - - {{ auth()->user()->initials() }} - + +
+ {{ __('Credits & Tarif') }} {{ __('später') }} +
+ + {{ __('Rechnungen') }} + +
+ {{ __('Zahlungsarten') }} {{ __('später') }} +
+
+ + + + {{ __('Profil') }} + + + {{ __('Sicherheit') }} + + + {{ __('API & Integrationen') }} + +
+ {{ __('Benachrichtigungen') }} {{ __('später') }} +
+
+ @endif + + {{-- Content Management (Admin/Editor) --}} + @if ($canAdmin) + + + {{ __('Pressemitteilungen') }} + + + {{ __('Kategorien') }} + + + {{ __('Footer-Codes') }} + + + + {{-- CRM --}} + + + {{ __('Firmen') }} + + + {{ __('Kontakte') }} + + + + {{-- Billing --}} + + + {{ __('Legacy Rechnungen') }} + + + {{ __('Zahlungen') }} + + + {{ __('Gutscheine') }} + + + {{ __('Newsletter Sync') }} + + + + {{-- Administration --}} + + + {{ __('Voreinstellungen') }} + + + {{ __('Benutzer') }} + + + {{ __('Rollen & Rechte') }} + + + + {{-- Reports --}} + + + {{ __('Performance') }} + + + @endif + + + + {{-- Portal-Filter für Admin-Benutzer (P2.6) --}} + @auth + @if ($canAdmin) +
+ +
+ @endif + @endauth + + @if ($impersonator) + {{-- Testmodus-Block im Hub-Stil (statt Amber-Warnfarbe). + Dunkles Hub-Blau-Panel mit Bernstein-Eyebrow, klare + CTA „Zurück zum Admin" als helle Pille. --}} +
+
+
+
+
+ + + {{ __('Testmodus aktiv') }} + +
+

+ {{ __('Angemeldet als') }} + {{ $user?->name }}.
+ {{ __('Admin:') }} + {{ $impersonator->name }} +

+
+ @csrf + +
+
+
+ @endif + + + + + + + + +
+
+ + + {{ auth()->user()->initials() }} + -
- {{ auth()->user()->name }} - {{ auth()->user()->email }} -
+
+ {{ auth()->user()->name }} + {{ auth()->user()->email }}
- +
+
- + - - {{ __('Settings') }} - + + + {{ __('Profil') }} + - + - {{-- Phase 5: Appearance-Switcher direkt im User-Menü. + {{-- Phase 5: Appearance-Switcher direkt im User-Menü. `$flux.appearance` ist FluxUIs Magic-Object, persistent über LocalStorage. Werte: 'light' | 'dark' | 'system'. --}} -
-
- {{ __('Erscheinung') }} -
- - - - - +
+
+ {{ __('Erscheinung') }}
+ + + + + +
- + -
- @csrf - - {{ __('Log Out') }} - -
- - - +
+ @csrf + + {{ __('Abmelden') }} + +
+ + + - - - + + + - - - - - - + + + + + + - - + + - - -
-
- - - {{ auth()->user()->initials() }} - + + +
+
+ + + {{ auth()->user()->initials() }} + -
- {{ auth()->user()->name }} - {{ auth()->user()->email }} -
+
+ {{ auth()->user()->name }} + {{ auth()->user()->email }}
- - - - - - {{ __('Settings') }} - - - - - {{-- Phase 5: Appearance-Switcher (Mobile-Dropdown). --}} -
-
- {{ __('Erscheinung') }} -
- - - - -
+ - + -
- @csrf - - {{ __('Log Out') }} - -
- - - + + + {{ __('Profil') }} + - {{ $slot }} + + + {{-- Phase 5: Appearance-Switcher (Mobile-Dropdown). --}} +
+
+ {{ __('Erscheinung') }} +
+ + + + + +
+ + + +
+ @csrf + + {{ __('Abmelden') }} + +
+ + + + + {{ $slot }} + + @persist('toast') + + @endpersist + + @vite(['resources/js/portal-form-hooks.js'], 'build/portal') + @fluxScripts + - @fluxScripts - diff --git a/resources/views/components/layouts/auth/pressekonto.blade.php b/resources/views/components/layouts/auth/pressekonto.blade.php index c0653b1..035a7dc 100644 --- a/resources/views/components/layouts/auth/pressekonto.blade.php +++ b/resources/views/components/layouts/auth/pressekonto.blade.php @@ -40,9 +40,7 @@ - - - + @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() diff --git a/resources/views/components/portal/pagination.blade.php b/resources/views/components/portal/pagination.blade.php new file mode 100644 index 0000000..4b4b13c --- /dev/null +++ b/resources/views/components/portal/pagination.blade.php @@ -0,0 +1,112 @@ +@php + if (! isset($scrollTo)) { + $scrollTo = 'body'; + } + + $scrollIntoViewJsSnippet = ($scrollTo !== false) + ? <<getPageName(); + $isLengthAware = method_exists($paginator, 'total'); +@endphp + +@if ($paginator->hasPages()) + +@endif diff --git a/resources/views/layouts/admin-master.blade.php b/resources/views/layouts/admin-master.blade.php index 4b1a29c..9b401a4 100644 --- a/resources/views/layouts/admin-master.blade.php +++ b/resources/views/layouts/admin-master.blade.php @@ -15,8 +15,7 @@ - - + @include('partials.local-fonts') @vite(['resources/css/portal.css', 'resources/js/app.js'], 'build/portal') diff --git a/resources/views/layouts/web-master.blade.php b/resources/views/layouts/web-master.blade.php index ba69bf6..224676d 100644 --- a/resources/views/layouts/web-master.blade.php +++ b/resources/views/layouts/web-master.blade.php @@ -12,15 +12,7 @@ - - - @if ($theme === 'landing1') - - @elseif($theme === 'landing2') - - @else - - @endif + @include('partials.local-fonts') @if ($theme === 'landing1') diff --git a/resources/views/livewire/admin/categories/index.blade.php b/resources/views/livewire/admin/categories/index.blade.php index 7ec4193..6a5f564 100644 --- a/resources/views/livewire/admin/categories/index.blade.php +++ b/resources/views/livewire/admin/categories/index.blade.php @@ -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 - {{ $categories->links() }} + {{ $categories->links('components.portal.pagination') }}
diff --git a/resources/views/livewire/admin/companies/index.blade.php b/resources/views/livewire/admin/companies/index.blade.php index f8b7ce4..56d796d 100644 --- a/resources/views/livewire/admin/companies/index.blade.php +++ b/resources/views/livewire/admin/companies/index.blade.php @@ -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
- {{ $companies->links() }} + {{ $companies->links('components.portal.pagination') }}
diff --git a/resources/views/livewire/admin/contacts/index.blade.php b/resources/views/livewire/admin/contacts/index.blade.php index 2e3461b..ab24a42 100644 --- a/resources/views/livewire/admin/contacts/index.blade.php +++ b/resources/views/livewire/admin/contacts/index.blade.php @@ -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
- {{ $contacts->links() }} + {{ $contacts->links('components.portal.pagination') }}
diff --git a/resources/views/livewire/admin/footer-codes/index.blade.php b/resources/views/livewire/admin/footer-codes/index.blade.php index ddca823..52d4e65 100644 --- a/resources/views/livewire/admin/footer-codes/index.blade.php +++ b/resources/views/livewire/admin/footer-codes/index.blade.php @@ -250,7 +250,7 @@ new #[Layout('components.layouts.app'), Title('Footer-Codes')] class extends Com
- {{ $codes->links() }} + {{ $codes->links('components.portal.pagination') }}
diff --git a/resources/views/livewire/admin/invoices/index.blade.php b/resources/views/livewire/admin/invoices/index.blade.php index a07c4ff..a863809 100644 --- a/resources/views/livewire/admin/invoices/index.blade.php +++ b/resources/views/livewire/admin/invoices/index.blade.php @@ -328,7 +328,7 @@ new #[Layout('components.layouts.app'), Title('Legacy Rechnungen')] class extend
- {{ $invoices->links() }} + {{ $invoices->links('components.portal.pagination') }}
diff --git a/resources/views/livewire/admin/presets/index.blade.php b/resources/views/livewire/admin/presets/index.blade.php index ed631c4..ff333d4 100644 --- a/resources/views/livewire/admin/presets/index.blade.php +++ b/resources/views/livewire/admin/presets/index.blade.php @@ -208,7 +208,7 @@ new #[Layout('components.layouts.app'), Title('Voreinstellungen')] class extends
- {{ $presets->links() }} + {{ $presets->links('components.portal.pagination') }}
diff --git a/resources/views/livewire/admin/press-releases/create.blade.php b/resources/views/livewire/admin/press-releases/create.blade.php index 7872517..bdbe2e9 100644 --- a/resources/views/livewire/admin/press-releases/create.blade.php +++ b/resources/views/livewire/admin/press-releases/create.blade.php @@ -4,13 +4,16 @@ use App\Enums\Portal; use App\Enums\PressReleaseStatus; use App\Models\Category; use App\Models\Company; +use App\Models\Contact; use App\Models\PressRelease; use App\Services\Admin\AdminPerformanceCache; use App\Services\PressRelease\PressReleaseHtmlSanitizer; +use Flux\Flux; use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; use Illuminate\Validation\Rule; +use Livewire\Attributes\Computed; use Livewire\Attributes\Layout; use Livewire\Attributes\Title; use Livewire\Volt\Component; @@ -27,38 +30,174 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex public int|string|null $categoryId = null; + public int|string|null $contactId = null; + public string $title = ''; + public string $subtitle = ''; + public string $text = ''; public string $keywords = ''; public string $backlinkUrl = ''; + public string $boilerplateOverride = ''; + + public bool $useBoilerplateOverride = false; + public bool $noExport = false; + public string $publishMode = 'now'; + + public ?string $scheduledAt = null; + + public bool $useEmbargo = false; + + public ?string $embargoAt = null; + public function updatedCompanySearch(): void { $this->resetErrorBag('companyId'); } + public function updatedCompanyId(): void + { + if (! $this->companyId) { + $this->contactId = null; + + return; + } + + $contactStillValid = $this->companyContact((int) $this->contactId, (int) $this->companyId); + + if (! $contactStillValid) { + $this->contactId = $this->defaultContactIdFor((int) $this->companyId); + } + + unset($this->presubmitChecks); + } + public function updatedTitle(): void { $this->resetErrorBag('title'); } - public function save(string $submitStatus = 'draft'): void + public function addTag(string $tag): void { - $this->validate([ + $tag = trim($tag); + + if ($tag === '') { + return; + } + + $existing = $this->tagsArray(); + + if (count($existing) >= 5) { + return; + } + + if (in_array($tag, $existing, true)) { + return; + } + + $existing[] = $tag; + $this->keywords = implode(', ', $existing); + + unset($this->tags, $this->presubmitChecks); + } + + public function removeTag(string $tag): void + { + $existing = array_values(array_filter( + $this->tagsArray(), + fn (string $existingTag): bool => $existingTag !== $tag, + )); + + $this->keywords = implode(', ', $existing); + + unset($this->tags, $this->presubmitChecks); + } + + /** + * Single Source of Truth für die Validierungsregeln. Ermöglicht + * Live-Re-Validation via updated()-Hook ohne Duplikation. + * + * @return array> + */ + protected function formRules(): array + { + $rules = [ 'portal' => ['required', Rule::in(array_map(fn (Portal $p) => $p->value, Portal::cases()))], 'language' => ['required', Rule::in(['de', 'en'])], 'companyId' => ['required', 'integer', Rule::exists('companies', 'id')], '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'])], + ]; + + 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; + } + + /** + * Live-Re-Validation für bereits invalide Felder. + */ + 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; Bag wird automatisch befüllt. + } + } + + 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, + ); + } + + public function save(string $submitStatus = 'draft'): void + { + try { + $this->validate($this->formRules()); + } catch (\Illuminate\Validation\ValidationException $e) { + $this->notifyValidationError($e); + + throw $e; + } $status = match ($submitStatus) { 'review' => PressReleaseStatus::Review, @@ -80,17 +219,38 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex 'company_id' => (int) $this->companyId, 'category_id' => (int) $this->categoryId, 'title' => $this->title, + 'subtitle' => trim($this->subtitle) ?: null, 'slug' => $slug, 'text' => $cleanText, + 'boilerplate_override' => $this->useBoilerplateOverride && trim($this->boilerplateOverride) !== '' + ? trim($this->boilerplateOverride) + : 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, 'no_export' => $this->noExport, ]); - session()->flash('success', $status === PressReleaseStatus::Review - ? __('Pressemitteilung zur Prüfung eingereicht.') - : __('Pressemitteilung als Entwurf gespeichert.')); + if ($this->contactId) { + $contact = $this->companyContact((int) $this->contactId, (int) $this->companyId); + if ($contact) { + $pr->contacts()->sync([$contact->id]); + } + } + + Flux::toast( + heading: $status === PressReleaseStatus::Review ? __('Eingereicht') : __('Entwurf gespeichert'), + text: $status === PressReleaseStatus::Review + ? __('Pressemitteilung zur Prüfung eingereicht.') + : __('Pressemitteilung als Entwurf gespeichert.'), + variant: 'success', + ); $this->redirect(route('admin.press-releases.edit', $pr->id), navigate: true); } @@ -116,13 +276,151 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex ->limit(50) ->get(['id', 'name']); + $selectedCompany = $this->companyId + ? Company::withoutGlobalScopes()->find((int) $this->companyId) + : null; + return [ 'companies' => $companies, 'categories' => $this->categoryOptions(), 'portalOptions' => array_filter(Portal::cases(), fn (Portal $p) => $p !== Portal::Both), + 'selectedCompany' => $selectedCompany, + 'selectedCompanyContacts' => $selectedCompany + ? $this->companyContacts((int) $selectedCompany->id) + : Contact::query()->whereRaw('0 = 1')->get(), + 'tagSuggestions' => $this->tagSuggestionsFor($selectedCompany), ]; } + #[Computed] + public function tags(): array + { + return $this->tagsArray(); + } + + #[Computed] + public function presubmitChecks(): array + { + $titleLen = mb_strlen(trim($this->title)); + $textLen = app(PressReleaseHtmlSanitizer::class)->plainTextLength($this->text); + $tagsCount = count($this->tagsArray()); + + return [ + [ + 'key' => 'title', + 'status' => $titleLen >= 5 ? 'ok' : 'err', + 'label' => __('Titel vorhanden'), + 'sub' => $titleLen > 0 ? __(':n Zeichen', ['n' => $titleLen]) : __('Noch leer'), + ], + [ + 'key' => 'text', + 'status' => $textLen >= 600 ? 'ok' : ($textLen >= 50 ? 'warn' : 'err'), + 'label' => __('Mindestlänge Fließtext erreicht'), + 'sub' => __(':n / 600 Zeichen empfohlen', ['n' => number_format($textLen, 0, ',', '.')]), + ], + [ + 'key' => 'company', + 'status' => $this->companyId ? 'ok' : 'err', + 'label' => __('Firma zugeordnet'), + 'sub' => $this->companyId ? '' : __('Keine Firma gewählt'), + ], + [ + 'key' => 'category', + 'status' => $this->categoryId ? 'ok' : 'err', + 'label' => __('Kategorie gewählt'), + 'sub' => $this->categoryId ? '' : __('Kategorie ist Pflicht'), + ], + [ + 'key' => 'contact', + 'status' => $this->contactId ? 'ok' : 'warn', + 'label' => __('Pressekontakt zugeordnet'), + 'sub' => $this->contactId ? '' : __('empfohlen — Journalisten greifen direkt zum Hörer'), + ], + [ + 'key' => 'tags', + 'status' => $tagsCount >= 1 ? 'ok' : 'warn', + 'label' => __('Themen-Tags vergeben'), + 'sub' => $tagsCount >= 1 + ? trans_choice('{1}:n Tag|[2,*]:n Tags', $tagsCount, ['n' => $tagsCount]) + : __('empfohlen für SEO & Auffindbarkeit'), + ], + ]; + } + + /** + * @return list + */ + private function tagsArray(): array + { + if (trim($this->keywords) === '') { + return []; + } + + return collect(explode(',', $this->keywords)) + ->map(fn (string $tag): string => trim($tag)) + ->filter() + ->unique() + ->values() + ->all(); + } + + private function defaultContactIdFor(int $companyId): ?int + { + if ($companyId <= 0) { + return null; + } + + return $this->companyContacts($companyId)->first()?->id; + } + + /** + * @return Collection + */ + private function companyContacts(int $companyId): Collection + { + return Contact::withoutGlobalScopes() + ->where('company_id', $companyId) + ->orderBy('last_name') + ->orderBy('first_name') + ->get(['id', 'company_id', 'first_name', 'last_name', 'responsibility', 'phone', 'email']); + } + + private function companyContact(int $contactId, int $companyId): ?Contact + { + if ($contactId <= 0 || $companyId <= 0) { + return null; + } + + return Contact::withoutGlobalScopes() + ->where('company_id', $companyId) + ->whereKey($contactId) + ->first(); + } + + /** + * @return list + */ + private function tagSuggestionsFor(?Company $company): array + { + $defaults = [ + __('Mittelstand'), + __('Unternehmen'), + __('Eröffnung'), + __('Innovation'), + __('Nachhaltigkeit'), + ]; + + if (! $company) { + return $defaults; + } + + return array_values(array_unique(array_filter([ + $company->portal?->label(), + $company->country_code === 'DE' ? __('Deutschland') : null, + ...$defaults, + ]))); + } + private function categoryOptions(): Collection { return app(AdminPerformanceCache::class)->remember(AdminPerformanceCache::ActiveCategoryOptions, AdminPerformanceCache::OptionsTtl, fn () => Category::query() @@ -140,7 +438,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex }; ?> -
+
{{-- ============== PAGE HEADER ============== --}}
@@ -152,7 +450,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex {{ __('Neue Pressemitteilung') }}

- {{ __('Entwurf erstellen oder direkt zur Prüfung einreichen.') }} + {{ __('Schreibfläche links, Steuerung rechts. Pflichtfelder werden rechts in der Checkliste angezeigt.') }}

@@ -163,79 +461,17 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
-
- {{-- ============== HAUPTINHALT ============== --}} -
-
-
- {{ __('Inhalt') }} -
-
- - {{ __('Titel') }} * - - - + {{-- ============== 2-COLUMN GRID ============== --}} +
- - {{ __('Text') }} * - - - -
-
+ {{-- =================== LINKS: SCHREIBFLÄCHE =================== --}} +
-
-
- {{ __('SEO & Links') }} -
-
- - {{ __('Stichwörter') }} - - - - - - {{ __('Backlink-URL') }} - - - -
-
-
- - {{-- ============== SIDEBAR ============== --}} -
+ {{-- /Schreibfläche --}} + + {{-- =================== RECHTS: SETTINGS-SIDEBAR =================== --}} +
diff --git a/resources/views/livewire/admin/press-releases/edit.blade.php b/resources/views/livewire/admin/press-releases/edit.blade.php index 1648189..dc782fd 100644 --- a/resources/views/livewire/admin/press-releases/edit.blade.php +++ b/resources/views/livewire/admin/press-releases/edit.blade.php @@ -4,6 +4,7 @@ use App\Enums\Portal; use App\Enums\PressReleaseStatus; use App\Models\Category; use App\Models\Company; +use App\Models\Contact; use App\Models\PressRelease; use App\Services\Admin\AdminPerformanceCache; use App\Services\PressRelease\BlacklistViolationException; @@ -14,6 +15,7 @@ use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; use Illuminate\Validation\Rule; +use Livewire\Attributes\Computed; use Livewire\Attributes\Layout; use Livewire\Attributes\Locked; use Livewire\Attributes\Title; @@ -34,16 +36,32 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl public int|string|null $categoryId = null; + public int|string|null $contactId = null; + public string $title = ''; + public string $subtitle = ''; + public string $text = ''; public string $keywords = ''; public string $backlinkUrl = ''; + public string $boilerplateOverride = ''; + + public bool $useBoilerplateOverride = false; + public bool $noExport = false; + public string $publishMode = 'now'; + + public ?string $scheduledAt = null; + + public bool $useEmbargo = false; + + public ?string $embargoAt = null; + public string $currentStatus = ''; public string $targetStatus = ''; @@ -59,12 +77,28 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl $this->companyId = $pr->company_id; $this->categoryId = $pr->category_id; $this->title = $pr->title; + $this->subtitle = $pr->subtitle ?? ''; $this->text = $pr->text; $this->keywords = $pr->keywords ?? ''; $this->backlinkUrl = $pr->backlink_url ?? ''; + $this->boilerplateOverride = $pr->boilerplate_override ?? ''; + $this->useBoilerplateOverride = filled($pr->boilerplate_override); $this->noExport = $pr->no_export; $this->currentStatus = $pr->status->value; $this->targetStatus = $this->currentStatus; + + $this->contactId = $pr->contacts()->withoutGlobalScopes()->first()?->id + ?? $this->defaultContactIdFor((int) $pr->company_id); + + if ($pr->scheduled_at) { + $this->publishMode = 'scheduled'; + $this->scheduledAt = $pr->scheduled_at->format('Y-m-d\TH:i'); + } + + if ($pr->embargo_at) { + $this->useEmbargo = true; + $this->embargoAt = $pr->embargo_at->format('Y-m-d\TH:i'); + } } public function updatedCompanySearch(): void @@ -72,18 +106,132 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl $this->resetErrorBag('companyId'); } - public function save(): void + public function updatedCompanyId(): void { - $this->validate([ + if (! $this->companyId) { + $this->contactId = null; + + return; + } + + $contactStillValid = $this->companyContact((int) $this->contactId, (int) $this->companyId); + + if (! $contactStillValid) { + $this->contactId = $this->defaultContactIdFor((int) $this->companyId); + } + + unset($this->presubmitChecks); + } + + public function addTag(string $tag): void + { + $tag = trim($tag); + + if ($tag === '') { + return; + } + + $existing = $this->tagsArray(); + + if (count($existing) >= 5) { + return; + } + + if (in_array($tag, $existing, true)) { + return; + } + + $existing[] = $tag; + $this->keywords = implode(', ', $existing); + + unset($this->tags, $this->presubmitChecks); + } + + public function removeTag(string $tag): void + { + $existing = array_values(array_filter( + $this->tagsArray(), + fn (string $existingTag): bool => $existingTag !== $tag, + )); + + $this->keywords = implode(', ', $existing); + + unset($this->tags, $this->presubmitChecks); + } + + /** + * @return array> + */ + protected function formRules(): array + { + $rules = [ 'portal' => ['required', Rule::in(array_map(fn (Portal $p) => $p->value, Portal::cases()))], 'language' => ['required', Rule::in(['de', 'en'])], 'companyId' => ['required', 'integer', Rule::exists('companies', 'id')], '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'])], + ]; + + 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 updated(string $property): void + { + if (! $this->getErrorBag()->has($property)) { + return; + } + + try { + $this->validateOnly($property, $this->formRules()); + } catch (\Illuminate\Validation\ValidationException) { + // Field bleibt invalid. + } + } + + 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, + ); + } + + public function save(): void + { + try { + $this->validate($this->formRules()); + } catch (\Illuminate\Validation\ValidationException $e) { + $this->notifyValidationError($e); + + throw $e; + } $pr = PressRelease::withoutGlobalScopes()->findOrFail($this->id); @@ -104,14 +252,31 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl 'company_id' => (int) $this->companyId, 'category_id' => (int) $this->categoryId, 'title' => $this->title, + 'subtitle' => trim($this->subtitle) ?: null, 'slug' => $slug, 'text' => $cleanText, + 'boilerplate_override' => $this->useBoilerplateOverride && trim($this->boilerplateOverride) !== '' + ? trim($this->boilerplateOverride) + : 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, 'no_export' => $this->noExport, ]); - session()->flash('success', __('Pressemitteilung gespeichert.')); + if ($this->contactId) { + $contact = $this->companyContact((int) $this->contactId, (int) $this->companyId); + if ($contact) { + $pr->contacts()->sync([$contact->id]); + } + } + + Flux::toast(text: __('Pressemitteilung gespeichert.'), variant: 'success'); } public function submitForReview(): void @@ -122,13 +287,18 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl app(PressReleaseService::class)->submitForReview($pr); } catch (BlacklistViolationException $e) { $this->currentStatus = PressReleaseStatus::Rejected->value; - 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; } $this->currentStatus = PressReleaseStatus::Review->value; - session()->flash('success', __('Zur Prüfung eingereicht.')); + Flux::toast(text: __('Zur Prüfung eingereicht.'), variant: 'success'); } public function publish(): void @@ -139,13 +309,18 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl app(PressReleaseService::class)->publish($pr); } catch (BlacklistViolationException $e) { $this->currentStatus = PressReleaseStatus::Rejected->value; - 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; } $this->currentStatus = PressReleaseStatus::Published->value; - session()->flash('success', __('Pressemitteilung veröffentlicht. Autor wurde benachrichtigt.')); + Flux::toast(text: __('Pressemitteilung veröffentlicht. Autor wurde benachrichtigt.'), variant: 'success'); } public function reject(): void @@ -153,7 +328,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl $pr = PressRelease::withoutGlobalScopes()->findOrFail($this->id); app(PressReleaseService::class)->reject($pr); $this->currentStatus = PressReleaseStatus::Rejected->value; - session()->flash('success', __('Pressemitteilung abgelehnt. Autor wurde benachrichtigt.')); + Flux::toast(text: __('Pressemitteilung abgelehnt. Autor wurde benachrichtigt.'), variant: 'warning'); } public function backToDraft(): void @@ -161,7 +336,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl $pr = PressRelease::withoutGlobalScopes()->findOrFail($this->id); app(PressReleaseService::class)->backToDraft($pr); $this->currentStatus = PressReleaseStatus::Draft->value; - session()->flash('success', __('Zurück auf Entwurf gesetzt.')); + Flux::toast(text: __('Zurück auf Entwurf gesetzt.'), variant: 'success'); } public function archive(): void @@ -170,7 +345,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl app(PressReleaseService::class)->archive($pr); $this->currentStatus = PressReleaseStatus::Archived->value; $this->targetStatus = $this->currentStatus; - session()->flash('success', __('Pressemitteilung archiviert.')); + Flux::toast(text: __('Pressemitteilung archiviert.'), variant: 'success'); } public function changeStatus(): void @@ -193,7 +368,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl $this->currentStatus = $status->value; $this->targetStatus = $status->value; - session()->flash('success', __('Status wurde auf ":status" geändert.', ['status' => $status->label()])); + Flux::toast(text: __('Status wurde auf ":status" geändert.', ['status' => $status->label()]), variant: 'success'); Flux::modal('confirm-status-change')->close(); } @@ -204,9 +379,12 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl app(PressReleaseService::class)->deleteFromAdmin($pr); - session()->flash('success', $wasPublished - ? __('Pressemitteilung wurde archiviert und der Inhalt durch den voreingestellten Ersatztext ersetzt.') - : __('Pressemitteilung wurde gelöscht.')); + Flux::toast( + text: $wasPublished + ? __('Pressemitteilung wurde archiviert und der Inhalt durch den voreingestellten Ersatztext ersetzt.') + : __('Pressemitteilung wurde gelöscht.'), + variant: 'success', + ); $this->redirect(route('admin.press-releases.index'), navigate: true); } @@ -233,6 +411,10 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl $statusEnum = PressReleaseStatus::tryFrom($this->currentStatus); + $selectedCompany = $this->companyId + ? Company::withoutGlobalScopes()->find((int) $this->companyId) + : null; + return [ 'companies' => $companies, 'categories' => $this->categoryOptions(), @@ -240,6 +422,11 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl 'statusOptions' => PressReleaseStatus::cases(), 'statusEnum' => $statusEnum, 'targetStatusEnum' => PressReleaseStatus::tryFrom($this->targetStatus), + 'selectedCompany' => $selectedCompany, + 'selectedCompanyContacts' => $selectedCompany + ? $this->companyContacts((int) $selectedCompany->id) + : Contact::query()->whereRaw('0 = 1')->get(), + 'tagSuggestions' => $this->tagSuggestionsFor($selectedCompany), 'statusColor' => match ($this->currentStatus) { 'published' => 'green', 'review' => 'yellow', @@ -250,6 +437,135 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl ]; } + #[Computed] + public function tags(): array + { + return $this->tagsArray(); + } + + #[Computed] + public function presubmitChecks(): array + { + $titleLen = mb_strlen(trim($this->title)); + $textLen = app(PressReleaseHtmlSanitizer::class)->plainTextLength($this->text); + $tagsCount = count($this->tagsArray()); + + return [ + [ + 'key' => 'title', + 'status' => $titleLen >= 5 ? 'ok' : 'err', + 'label' => __('Titel vorhanden'), + 'sub' => $titleLen > 0 ? __(':n Zeichen', ['n' => $titleLen]) : __('Noch leer'), + ], + [ + 'key' => 'text', + 'status' => $textLen >= 600 ? 'ok' : ($textLen >= 50 ? 'warn' : 'err'), + 'label' => __('Mindestlänge Fließtext erreicht'), + 'sub' => __(':n / 600 Zeichen empfohlen', ['n' => number_format($textLen, 0, ',', '.')]), + ], + [ + 'key' => 'company', + 'status' => $this->companyId ? 'ok' : 'err', + 'label' => __('Firma zugeordnet'), + 'sub' => $this->companyId ? '' : __('Keine Firma gewählt'), + ], + [ + 'key' => 'category', + 'status' => $this->categoryId ? 'ok' : 'err', + 'label' => __('Kategorie gewählt'), + 'sub' => $this->categoryId ? '' : __('Kategorie ist Pflicht'), + ], + [ + 'key' => 'contact', + 'status' => $this->contactId ? 'ok' : 'warn', + 'label' => __('Pressekontakt zugeordnet'), + 'sub' => $this->contactId ? '' : __('empfohlen — Journalisten greifen direkt zum Hörer'), + ], + [ + 'key' => 'tags', + 'status' => $tagsCount >= 1 ? 'ok' : 'warn', + 'label' => __('Themen-Tags vergeben'), + 'sub' => $tagsCount >= 1 + ? trans_choice('{1}:n Tag|[2,*]:n Tags', $tagsCount, ['n' => $tagsCount]) + : __('empfohlen für SEO & Auffindbarkeit'), + ], + ]; + } + + /** + * @return list + */ + private function tagsArray(): array + { + if (trim($this->keywords) === '') { + return []; + } + + return collect(explode(',', $this->keywords)) + ->map(fn (string $tag): string => trim($tag)) + ->filter() + ->unique() + ->values() + ->all(); + } + + private function defaultContactIdFor(int $companyId): ?int + { + if ($companyId <= 0) { + return null; + } + + return $this->companyContacts($companyId)->first()?->id; + } + + /** + * @return Collection + */ + private function companyContacts(int $companyId): Collection + { + return Contact::withoutGlobalScopes() + ->where('company_id', $companyId) + ->orderBy('last_name') + ->orderBy('first_name') + ->get(['id', 'company_id', 'first_name', 'last_name', 'responsibility', 'phone', 'email']); + } + + private function companyContact(int $contactId, int $companyId): ?Contact + { + if ($contactId <= 0 || $companyId <= 0) { + return null; + } + + return Contact::withoutGlobalScopes() + ->where('company_id', $companyId) + ->whereKey($contactId) + ->first(); + } + + /** + * @return list + */ + private function tagSuggestionsFor(?Company $company): array + { + $defaults = [ + __('Mittelstand'), + __('Unternehmen'), + __('Eröffnung'), + __('Innovation'), + __('Nachhaltigkeit'), + ]; + + if (! $company) { + return $defaults; + } + + return array_values(array_unique(array_filter([ + $company->portal?->label(), + $company->country_code === 'DE' ? __('Deutschland') : null, + ...$defaults, + ]))); + } + private function categoryOptions(): Collection { return app(AdminPerformanceCache::class)->remember(AdminPerformanceCache::ActiveCategoryOptions, AdminPerformanceCache::OptionsTtl, fn () => Category::query() @@ -267,7 +583,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl }; ?> -
+
@php $statusClass = match ($currentStatus) { 'published' => 'ok', @@ -277,18 +593,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl }; @endphp - @if (session('success')) -
- {{ session('success') }} -
- @endif - @if (session('error')) -
- {{ session('error') }} -
- @endif + {{-- Flash-Banner ersetzt durch im Layout. --}} {{-- ============== PAGE HEADER ============== --}}
@@ -303,115 +608,31 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl {{ __('Pressemitteilung bearbeiten') }}

- {{ __('Inhalt, Metadaten und Status der PM aktualisieren. Änderungen werden sofort wirksam.') }} + {{ __('Schreibfläche links, Steuerung rechts. Status-Aktionen erscheinen oben in der Sidebar.') }}

+ + {{ __('Vorschau / Detail') }} + - {{ __('Zurück') }} + {{ __('Zur Liste') }}
-
- {{-- ============== HAUPTINHALT ============== --}} -
-
-
- {{ __('Inhalt') }} -
-
- - {{ __('Titel') }} * - - - + {{-- ============== 2-COLUMN GRID ============== --}} +
- - {{ __('Text') }} * - - - -
-
+ {{-- =================== LINKS: SCHREIBFLÄCHE =================== --}} +
-
-
- {{ __('SEO & Links') }} -
-
- - {{ __('Stichwörter') }} - - - - - {{ __('Backlink-URL') }} - - - -
-
- - -
- - {{-- ============== SIDEBAR ============== --}} -
diff --git a/resources/views/livewire/admin/press-releases/index.blade.php b/resources/views/livewire/admin/press-releases/index.blade.php index ec0ff75..48eec0a 100644 --- a/resources/views/livewire/admin/press-releases/index.blade.php +++ b/resources/views/livewire/admin/press-releases/index.blade.php @@ -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 }; ?>
- @if (session('success')) -
- {{ session('success') }} -
- @endif - @if (session('error')) -
- {{ session('error') }} -
- @endif + {{-- Flash-Banner ersetzt durch im Layout. --}} {{-- ============== PAGE HEADER ============== --}}
@@ -905,6 +900,18 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
{{ $dateSubLabel }} · {{ $primaryDate?->format('H:i') }}
+ @if ($status === 'review' && $pr->scheduled_at && $pr->scheduled_at->isFuture()) +
+ + {{ __('geplant') }} · {{ $pr->scheduled_at->format('d.m. H:i') }} +
+ @endif + @if ($pr->embargo_at && $pr->embargo_at->isFuture()) +
+ + {{ __('Embargo bis') }} {{ $pr->embargo_at->format('d.m.') }} +
+ @endif @@ -1029,5 +1036,5 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten - {{ $pressReleases->links() }} + {{ $pressReleases->links('components.portal.pagination') }}
diff --git a/resources/views/livewire/admin/press-releases/show.blade.php b/resources/views/livewire/admin/press-releases/show.blade.php index c17b1ba..959a4c6 100644 --- a/resources/views/livewire/admin/press-releases/show.blade.php +++ b/resources/views/livewire/admin/press-releases/show.blade.php @@ -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')) -
- {{ session('success') }} -
- @endif - @if (session('error')) -
- {{ session('error') }} -
- @endif + {{-- Flash-Banner ersetzt durch im Layout. --}} {{-- ============== PAGE HEADER ============== --}}
@@ -126,6 +134,11 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends

{{ $pr->title }}

+ @if ($pr->subtitle) +

+ {{ $pr->subtitle }} +

+ @endif

{{ __('Firma') }}: {{ $pr->company?->name ?? '–' }} @@ -148,39 +161,98 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends

+ {{-- ============== REJECTION-HINWEIS ============== --}} + @if ($pr->status === \App\Enums\PressReleaseStatus::Rejected && $latestRejection) +
+
+ {{ __('Diese Pressemitteilung wurde abgelehnt') }} + {{ __('Handlung erforderlich') }} +
+
+
+ +
+
+ @if ($latestRejection->reason) + {{ __('Begründung') }}: + {{ $latestRejection->reason }} + @else + {{ __('Der Autor sollte den Inhalt überarbeiten und erneut einreichen.') }} + @endif + + {{ __('Abgelehnt am') }} {{ $latestRejection->created_at->format('d.m.Y H:i') }} + @if ($latestRejection->changedBy) + · {{ __('durch :name', ['name' => $latestRejection->changedBy->name]) }} + @endif + +
+
+
+ @endif + {{-- ============== STATUS-WORKFLOW ============== --}} @if ($pr->status === \App\Enums\PressReleaseStatus::Review) -
+
{{ __('Status-Workflow') }} {{ __('Wartet auf Prüfung') }}
-
-

- {{ __('Diese PM wartet auf Prüfung.') }} -

- - {{ __('Veröffentlichen') }} - - - {{ __('Ablehnen') }} - +
+
+ +
+
+

{{ __('Diese PM wartet auf eine redaktionelle Entscheidung.') }}

+ @if ($pr->scheduled_at) +

+ + {{ __('Geplante Veröffentlichung: :date', ['date' => $pr->scheduled_at->format('d.m.Y H:i')]) }} +

+ @endif +
+
+ + {{ __('Veröffentlichen') }} + + + {{ __('Ablehnen') }} + +
@endif + @if ($pr->status === \App\Enums\PressReleaseStatus::Published) -
+
{{ __('Status-Workflow') }} {{ __('Live') }}
-
- @if ($pr->hits > 0) -

- {{ number_format($pr->hits) }} - {{ __('Aufrufe seit Veröffentlichung') }} +

+
+ +
+
+

+ {{ __('Veröffentlicht am') }} + {{ $pr->published_at?->format('d.m.Y H:i') ?? '–' }}

- @endif + @if ($pr->embargo_at && $pr->embargo_at->isFuture()) +

+ + {{ __('Sperrfrist bis: :date', ['date' => $pr->embargo_at->format('d.m.Y H:i')]) }} +

+ @endif + @if ($pr->hits > 0) +

+ {{ number_format($pr->hits, 0, ',', '.') }} + {{ __('Aufrufe seit Veröffentlichung') }} +

+ @endif +
{{ __('Archivieren') }} @@ -188,142 +260,256 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
@endif - {{-- ============== TEXT + SIDEBAR ============== --}} -
+ {{-- ============== KONTAKTE + STATUS/VERLAUF ============== --}} +
- {{ __('Inhalt') }} + {{ __('Zugeordnete Pressekontakte') }} + @if ($pr->company) + + {{ __('Firma') }} + + @endif
-
- {!! $pr->renderedText() !!} +

+ {{ __('Kontakte, die dieser Pressemitteilung zugeordnet sind.') }} +

+
+ @forelse ($contacts as $contact) +
+
+ {{ trim(($contact->first_name ?? '').' '.($contact->last_name ?? '')) ?: __('Kontakt ohne Name') }} +
+
+ {{ $contact->responsibility ?: __('Keine Rolle hinterlegt') }} +
+
+ @if ($contact->email) + + {{ $contact->email }} + + @endif + @if ($contact->phone) + {{ $contact->phone }} + @endif +
+
+ @empty +
+ {{ __('Dieser Pressemitteilung ist kein Pressekontakt zugeordnet.') }} +
+ @endforelse
- -
- - {{-- ============== STATUS-VERLAUF ============== --}} - @if ($statusLogs->isNotEmpty())
- {{ __('Status-Verlauf') }} - - {{ $statusLogs->count() }} {{ __('Einträge') }} - + {{ __('Status & Verlauf') }} + {{ $pr->status->label() }}
-
    - @foreach ($statusLogs as $log) -
  1. -
    - @php - $logClass = match ($log->to_status?->value) { - 'published' => 'ok', - 'review' => 'warn', - 'rejected' => 'err', - default => 'hub', - }; - @endphp - {{ $log->to_status?->label() ?? $log->to_status }} - @if ($log->from_status) - - {{ __('von') }} {{ $log->from_status->label() }} - - @endif - · - - {{ $log->created_at->format('d.m.Y H:i') }} - - @if ($log->changedBy) - · - {{ $log->changedBy->name }} - @endif - @if ($log->source !== 'admin') - {{ $log->source }} - @endif +
    +
    +
    {{ __('Autor') }}
    +
    + {{ $pr->user?->name ?? '–' }} +
    +
    +
    +
    {{ __('Erstellt') }}
    +
    + {{ $pr->created_at?->format('d.m.Y H:i') ?? '–' }} +
    +
    +
    +
    {{ __('Veröffentlicht') }}
    +
    + {{ $pr->published_at?->format('d.m.Y H:i') ?? '–' }} +
    +
    +
    +
    {{ __('Aufrufe') }}
    +
    + {{ number_format($pr->hits, 0, ',', '.') }} +
    +
    + @if ($pr->scheduled_at) +
    +
    {{ __('Geplant') }}
    +
    + {{ $pr->scheduled_at->format('d.m.Y H:i') }}
    - @if ($log->reason) -

    {{ $log->reason }}

    - @endif -
  2. - @endforeach -
+
+ @endif + @if ($pr->embargo_at) +
+
{{ __('Sperrfrist bis') }}
+
+ {{ $pr->embargo_at->format('d.m.Y H:i') }} +
+
+ @endif +
+ + @if ($pr->no_export) +
+ + {{ __('Kein Export aktiv (PM wird nicht über Feeds verteilt).') }} +
+ @endif + +
+ + @if ($statusLogs->isNotEmpty()) +
    + @foreach ($statusLogs as $log) +
  1. +
    + @php + $logClass = match ($log->to_status?->value) { + 'published' => 'ok', + 'review' => 'warn', + 'rejected' => 'err', + default => 'hub', + }; + @endphp + {{ $log->to_status?->label() ?? $log->to_status }} + @if ($log->from_status) + + {{ __('von') }} {{ $log->from_status->label() }} + + @endif + · + + {{ $log->created_at->format('d.m.Y H:i') }} + + @if ($log->changedBy) + · + {{ $log->changedBy->name }} + @endif + @if ($log->source && $log->source !== 'admin') + {{ $log->source }} + @endif +
    + @if ($log->reason) +

    {{ $log->reason }}

    + @endif +
  2. + @endforeach +
+ @else +

+ {{ __('Noch keine Statusänderungen protokolliert.') }} +

+ @endif +
+ + + + {{-- ============== INHALT ============== --}} +
+
+ {{ __('Inhalt') }} +
+
+
+ {!! $pr->renderedText() !!} +
+ + @if ($pr->keywords || $pr->backlink_url) +
+ @if ($pr->keywords) +

+ {{ __('Stichwörter') }}: + {{ $pr->keywords }} +

+ @endif + @if ($pr->backlink_url) +

+ {{ __('Backlink') }}: + + {{ $pr->backlink_url }} + +

+ @endif +
+ @endif +
+
+ + {{-- ============== BOILERPLATE-OVERRIDE ============== --}} + @if ($pr->boilerplate_override) +
+
+ {{ __('Eigener Abbinder (Boilerplate)') }} + {{ __('Override') }} +
+
+

+ {{ __('Dieser Text wird für diese Pressemitteilung anstelle des Standard-Abbinders der Firma verwendet.') }} +

+
+ {{ $pr->boilerplate_override }} +
@endif + {{-- ============== MEDIEN ============== --}} + @if ($pr->images->isNotEmpty()) +
+
+ {{ __('Bilder') }} + + {{ $pr->images->count() }} + +
+
+ @foreach ($pr->images as $image) +
+ + {{ basename($image->path) }} + @if ($image->is_preview) + {{ __('Preview') }} + @endif +
+ @endforeach +
+
+ @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()) +
+
+ {{ __('Anhänge') }} + + {{ $pr->attachments->count() }} + +
+
+ @foreach ($pr->attachments as $attachment) +
+ + + {{ $attachment->title ?: $attachment->original_name }} + + + {{ number_format($attachment->size / 1024, 0, ',', '.') }} KB + +
+ @endforeach +
+
+ @endif + --}} + @if($pr->status === \App\Enums\PressReleaseStatus::Review)
diff --git a/resources/views/livewire/admin/users.blade.php b/resources/views/livewire/admin/users.blade.php index a12ebc5..02cd0b8 100644 --- a/resources/views/livewire/admin/users.blade.php +++ b/resources/views/livewire/admin/users.blade.php @@ -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
- {{ $users->links() }} + {{ $users->links('components.portal.pagination') }}
diff --git a/resources/views/livewire/components/press-release-attachments-manager.blade.php b/resources/views/livewire/components/press-release-attachments-manager.blade.php new file mode 100644 index 0000000..060d520 --- /dev/null +++ b/resources/views/livewire/components/press-release-attachments-manager.blade.php @@ -0,0 +1,382 @@ +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, + ); + } +}; ?> + +
+
+
+ + {{ __('Anhänge / Downloads') }} + + — {{ count($attachments) }}/10 + + +
+ + {{ strtoupper(implode(' · ', $allowedExtensions)) }} · max. {{ $maxMb }} MB + +
+
+ + @if ($canEdit) +
+
+ + {{ __('Datei') }} * + + + + + {{ __('Hochladen') }} + {{ __('Lädt…') }} + +
+
+ + {{ __('Titel (optional)') }} + + + + + {{ __('Beschreibung (optional)') }} + + + +
+
+ @endif + + @if ($attachments->isEmpty()) +
+ +

+ {{ __('Noch keine Anhänge — Pressemappen, Factsheets oder Bildmaterial-Pakete passen hier rein.') }} +

+
+ @else +
+ @foreach ($attachments as $attachment) +
+ @if ($editingId === $attachment->id && $canEdit) + {{-- Inline-Edit-Form --}} +
+ + {{ __('Titel') }} + + + + + {{ __('Beschreibung') }} + + + +
+ {{ __('Abbrechen') }} + {{ __('Speichern') }} +
+
+ @else +
+
+ @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 + +
+
+

+ {{ $attachment->title ?? $attachment->original_name }} +

+ @if ($attachment->title) +

+ {{ $attachment->original_name }} +

+ @endif + @if ($attachment->description) +

+ {{ $attachment->description }} +

+ @endif +

+ {{ 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 }} +

+
+
+ +
+ @if ($attachment->url()) + + {{ __('Download') }} + + @endif + @if ($canEdit) + + + + + + @endif +
+ @endif +
+ @endforeach +
+ @endif +
+
diff --git a/resources/views/livewire/components/press-release-images-manager.blade.php b/resources/views/livewire/components/press-release-images-manager.blade.php index ce6b579..cc0375a 100644 --- a/resources/views/livewire/components/press-release-images-manager.blade.php +++ b/resources/views/livewire/components/press-release-images-manager.blade.php @@ -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 {{ count($images) }}
- @if(session('image-status')) - {{ session('image-status') }} - @endif - @if($canEdit)
{{ __('Neues Bild hinzufügen') }} diff --git a/resources/views/livewire/customer/bookings.blade.php b/resources/views/livewire/customer/bookings.blade.php index a19c7fe..bfca14f 100644 --- a/resources/views/livewire/customer/bookings.blade.php +++ b/resources/views/livewire/customer/bookings.blade.php @@ -6,6 +6,96 @@ use Livewire\Volt\Component; new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class extends Component { + public function with(): array + { + return [ + 'creditSummary' => [ + 'total' => 17, + 'bonus' => 12, + 'paid' => 5, + 'auto_refill' => __('ab 10 Credits empfohlen'), + 'validity' => __('Bonus-Credits verfallen monatlich, gekaufte Credits bleiben 24 Monate gültig.'), + ], + 'currentPlan' => [ + 'name' => 'Starter', + 'price' => '19 €/Mo.', + 'press_releases' => '3 PMs/Monat', + 'bonus_credits' => 12, + ], + 'creditPackages' => [ + ['name' => 'Test', 'credits' => 10, 'price' => '10 €', 'rate' => '1,00 €', 'saving' => null], + ['name' => 'Standard', 'credits' => 50, 'price' => '45 €', 'rate' => '0,90 €', 'saving' => '10 %'], + ['name' => 'Plus', 'credits' => 150, 'price' => '120 €', 'rate' => '0,80 €', 'saving' => '20 %'], + ['name' => 'Pro', 'credits' => 500, 'price' => '375 €', 'rate' => '0,75 €', 'saving' => '25 %'], + ['name' => 'Business', 'credits' => 1500, 'price' => '1.050 €', 'rate' => '0,70 €', 'saving' => '30 %'], + ], + 'serviceGroups' => [ + [ + 'title' => __('Veröffentlichung'), + 'description' => __('Basisleistungen rund um Veröffentlichung, Korrektur und Aktualisierung.'), + 'services' => [ + ['name' => __('Standard-PM (Pay-as-you-go)'), 'credits' => '19', 'meta' => __('1 Veröffentlichung')], + ['name' => __('PM-Korrektur'), 'credits' => '8', 'meta' => __('Pfad C')], + ['name' => __('PM-Update'), 'credits' => '4', 'meta' => __('im ersten Jahr ggf. kostenlos')], + ['name' => __('Depublizierung'), 'credits' => '19–25', 'meta' => __('abhängig vom Aufwand')], + ], + ], + [ + 'title' => __('Bilder'), + 'description' => __('Stock- und KI-Bilder für mehr Sichtbarkeit in Listen und Detailseiten.'), + 'services' => [ + ['name' => __('Free-Stock'), 'credits' => '0', 'meta' => __('Unsplash, Pexels')], + ['name' => __('Premium-Stock'), 'credits' => '8', 'meta' => __('Adobe, Shutterstock')], + ['name' => __('KI-Bild generieren'), 'credits' => '4', 'meta' => __('neues Motiv')], + ['name' => __('KI-Bild Re-Generation'), 'credits' => '2', 'meta' => __('Variante erzeugen')], + ], + ], + [ + 'title' => __('KI-Textservices'), + 'description' => __('Qualität verbessern, Score-Stufe erreichen und bessere Headlines testen.'), + 'services' => [ + ['name' => __('Quality-Check'), 'credits' => '3', 'meta' => __('Stil und Pressestil')], + ['name' => __('Lektorat'), 'credits' => '8', 'meta' => __('sprachliche Prüfung')], + ['name' => __('Pressetext-Optimierung'), 'credits' => '15', 'meta' => __('Headlines und SEO')], + ['name' => __('Headline-Booster'), 'credits' => '5', 'meta' => __('nur Headlines')], + ['name' => __('PM aus Stichworten generieren'), 'credits' => '25', 'meta' => __('Entwurf aus Briefing')], + ['name' => __('Übersetzung DE/EN'), 'credits' => '12', 'meta' => __('pro Sprachrichtung')], + ], + ], + [ + 'title' => __('Distribution'), + 'description' => __('Zusätzliche Formate und externe Reichweite für passende Meldungen.'), + 'services' => [ + ['name' => __('PDF-Export mit Branding'), 'credits' => '2', 'meta' => __('für Weitergabe')], + ['name' => __('Social-Snippet-Generierung'), 'credits' => '3', 'meta' => __('Kurztexte')], + ['name' => __('Verteiler-Versand klein'), 'credits' => '39', 'meta' => __('branchenspezifisch')], + ['name' => __('Verteiler-Versand mittel'), 'credits' => '99', 'meta' => __('mehr Empfänger')], + ['name' => __('Verteiler-Versand groß'), 'credits' => '199', 'meta' => __('branchenübergreifend')], + ], + ], + [ + 'title' => __('Account & Profil'), + 'description' => __('Vertrauen, Wiedererkennung und zusätzliche Profilfunktionen.'), + 'services' => [ + ['name' => __('Verifiziertes Firmenprofil'), 'credits' => '79', 'meta' => __('einmalig')], + ['name' => __('Custom Subdomain'), 'credits' => '49', 'meta' => __('pro Jahr')], + ['name' => __('Erweiterte Statistiken'), 'credits' => '15', 'meta' => __('pro Monat')], + ], + ], + ], + 'placements' => [ + ['name' => __('Highlight Kategorie'), 'credits' => '15', 'duration' => __('3 Tage'), 'tier' => __('Standard'), 'score' => '30+'], + ['name' => __('Highlight Kategorie'), 'credits' => '30', 'duration' => __('7 Tage'), 'tier' => __('Standard'), 'score' => '30+'], + ['name' => __('Startseite-Highlight'), 'credits' => '39', 'duration' => __('24 h'), 'tier' => __('Geprüft'), 'score' => '60+'], + ['name' => __('Startseite-Highlight'), 'credits' => '89', 'duration' => __('3 Tage'), 'tier' => __('Geprüft'), 'score' => '60+'], + ['name' => __('Top-Slot Startseite'), 'credits' => '119', 'duration' => __('24 h'), 'tier' => __('Hochwertig'), 'score' => '80+'], + ['name' => __('Newsletter-Erwähnung'), 'credits' => '59', 'duration' => __('nächster Versand'), 'tier' => __('Geprüft'), 'score' => '60+'], + ['name' => __('Social-Share'), 'credits' => '25', 'duration' => __('offizieller Kanal'), 'tier' => __('Geprüft'), 'score' => '60+'], + ], + 'activeBookings' => [], + 'bookingHistory' => [], + ]; + } }; ?>
@@ -15,56 +105,285 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte
{{ __('User Backend') }} {{ __('Mein Bereich · Finanzen') }} - {{ __('In Vorbereitung') }} + {{ __('Konzeptstand Mai 2026') }}

{{ __('Buchungen & Add-ons') }}

- {{ __('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 €.') }}

+ +
+ + {{ __('Rechnungen') }} + + + {{ __('Credits kaufen') }} + +
-
- -
- {{ __('Der Bereich ist bereits in der Navigation vorbereitet. Buchbare Add-ons werden aktiviert, sobald das Preismodell und die Zahlungslogik final sind.') }} + {{-- ============== CREDIT-ÜBERSICHT ============== --}} +
+
+
+ {{ __('Credit-Stand') }} + {{ __('Auto-Refill vorbereitet') }} +
+
+
+
+ {{ $creditSummary['total'] }} +
+

+ {{ __('verfügbare Credits') }} +

+
+ +
+
+
{{ __('Bonus-Credits') }}
+
{{ $creditSummary['bonus'] }}
+
{{ __('monatlich verfallend') }}
+
+
+
{{ __('Gekaufte Credits') }}
+
{{ $creditSummary['paid'] }}
+
{{ __('24 Monate gültig') }}
+
+
+ +
+ +
+ {{ $creditSummary['validity'] }} + {{ __('Für spätere Checkouts ist Auto-Refill :threshold vorgesehen.', ['threshold' => $creditSummary['auto_refill']]) }} +
+
+
+
+ +
+
+ {{ __('Aktueller Tarif') }} + {{ $currentPlan['name'] }} +
+
+
+
+ {{ $currentPlan['price'] }} +
+

+ {{ __('inkl. :credits Bonus-Credits und :pms', [ + 'credits' => $currentPlan['bonus_credits'], + 'pms' => $currentPlan['press_releases'], + ]) }} +

+
+
+
+ {{ __('Nächster sinnvoller Schritt') }} +
+

+ {{ __('Bei mehreren PMs mit KI-Optimierung oder Platzierungen ergänzt das Standard-Paket die monatlichen Bonus-Credits am saubersten.') }} +

+
+
+
+
+ + {{-- ============== CREDIT-PAKETE ============== --}} +
+
+ {{ __('Credit-Pakete') }} + {{ __('Volumenrabatt nach Paketgröße') }}
-
+ + + {{ __('Paket') }} + {{ __('Credits') }} + {{ __('Preis') }} + {{ __('Effektiv/Credit') }} + {{ __('Ersparnis') }} + {{ __('Aktion') }} + -
+ @foreach ($creditPackages as $package) + + + {{ $package['name'] }} + + {{ number_format($package['credits'], 0, ',', '.') }} + + {{ $package['price'] }} + + {{ $package['rate'] }} + + @if ($package['saving']) + {{ $package['saving'] }} + @else + + @endif + + + + {{ __('Kaufen') }} + + + + @endforeach + + + + {{-- ============== PLATZIERUNGEN ============== --}} +
+
+ {{ __('Boost & Platzierungen') }} +

+ {{ __('Sichtbarkeit buchen, wenn die Score-Stufe passt') }} +

+

+ {{ __('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.') }} +

+
+ +
+ @foreach ($placements as $placement) +
+
+
+
+
+ +
+
+

+ {{ $placement['name'] }} +

+

+ {{ $placement['duration'] }} +

+
+
+
+
{{ $placement['credits'] }}
+
{{ __('Credits') }}
+
+
+ +
+
+
{{ __('Mindeststufe') }}
+
{{ $placement['tier'] }}
+
+ + {{ __('Score :score', ['score' => $placement['score']]) }} + +
+ + + {{ __('Buchung vorbereiten') }} + +
+
+ @endforeach +
+
+ + {{-- ============== SERVICE-MARKTPLATZ ============== --}} +
+
+ {{ __('Add-on-Marktplatz') }} +

+ {{ __('Buchbare Services nach Kategorie') }} +

+
+ +
+ @foreach ($serviceGroups as $group) +
+
+
+ + {{ $group['title'] }} +
+
+
+

+ {{ $group['description'] }} +

+
+ @foreach ($group['services'] as $service) +
+
+
{{ $service['name'] }}
+
{{ $service['meta'] }}
+
+
+
{{ $service['credits'] }}
+
{{ __('Credits') }}
+
+
+ @endforeach +
+
+
+ @endforeach +
+
+ + {{-- ============== AKTIVE BUCHUNGEN / VERLAUF ============== --}} +
- {{ __('Firmenbezogene Add-ons') }} + {{ __('Aktive Buchungen') }} + {{ __('läuft aktuell') }}
-

- {{ __('Zum Beispiel zusätzliche Sichtbarkeit, Verifizierung oder besondere Platzierungen.') }} -

+ @forelse ($activeBookings as $booking) +
{{ $booking }}
+ @empty +
+
+ +
+
+ {{ __('Noch keine aktiven Buchungen') }} +
+

+ {{ __('Gebuchte Highlights, Newsletter-Platzierungen oder Add-ons erscheinen hier mit Laufzeit und zugehöriger Firma.') }} +

+
+ @endforelse
- {{ __('Credits & Tarif') }} + {{ __('Verlauf') }} + {{ __('verbrauchte Credits') }}
-

- {{ __('Tarif- und Credit-Informationen folgen, sobald das neue Preismodell live ist.') }} -

-
-
- -
-
- {{ __('Zahlungsarten') }} -
-
-

- {{ __('Zahlungsarten werden später unter Finanzen eingebunden.') }} -

+ @forelse ($bookingHistory as $booking) +
{{ $booking }}
+ @empty +
+
+ +
+
+ {{ __('Noch kein Buchungsverlauf') }} +
+

+ {{ __('Nach dem ersten Checkout werden Verbrauch, Rechnungsbezug und betroffene Pressemitteilung hier nachvollziehbar.') }} +

+
+ @endforelse
diff --git a/resources/views/livewire/customer/company-switcher.blade.php b/resources/views/livewire/customer/company-switcher.blade.php index 53692ba..7077c22 100644 --- a/resources/views/livewire/customer/company-switcher.blade.php +++ b/resources/views/livewire/customer/company-switcher.blade.php @@ -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) }} @endforeach + @if ($hasMoreCompanies) + + @endif
diff --git a/resources/views/livewire/customer/dashboard.blade.php b/resources/views/livewire/customer/dashboard.blade.php index dc371b7..18eded6 100644 --- a/resources/views/livewire/customer/dashboard.blade.php +++ b/resources/views/livewire/customer/dashboard.blade.php @@ -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 */ @@ -234,7 +226,7 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C {{ __('Aktive Firma:') }} {{ $selectedCompany->name }} @else - {{ __('Keine Firma zugeordnet') }} @@ -448,11 +440,11 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C {{ __('Meine Firmen') }} @@ -483,6 +475,15 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C @endforeach + @if ($companiesTotal > $companies->count()) +
+ {{ __('Die zehn neuesten Firmen werden hier als Vorschau angezeigt.') }} + + {{ __('Zur vollständigen Firmenliste') }} → + +
+ @endif @else
{{ __('Hinweis') }}
- {{ __('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.') }}
- - {{ __('Profil prüfen') }} + + {{ __('Firmen öffnen') }}
diff --git a/resources/views/livewire/customer/invoices.blade.php b/resources/views/livewire/customer/invoices.blade.php index a7ebe29..59c7b82 100644 --- a/resources/views/livewire/customer/invoices.blade.php +++ b/resources/views/livewire/customer/invoices.blade.php @@ -224,7 +224,7 @@ new #[Layout('components.layouts.app'), Title('Rechnungen')] class extends Compo @endforelse
- {{ $invoices->links() }} + {{ $invoices->links('components.portal.pagination') }}
diff --git a/resources/views/livewire/customer/press-kits/create.blade.php b/resources/views/livewire/customer/press-kits/create.blade.php new file mode 100644 index 0000000..afd91ca --- /dev/null +++ b/resources/views/livewire/customer/press-kits/create.blade.php @@ -0,0 +1,218 @@ +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', []), + ]; + } +}; ?> + +
+ {{-- ============== PAGE HEADER ============== --}} +
+
+
+ {{ __('User Backend') }} + {{ __('Mein Bereich · Firmen · Anlegen') }} +
+

+ {{ __('Neue Firma anlegen') }} +

+

+ {{ __('Lege Stammdaten und Portal-Zuordnung an. Die Firma steht sofort zur Verfügung — die redaktionelle Prüfung erfolgt erst bei der ersten Pressemitteilung.') }} +

+
+ +
+ + {{ __('Zurück zur Liste') }} + +
+
+ + +
+
+ {{ __('Stammdaten') }} +
+
+ + + + + + + + @foreach ($portals as $value => $label) + {{ $label }} + @endforeach + + + + + + + @foreach ($types as $value => $label) + {{ $label }} + @endforeach + + + + + + + + + + + + + + + + + + + + + + + + + + + @foreach ($countries as $code => $countryName) + {{ $countryName }} + @endforeach + + + + + + + +
+
+ +
+ + {{ __('Abbrechen') }} + + + {{ __('Firma anlegen') }} + +
+ +
diff --git a/resources/views/livewire/customer/press-kits/index.blade.php b/resources/views/livewire/customer/press-kits/index.blade.php index a87950e..cf3f9d8 100644 --- a/resources/views/livewire/customer/press-kits/index.blade.php +++ b/resources/views/livewire/customer/press-kits/index.blade.php @@ -1,8 +1,15 @@ resetPage(); } + public function setSavedView(string $view): void + { + $allowed = ['all', 'active', 'drafts', 'inactive', 'shared']; + $this->savedView = in_array($view, $allowed, true) ? $view : 'all'; + $this->resetPage(); + } + + 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 + */ + private function baseQuery(User $user): Builder + { + return app(CustomerCompanyContext::class) + ->accessibleCompanyQuery($user); + } + + /** + * Wendet die "Saved View"-Logik auf eine Query an. + * + * @param Builder $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 $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 $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(); - $context = app(CustomerCompanyContext::class); - $pressKits = $context->accessibleCompanyQuery($user) - ->withCount(['contacts', 'pressReleases']) - ->when(filled($this->search), function ($query): void { - $search = trim($this->search); + $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'); - $query->where(function ($query) use ($search): void { - $query->where('name', 'like', '%'.$search.'%') - ->orWhere('email', 'like', '%'.$search.'%') - ->orWhere('slug', 'like', '%'.$search.'%'); - }); - }) + $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'], ]; } }; ?> -
+
{{-- ============== PAGE HEADER ============== --}}
@@ -55,102 +425,486 @@ new #[Layout('components.layouts.app'), Title('Meine Firmen')] class extends Com

{{ __('Meine Firmen') }}

-

- {{ __('Verwalten Sie Firmen, Pressekontakte und zugeordnete Pressemitteilungen.') }} + +

+ + {{ $counters['companies'] }} {{ __('Firmen') }} + + + + {{ $counters['active'] }} {{ __('aktiv') }} + + + + {{ $counters['press_releases'] }} + {{ __('Pressemitteilungen gesamt') }} + + + + {{ $counters['contacts'] }} + {{ __('Pressekontakte hinterlegt') }} + +
+ +

+ {{ __('Eine Firma ist der Container für Pressemitteilungen: Stammdaten, Boilerplate, Pressekontakte. Anlage ohne separate Freigabe — die redaktionelle Prüfung erfolgt erst bei der Pressemitteilung.') }}

- - {{ __('Firma anlegen anfragen') }} + + {{ __('Export') }} + + {{ __('bald') }} + + + + {{ __('Firma anlegen') }}
- {{-- ============== FILTER-PANEL ============== --}} -
-
- {{ __('Filter & Suche') }} -
-
- -
-
+ {{-- ============== SAVED VIEW TABS ============== --}} + + + {{-- ============== FILTER + SUCHE ============== --}} +
+
+ +
+ {{ __('Portal') }}: + + @switch($portalFilter) + @case('presseecho') presseecho @break + @case('businessportal24') businessportal24 @break + @default {{ __('Alle') }} + @endswitch + + + + + {{ __('Alle Portale') }} + presseecho + businessportal24 + + -
-
- {{ $company->slug }} -
+ + + + {{ __('Alle Rollen') }} + {{ __('Owner') }} + {{ __('Verantwortlich') }} + {{ __('Mitglied') }} + + -
- {{ $company->portal?->label() ?? __('Portal unbekannt') }} - {{ $context->roleLabelFor($company, $user) }} - @if ($company->disable_footer_code) - {{ __('Footer-Code aus') }} - @endif -
+ -
-
-
- {{ __('Pressemitteilungen') }} -
-
- {{ $company->press_releases_count }} -
-
-
-
- {{ __('Pressekontakte') }} -
-
- {{ $company->contacts_count }} -
-
-
-
+ -
- - {{ __('Firma öffnen') }} - -
- - @empty -
-
-
- -
-
- {{ __('Keine Firmen gefunden') }} -
-

- {{ __('Prüfen Sie die Suche oder wenden Sie sich an den Support, wenn eine Firma fehlen sollte.') }} -

- - {{ __('Profil prüfen') }} - -
-
- @endforelse +
+ + +
+ + + + {{-- View-Toggle Karten/Liste --}} +
+ + +
+
- {{ $pressKits->links() }} + {{-- ============== CONTENT-HOST ============== --}} +
+ + @if ($pressKits->isEmpty()) + {{-- Empty States --}} + @if ($hasActiveFilters) + {{-- Empty: Filter ohne Treffer --}} +
+
+
+ +
+

{{ __('Keine Firmen mit diesen Filtern') }}

+

+ {{ __('Aktive Filter passen auf keine Einträge. Filter zurücksetzen oder weiter fassen.') }} +

+
+ + {{ __('Alle Filter zurücksetzen') }} + +
+
+
+ @else + {{-- Empty: noch keine Firma --}} +
+
+
+ +
+

{{ __('Noch keine Firma angelegt') }}

+

+ {{ __('Lege deine erste Firma an. Du kannst direkt im Anschluss eine Pressemitteilung darauf veröffentlichen — eine separate Freigabe der Firma ist nicht erforderlich.') }} +

+
+ + {{ __('Erste Firma anlegen') }} + +
+
+
+
01
+
+ {{ __('Stammdaten erfassen') }} +
+
+
+
02
+
+ {{ __('Boilerplate schreiben') }} +
+
+
+
03
+
+ {{ __('Pressekontakte zuordnen') }} +
+
+
+
+
+ @endif + + @elseif ($viewMode === 'cards') + {{-- Karten-Ansicht --}} +
+ @foreach ($pressKits as $company) +
+
+ +
+ @if ($company->is_active) + {{ __('Aktiv') }} + @else + {{ __('Inaktiv') }} + @endif +
+
+ +
+

{{ $company->name }}

+ @if (filled($company->panel_meta_line)) +
{{ $company->panel_meta_line }}
+ @endif +
+ +
+ @if ($company->portal === \App\Enums\Portal::Both) + presseecho + businessportal24 + @elseif ($company->portal === \App\Enums\Portal::Presseecho) + presseecho + @elseif ($company->portal === \App\Enums\Portal::Businessportal24) + businessportal24 + @endif + + + {{ $company->panel_role_label }} + +
+ +
+
+ {{ $company->press_releases_count }} + {{ __('PMs') }} +
+
+ {{ $company->contacts_count }} + {{ __('Kontakte') }} +
+
+ + {{ $company->panel_last_press_release_short }} + + {{ __('letzte PM') }} +
+
+ + +
+ @endforeach + + {{-- Add-Tile am Ende des Grids, nur auf der letzten Seite --}} + @if ($pressKits->currentPage() === $pressKits->lastPage()) + + + + + {{ __('Neue Firma anlegen') }} + + {{ __('Stammdaten und Boilerplate. Die Anlage benötigt keine separate Freigabe.') }} + + + @endif +
+ + @else + {{-- Listen-Ansicht --}} +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + @foreach ($pressKits as $company) + + + + + + + + + + + + @endforeach + +
{{ __('Firma') }}{{ __('Portal') }}{{ __('Rolle') }}{{ __('Status') }}{{ __('PMs') }}{{ __('Kontakte') }}{{ __('Letzte PM') }}
+ + + + {{ $company->name }} + + @if (filled($company->panel_meta_line)) +
{{ $company->panel_meta_line }}
+ @endif +
+
+ @if ($company->portal === \App\Enums\Portal::Both) + presseecho + businessportal24 + @elseif ($company->portal === \App\Enums\Portal::Presseecho) + presseecho + @elseif ($company->portal === \App\Enums\Portal::Businessportal24) + businessportal24 + @endif +
+
+ + {{ $company->panel_role_label }} + + + @if ($company->is_active) + {{ __('Aktiv') }} + @else + {{ __('Inaktiv') }} + @endif + + {{ $company->press_releases_count }} + + {{ $company->contacts_count }} + + + {{ $company->panel_last_press_release_date }} + + +
+ + +
+
+
+
+ @endif + +
+ + {{-- Pagination --}} + @if ($pressKits->hasPages()) +
+ {{ $pressKits->links('components.portal.pagination', ['scrollTo' => '[data-state-host]']) }} +
+ @endif + + {{-- ============== ROLLEN-LEGENDE ============== --}} +
+
+
+
{{ __('Rollen pro Firma') }}
+

+ {{ __('Mehrere Personen können einer Firma zugeordnet sein. Die Rolle steuert, was im Backend möglich ist.') }} +

+
+ +
+
+ {{ __('Owner') }} +
    +
  • {{ __('Stammdaten & Boilerplate') }}
  • +
  • {{ __('Pressekontakte verwalten') }}
  • +
  • {{ __('PMs erstellen, einreichen, archivieren') }}
  • +
  • {{ __('Weitere Mitglieder einladen') }}
  • +
+
+
+ {{ __('Verantwortlich') }} +
    +
  • {{ __('Stammdaten & Boilerplate') }}
  • +
  • {{ __('Pressekontakte verwalten') }}
  • +
  • {{ __('PMs erstellen & einreichen') }}
  • +
  • {{ __('keine Mitglieder-Verwaltung') }}
  • +
+
+
+ + {{ __('Mitglied') }} + · {{ __('bald erweitert') }} + +
    +
  • {{ __('PMs einsehen') }}
  • +
  • {{ __('Stammdaten lesen') }}
  • +
  • {{ __('keine Bearbeitung') }}
  • +
+
+
+
+
diff --git a/resources/views/livewire/customer/press-releases/create.blade.php b/resources/views/livewire/customer/press-releases/create.blade.php index 3ee7cf8..aa7fa78 100644 --- a/resources/views/livewire/customer/press-releases/create.blade.php +++ b/resources/views/livewire/customer/press-releases/create.blade.php @@ -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> + */ + 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,34 +197,35 @@ 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 = $this->companyContact((int) $this->contactId, (int) $company->id); + $contact = null; - if (! $contact) { - $this->addError('contactId', __('Der gewählte Pressekontakt gehört nicht zu dieser Firma.')); + if ($this->contactId) { + $contact = $this->companyContact((int) $this->contactId, (int) $company->id); - return; + 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, ]); - $pr->contacts()->sync([$contact->id]); + 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 {{-- ============== 2-COLUMN GRID ============== --}} -
+
{{-- =================== LINKS: SCHREIBFLÄCHE =================== --}}
@@ -513,7 +615,10 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
- {{-- 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.
@@ -529,6 +634,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
+ --}} {{-- 7) BOILERPLATE --}}
@@ -655,14 +761,46 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
- {{-- Portal (Read-only) --}} + {{-- Kategorie (Pflichtfeld - prominent direkt unter Status) --}} +
+
+ + {{ __('Kategorie') }} + * + +
+
+ + + + @foreach ($categories as $cat) + + @endforeach + + + {{ __('Pflichtfeld — steuert, in welcher Rubrik die PM erscheint.') }} + +
+
+ + {{-- 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
{{ __('Portal') }}
- {{ $selectedPortalLabel }} + + {{ $selectedPortalLabel }} + {{ __('automatisch aus der Firma') }} @@ -691,7 +829,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex @endif @else - {{ __('Kontakt für diese PM') }} * + {{ __('Kontakt für diese PM') }} @foreach ($selectedCompanyContacts as $contact) @@ -708,18 +846,25 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex - @php($activeContact = $selectedCompanyContacts->firstWhere('id', (int) $contactId)) - @if ($activeContact && empty($activeContact->phone)) + @if (! $contactId)
- {{ __('Kein Telefon hinterlegt — Journalisten greifen oft direkt zum Hörer.') }} + {{ __('Noch kein Pressekontakt ausgewählt. Empfohlen, aber nicht zwingend.') }}
+ @else + @php($activeContact = $selectedCompanyContacts->firstWhere('id', (int) $contactId)) + @if ($activeContact && empty($activeContact->phone)) +
+ + {{ __('Kein Telefon hinterlegt — Journalisten greifen oft direkt zum Hörer.') }} +
+ @endif @endif @endif
- {{-- Themen-Tags + Kategorie --}} + {{-- Themen-Tags --}}
{{ __('Themen-Tags') }} @@ -769,20 +914,8 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
@endif - - {{ __('Kategorie') }} * - - - @foreach ($categories as $cat) - @php($catName = $cat->translations->firstWhere('locale', 'de')?->name ?? $cat->id) - - @endforeach - - - -

- {{ __('Tags helfen bei SEO und Auffindbarkeit. Die Kategorie steuert, in welcher Rubrik die PM erscheint.') }} + {{ __('Tags helfen bei SEO und Auffindbarkeit.') }}

@@ -792,7 +925,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
{{ __('Veröffentlichung') }}
-
+
- + + + @if ($publishMode === 'scheduled') + + {{ __('Veröffentlichungstermin') }} + + {{ __('Frühestens 5 Min. in der Zukunft.') }} + + + @endif + +
+ +

+ {{ __('Die PM ist intern frei, aber öffentlich erst ab gewähltem Zeitpunkt sichtbar.') }} +

+ + @if ($useEmbargo) + + {{ __('Sperrfrist bis') }} + + + + @endif +
@@ -851,7 +1019,6 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
  • · {{ __('KI-Titel-Optimierung & KI-Lektorat live') }}
  • -
  • · {{ __('Geplante Veröffentlichung / Scheduling') }}
  • · {{ __('Versionshistorie & Kommentare') }}
  • · {{ __('Portal-Vorschau (presseecho vs. BP24)') }}
diff --git a/resources/views/livewire/customer/press-releases/edit.blade.php b/resources/views/livewire/customer/press-releases/edit.blade.php index ccba0a7..64e2212 100644 --- a/resources/views/livewire/customer/press-releases/edit.blade.php +++ b/resources/views/livewire/customer/press-releases/edit.blade.php @@ -4,9 +4,16 @@ use App\Enums\Portal; use App\Enums\PressReleaseStatus; use App\Models\Category; use App\Models\Company; +use App\Models\Contact; use App\Models\PressRelease; +use App\Services\Customer\CustomerCompanyContext; +use App\Services\PressRelease\BlacklistViolationException; use App\Services\PressRelease\PressReleaseHtmlSanitizer; +use App\Services\PressRelease\PressReleaseService; +use Flux\Flux; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Validation\Rule; +use Livewire\Attributes\Computed; use Livewire\Attributes\Layout; use Livewire\Attributes\Locked; use Livewire\Attributes\Title; @@ -25,14 +32,32 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl public int|string|null $categoryId = null; + public int|string|null $contactId = null; + public string $title = ''; + public string $subtitle = ''; + public string $text = ''; public string $keywords = ''; public string $backlinkUrl = ''; + public string $boilerplateOverride = ''; + + public bool $useBoilerplateOverride = false; + + public string $publishMode = 'now'; + + public ?string $scheduledAt = null; + + public bool $useEmbargo = false; + + public ?string $embargoAt = null; + + public string $currentStatus = ''; + public function mount(int $id): void { $this->id = $id; @@ -46,14 +71,30 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl __('Nur Entwürfe und abgelehnte Pressemitteilungen können bearbeitet werden.') ); + $this->currentStatus = $pr->status->value; $this->portal = $pr->portal->value; $this->language = $pr->language; $this->companyId = $pr->company_id; $this->categoryId = $pr->category_id; $this->title = $pr->title; + $this->subtitle = $pr->subtitle ?? ''; $this->text = $pr->text; $this->keywords = $pr->keywords ?? ''; $this->backlinkUrl = $pr->backlink_url ?? ''; + $this->boilerplateOverride = $pr->boilerplate_override ?? ''; + $this->useBoilerplateOverride = filled($pr->boilerplate_override); + $this->contactId = $pr->contacts()->withoutGlobalScopes()->first()?->id + ?? $this->defaultContactIdFor((int) $pr->company_id); + + if ($pr->scheduled_at) { + $this->publishMode = 'scheduled'; + $this->scheduledAt = $pr->scheduled_at->format('Y-m-d\TH:i'); + } + + if ($pr->embargo_at) { + $this->useEmbargo = true; + $this->embargoAt = $pr->embargo_at->format('Y-m-d\TH:i'); + } } public function updatedCompanyId(): void @@ -63,19 +104,139 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl if ($company?->portal) { $this->portal = $company->portal->value; } + + if ($company) { + $contactStillValid = $this->companyContact((int) $this->contactId, (int) $company->id); + + if (! $contactStillValid) { + $this->contactId = $this->defaultContactIdFor((int) $company->id); + } + } else { + $this->contactId = null; + } + + unset($this->tags, $this->presubmitChecks); } - public function save(): void + public function addTag(string $tag): void { - $this->validate([ + $tag = trim($tag); + + if ($tag === '') { + return; + } + + $existing = $this->tagsArray(); + + if (count($existing) >= 5) { + return; + } + + if (in_array($tag, $existing, true)) { + return; + } + + $existing[] = $tag; + $this->keywords = implode(', ', $existing); + + unset($this->tags, $this->presubmitChecks); + } + + public function removeTag(string $tag): void + { + $existing = array_values(array_filter( + $this->tagsArray(), + fn (string $existingTag): bool => $existingTag !== $tag, + )); + + $this->keywords = implode(', ', $existing); + + 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 „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. + */ + 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> + */ + 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'])], + ]; + + 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 save(bool $submitAfterSave = false): void + { + try { + $this->validate($this->formRules()); + } catch (\Illuminate\Validation\ValidationException $e) { + $this->notifyValidationError($e); + + throw $e; + } $pr = $this->getMyPR(); $this->authorize('update', $pr); @@ -84,10 +245,24 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl 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; $cleanText = app(PressReleaseHtmlSanitizer::class)->clean($this->text); @@ -98,19 +273,67 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl 'company_id' => (int) $this->companyId, 'category_id' => (int) $this->categoryId, 'title' => $this->title, + 'subtitle' => trim($this->subtitle) ?: null, 'text' => $cleanText, + 'boilerplate_override' => $this->useBoilerplateOverride && trim($this->boilerplateOverride) !== '' + ? trim($this->boilerplateOverride) + : 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, ]); - session()->flash('success', __('Pressemitteilung gespeichert.')); + $pr->contacts()->sync($contact ? [$contact->id] : []); + + if ($submitAfterSave) { + $this->authorize('submitForReview', $pr); + + try { + app(PressReleaseService::class)->submitForReview($pr->fresh()); + } catch (BlacklistViolationException $e) { + Flux::toast( + heading: __('Automatisch abgelehnt'), + text: __('Unzulässiges Wort gefunden: ":word". Bitte überarbeiten.', ['word' => $e->word]), + variant: 'danger', + duration: 8000, + ); + + $this->redirect(route('me.press-releases.show', $pr->id), navigate: true); + + return; + } + + Flux::toast( + heading: __('Eingereicht'), + text: __('Die Redaktion prüft die Pressemitteilung typischerweise innerhalb von 24 h.'), + variant: 'success', + ); + } else { + Flux::toast( + heading: __('Gespeichert'), + text: __('Deine Änderungen sind gesichert.'), + variant: 'success', + ); + } + $this->redirect(route('me.press-releases.show', $pr->id), navigate: true); } + public function saveAndSubmit(): void + { + $this->save(submitAfterSave: true); + } + public function with(): array { $user = auth()->user(); - $myCompanies = $user->companies()->orderBy('name')->get(['companies.id', 'companies.name', 'companies.portal']); + $context = app(CustomerCompanyContext::class); + $myCompanies = $context->companiesFor($user); $selectedCompany = $this->selectedCompany(); $categories = Category::query() @@ -122,10 +345,112 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl return [ 'myCompanies' => $myCompanies, 'categories' => $categories, + 'selectedCompany' => $selectedCompany, + 'selectedCompanyContacts' => $selectedCompany + ? $this->companyContacts((int) $selectedCompany->id) + : Contact::query()->whereRaw('0 = 1')->get(), 'selectedPortalLabel' => $selectedCompany?->portal?->label() ?? __('Wird aus der Firma übernommen'), + 'tagSuggestions' => $this->tagSuggestionsFor($selectedCompany), ]; } + #[Computed] + public function tags(): array + { + return $this->tagsArray(); + } + + #[Computed] + public function presubmitChecks(): array + { + $titleLen = mb_strlen(trim($this->title)); + $textLen = app(PressReleaseHtmlSanitizer::class)->plainTextLength($this->text); + $tagsCount = count($this->tagsArray()); + + return [ + [ + 'key' => 'title', + 'status' => $titleLen >= 5 ? 'ok' : 'err', + 'label' => __('Titel vorhanden'), + 'sub' => $titleLen > 0 ? __(':n Zeichen', ['n' => $titleLen]) : __('Noch leer'), + ], + [ + 'key' => 'text', + 'status' => $textLen >= 600 ? 'ok' : ($textLen >= 50 ? 'warn' : 'err'), + 'label' => __('Mindestlänge Fließtext erreicht'), + 'sub' => __(':n / 600 Zeichen empfohlen', ['n' => number_format($textLen, 0, ',', '.')]), + ], + [ + 'key' => 'company', + 'status' => $this->companyId ? 'ok' : 'err', + 'label' => __('Firma zugeordnet'), + 'sub' => $this->selectedCompany()?->name ?? __('Keine Firma gewählt'), + ], + [ + 'key' => 'category', + 'status' => $this->categoryId ? 'ok' : 'err', + 'label' => __('Kategorie gewählt'), + 'sub' => $this->categoryId ? '' : __('Kategorie ist Pflicht'), + ], + [ + 'key' => 'contact', + 'status' => $this->contactId ? 'ok' : 'warn', + 'label' => __('Pressekontakt zugeordnet'), + 'sub' => $this->contactId ? '' : __('empfohlen — Journalisten greifen direkt zum Hörer'), + ], + [ + 'key' => 'tags', + 'status' => $tagsCount >= 1 ? 'ok' : 'warn', + 'label' => __('Themen-Tags vergeben'), + 'sub' => $tagsCount >= 1 + ? trans_choice('{1}:n Tag|[2,*]:n Tags', $tagsCount, ['n' => $tagsCount]) + : __('empfohlen für SEO & Auffindbarkeit'), + ], + ]; + } + + /** + * @return list + */ + private function tagsArray(): array + { + if (trim($this->keywords) === '') { + return []; + } + + return collect(explode(',', $this->keywords)) + ->map(fn (string $tag): string => trim($tag)) + ->filter() + ->unique() + ->values() + ->all(); + } + + private function defaultContactIdFor(int $companyId): ?int + { + return $this->companyContacts($companyId)->first()?->id; + } + + /** + * @return Collection + */ + private function companyContacts(int $companyId): Collection + { + return Contact::withoutGlobalScopes() + ->where('company_id', $companyId) + ->orderBy('last_name') + ->orderBy('first_name') + ->get(['id', 'company_id', 'first_name', 'last_name', 'responsibility', 'phone', 'email']); + } + + private function companyContact(int $contactId, int $companyId): ?Contact + { + return Contact::withoutGlobalScopes() + ->where('company_id', $companyId) + ->whereKey($contactId) + ->first(); + } + private function getMyPR(): PressRelease { return PressRelease::withoutGlobalScopes() @@ -135,104 +460,579 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl private function selectedCompany(): ?Company { - return auth()->user() - ->companies() - ->whereKey((int) $this->companyId) - ->first(['companies.id', 'companies.portal']); + if (! $this->companyId) { + return null; + } + + return app(CustomerCompanyContext::class) + ->findFor(auth()->user(), (int) $this->companyId); + } + + /** + * @return list + */ + private function tagSuggestionsFor(?Company $company): array + { + $defaults = [ + __('Mittelstand'), + __('Unternehmen'), + __('Eröffnung'), + __('Innovation'), + __('Nachhaltigkeit'), + ]; + + if (! $company) { + return $defaults; + } + + $portalLabel = $company->portal?->label(); + + return array_values(array_unique(array_filter([ + $portalLabel, + $company->country_code === 'DE' ? __('Deutschland') : null, + ...$defaults, + ]))); } }; ?> -
+
{{-- ============== PAGE HEADER ============== --}}
{{ __('User Backend') }} {{ __('Mein Bereich · Bearbeiten') }} + @if ($currentStatus === 'rejected') + {{ __('Abgelehnt') }} + @else + {{ __('Entwurf') }} + @endif ID {{ $id }}

{{ __('Pressemitteilung bearbeiten') }}

- {{ __('Inhalt und Metadaten der Pressemitteilung aktualisieren.') }} + {{ __('Schreibfläche links, Steuerung rechts. Pflichtfelder werden rechts in der Checkliste angezeigt.') }}

- - {{ __('Zurück') }} + + {{ __('Vorschau / Detail') }} + + + {{ __('Zur Liste') }}
-
-
+ {{-- ============== 2-COLUMN GRID ============== --}} +
+ + {{-- =================== LINKS: SCHREIBFLÄCHE =================== --}} +
+ + {{-- 1) FIRMA-SELEKTOR --}} +
+
+ {{ __('Für Firma') }} + + + @foreach ($myCompanies as $c) + + @endforeach + + + {{ __('Boilerplate und Pressekontakt werden bei Wechsel angepasst.') }} + + + @if ($selectedCompany) + + {{ __('Firmenprofil') }} + + @endif +
+ +
+ + {{-- 2) TITEL --}} +
+
+
+ + {{ __('Titel / Headline') }} * + +
+ @php + $titleLen = mb_strlen($title); + $titleClass = $titleLen >= 40 && $titleLen <= 90 ? 'good' : ($titleLen > 0 ? 'warn' : ''); + $titleBar = min(100, max(0, ($titleLen / 100) * 100)); + @endphp + + + {{ $titleLen }} / 100 + + {{ __('KI-Titel · bald') }} +
+
+ +

+ {{ __('40–90 Zeichen empfohlen. Konkret, ohne Marketing-Floskeln.') }} +

+ +
+
+ + {{-- 3) SUBTITLE --}} +
+
+
+ + {{ __('Untertitel') }} + + — {{ __('optional') }} + + + @php + $subLen = mb_strlen($subtitle); + $subBar = min(100, max(0, ($subLen / 200) * 100)); + @endphp + + + {{ $subLen }} / 200 + +
+ + +
+
+ + {{-- 4) FLIESSTEXT --}} +
+
+
+ + {{ __('Fließtext') }} * + +
+ @php + $textLen = app(\App\Services\PressRelease\PressReleaseHtmlSanitizer::class)->plainTextLength($text); + $textClass = $textLen >= 600 ? 'good' : ($textLen >= 50 ? 'warn' : ''); + $textBar = min(100, max(0, ($textLen / 3500) * 100)); + @endphp + + + {{ number_format($textLen, 0, ',', '.') }} / 3.500 Z. + + {{ __('KI-Lektorat · bald') }} +
+
+ + + +
+ + + + + {{ __('KI-Lektorat') }} + {{ __('liest Korrektur, schlägt Kürzungen vor und prüft auf werbliche Sprache. Erscheint hier inline — bald verfügbar.') }} + +
+
+
+ + {{-- 5) MEDIEN — Image-Manager direkt eingebunden --}} + + + {{-- 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. + + --}} + + {{-- 7) BOILERPLATE --}} +
+
+
+ + {{ __('Über das Unternehmen') }} + + — {{ __('Boilerplate aus Firma') }} + + + +
+ + @if ($selectedCompany?->boilerplate) +
+

{!! nl2br(e($selectedCompany->boilerplate)) !!}

+ @if ($selectedCompany->website) +

+ {{ __('Web') }}: + {{ $selectedCompany->website }} +

+ @endif +
+ @else +
+ {{ __('Für diese Firma ist noch kein Boilerplate-Text hinterlegt. Du kannst entweder einen Override-Text für diese PM setzen oder das Firmenprofil ergänzen.') }} +
+ @endif + + @if ($useBoilerplateOverride) +
+ + +
+ @endif + +

+ {{ __('Wird automatisch unter jeder Pressemitteilung dieser Firma angefügt. Pro PM editierbar.') }} +

+
+
+ +
+ {{-- /Schreibfläche --}} + + {{-- =================== RECHTS: SETTINGS-SIDEBAR =================== --}} +
- -
+ +
diff --git a/resources/views/livewire/customer/press-releases/index.blade.php b/resources/views/livewire/customer/press-releases/index.blade.php index 58b2f0d..688541a 100644 --- a/resources/views/livewire/customer/press-releases/index.blade.php +++ b/resources/views/livewire/customer/press-releases/index.blade.php @@ -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
- {{-- ============== FLASH ============== --}} - @if (session('success')) -
- {{ session('success') }} -
- @endif - @if (session('error')) -
- {{ session('error') }} -
- @endif + {{-- Flash-Banner ersetzt durch im Layout. --}} {{-- ============== PAGE HEADER ============== --}}
@@ -504,6 +502,18 @@ new #[Layout('components.layouts.app'), Title('Meine Pressemitteilungen')] class
{{ $dateSubLabel }} · {{ $primaryDate?->format('H:i') }}
+ @if ($status === 'review' && $pr->scheduled_at && $pr->scheduled_at->isFuture()) +
+ + {{ __('geplant') }} · {{ $pr->scheduled_at->format('d.m. H:i') }} +
+ @endif + @if ($pr->embargo_at && $pr->embargo_at->isFuture()) +
+ + {{ __('Embargo bis') }} {{ $pr->embargo_at->format('d.m.') }} +
+ @endif @@ -520,7 +530,7 @@ new #[Layout('components.layouts.app'), Title('Meine Pressemitteilungen')] class @endforeach
- {{ $pressReleases->links() }} + {{ $pressReleases->links('components.portal.pagination') }}
@elseif ($hasAnyPR && $search !== '') {{-- Empty: Suche ohne Treffer --}} diff --git a/resources/views/livewire/customer/press-releases/show.blade.php b/resources/views/livewire/customer/press-releases/show.blade.php index da1beef..27f9cc1 100644 --- a/resources/views/livewire/customer/press-releases/show.blade.php +++ b/resources/views/livewire/customer/press-releases/show.blade.php @@ -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')) -
- {{ session('success') }} -
- @endif - @if (session('error')) -
- {{ session('error') }} -
- @endif + {{-- Flash-Banner ersetzt durch im Layout. --}} {{-- ============== PAGE HEADER ============== --}}
@@ -139,6 +138,11 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends

{{ $pr->title }}

+ @if ($pr->subtitle) +

+ {{ $pr->subtitle }} +

+ @endif

{{ $pr->company?->name ?? '–' }} · @@ -333,8 +337,31 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends {{ number_format($pr->hits, 0, ',', '.') }}

+ @if ($pr->scheduled_at) +
+
{{ __('Geplante Veröffentlichung') }}
+
+ {{ $pr->scheduled_at->format('d.m.Y H:i') }} +
+
+ @endif + @if ($pr->embargo_at) +
+
{{ __('Sperrfrist bis') }}
+
+ {{ $pr->embargo_at->format('d.m.Y H:i') }} +
+
+ @endif + @if ($pr->no_export) +
+ + {{ __('Kein Export aktiv (PM wird nicht über Feeds verteilt).') }} +
+ @endif +
@if ($statusLogs->isNotEmpty()) @@ -406,4 +433,22 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends @endif + + {{-- ============== BOILERPLATE-OVERRIDE ============== --}} + @if ($pr->boilerplate_override) +
+
+ {{ __('Eigener Abbinder (Boilerplate)') }} + {{ __('Override') }} +
+
+

+ {{ __('Dieser Text wird für diese Pressemitteilung anstelle des Standard-Abbinders der Firma verwendet.') }} +

+
+ {{ $pr->boilerplate_override }} +
+
+
+ @endif diff --git a/resources/views/livewire/customer/profile.blade.php b/resources/views/livewire/customer/profile.blade.php index 1607afe..c530e6a 100644 --- a/resources/views/livewire/customer/profile.blade.php +++ b/resources/views/livewire/customer/profile.blade.php @@ -1,20 +1,14 @@ 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

{{ __('Mein Profil') }}

-

- {{ __('Hier pflegen Sie Ihre persönlichen Konto- und Profildaten. Firmendaten verwalten Sie direkt in der jeweiligen Firma.') }} +

+ {{ __('Hier verwalten Sie Ihre Rechnungsadresse und persönlichen Profileinstellungen. Firmendaten liegen separat in der Firmenverwaltung.') }}

+
+ + {{ __('Firmen verwalten') }} + +
@if (session('profile-status')) @@ -362,60 +214,42 @@ new #[Layout('components.layouts.app'), Title('Mein Profil')] class extends Comp @endif
-
-
-
- {{ __('Konto') }} -
-
- - - - - - -
-
- -
-
- {{ __('Profil') }} -
-
- - @foreach ($salutations as $key => $label) - - @endforeach - - - - - - - - -
-
-
-
{{ __('Rechnungsadresse') }} -
-
-

- {{ __('Diese Angaben werden für künftige Rechnungen verwendet. Eine vollständige Rechnungsadresse benötigt Name, Adresse, PLZ, Ort und Land.') }} -

- - @if (! $this->billingIsComplete()) -
- -
- {{ __('Rechnungsadresse noch unvollständig. Bitte ergänzen Sie die Pflichtangaben, bevor neue Buchungen sauber abgerechnet werden können.') }} -
-
+ @if ($billingComplete) + {{ __('vollständig') }} + @else + {{ __('unvollständig') }} @endif +
+
+
+

+ {{ __('Diese Adresse ist die maßgebliche Grundlage für Rechnungen und künftige Buchungen.') }} +

+

+ {{ __('Pflichtangaben sind Rechnungsname, Adresse, PLZ, Ort und Land. Die USt-ID ist optional.') }} +

+ + @if (! $billingComplete) +
+ +
+ {{ __('Bitte ergänzen Sie die Rechnungsadresse, damit neue Buchungen sauber abgerechnet werden können.') }} +
+
+ @else +
+ +
+ {{ __('Ihre Rechnungsadresse ist vollständig hinterlegt.') }} +
+
+ @endif +
@@ -432,52 +266,86 @@ new #[Layout('components.layouts.app'), Title('Mein Profil')] class extends Comp
+
+ + {{ __('Rechnungsadresse speichern') }} + +
+
+
+
+ {{ __('Profileinstellungen') }} +
+
+ + + @foreach ($salutations as $key => $label) + + @endforeach + + + + + + + + + @foreach ($countries as $code => $name) + + @endforeach + +
+
+ + {{ __('Profil speichern') }} + +
+
+ +
+
+ {{ __('Konto & Sicherheit') }} +
+
+ + + + + +
+ + {{ __('Konto-Sicherheit öffnen') }} + +
+
+
+
+
- {{ __('Aktionen') }} + {{ __('Einstellungen') }}
-
- {{ __('Profil speichern') }} +
+ + +
+
+ + {{ __('Einstellungen speichern') }} +
- -
-
- {{ __('Zugeordnete Firmen') }} - - {{ $companies->count() }} {{ __('Einträge') }} - -
- - @forelse ($companies as $company) -
-
-

{{ $company->name }}

-
- {{ $company->portal?->label() ?? '–' }} - {{ $company->pivot->role ?? 'member' }} - @if ($company->owner_user_id === $user->id) - {{ __('Eigentümer') }} - @endif -
-
- @if ($company->owner_user_id === $user->id || in_array($company->pivot->role, ['owner', 'responsible'], true)) - - {{ __('Firma verwalten') }} - - @else - - {{ __('Firma öffnen') }} - - @endif -
- @empty -
- {{ __('Keine Firmen zugeordnet. Bitte wenden Sie sich an den Administrator.') }} -
- @endforelse -
diff --git a/resources/views/partials/admin-head.blade.php b/resources/views/partials/admin-head.blade.php index 8ccaf3c..9e163e4 100644 --- a/resources/views/partials/admin-head.blade.php +++ b/resources/views/partials/admin-head.blade.php @@ -7,8 +7,7 @@ - - +@include('partials.local-fonts') @vite(['resources/css/portal.css', 'resources/js/app.js'], 'build/portal') @livewireStyles diff --git a/resources/views/partials/head.blade.php b/resources/views/partials/head.blade.php index 4cb30cc..09c095c 100644 --- a/resources/views/partials/head.blade.php +++ b/resources/views/partials/head.blade.php @@ -7,10 +7,9 @@ - {{-- Hub × FluxUI Phase 1: Inter Tight + JetBrains Mono + Source Serif 4 (Source Serif 4 nur für brand-mark in Headern, deshalb mitgeladen). --}} - +@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 diff --git a/resources/views/partials/local-fonts.blade.php b/resources/views/partials/local-fonts.blade.php new file mode 100644 index 0000000..9243345 --- /dev/null +++ b/resources/views/partials/local-fonts.blade.php @@ -0,0 +1,3 @@ + + + diff --git a/resources/views/web/layouts/web-master-test.blade.php b/resources/views/web/layouts/web-master-test.blade.php index d164a99..20d0397 100644 --- a/resources/views/web/layouts/web-master-test.blade.php +++ b/resources/views/web/layouts/web-master-test.blade.php @@ -12,7 +12,7 @@ - + @include('partials.local-fonts') @php $font = \App\Helpers\ThemeHelper::getFont(); @@ -25,12 +25,6 @@ @stack('styles') - - @if ($font === 'Montserrat') - - @else - - @endif diff --git a/resources/views/web/layouts/web-master.blade.php b/resources/views/web/layouts/web-master.blade.php index 9a41ee8..fbbc913 100644 --- a/resources/views/web/layouts/web-master.blade.php +++ b/resources/views/web/layouts/web-master.blade.php @@ -13,7 +13,7 @@ - + @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)) - - - @endif -