diff --git a/Modules/Core/Filament/Company/Pages/CompanySettings.php b/Modules/Core/Filament/Company/Pages/CompanySettings.php index 000621b62..4f53ec7f9 100644 --- a/Modules/Core/Filament/Company/Pages/CompanySettings.php +++ b/Modules/Core/Filament/Company/Pages/CompanySettings.php @@ -4,6 +4,7 @@ use BackedEnum; use Filament\Actions\Action; +use Filament\Forms\Components\ColorPicker; use Filament\Forms\Components\FileUpload; use Filament\Forms\Components\Select; use Filament\Forms\Components\Textarea; @@ -153,6 +154,31 @@ protected function getFormSchema(): array ->placeholder('INV-') ->helperText(trans('ip.invoice_number_prefix_help')), ]), + + Section::make(trans('ip.company_branding'))->columns(2)->schema([ + ColorPicker::make(Setting::KEY_PRIMARY_COLOR) + ->label(trans('ip.primary_color')), + + ColorPicker::make(Setting::KEY_ACCENT_COLOR) + ->label(trans('ip.accent_color')), + + Select::make(Setting::KEY_FONT_FAMILY) + ->label(trans('ip.font_family')) + ->options([ + 'Arial' => 'Arial', + 'Helvetica' => 'Helvetica', + 'Georgia' => 'Georgia', + 'Times New Roman' => 'Times New Roman', + ]) + ->placeholder(trans('ip.none')), + + TextInput::make(Setting::KEY_FONT_SIZE) + ->label(trans('ip.font_size')) + ->numeric() + ->minValue(8) + ->maxValue(72) + ->placeholder('14'), + ]), ]), Tab::make('Amounts') @@ -415,6 +441,10 @@ private function allKeys(): array return [ Setting::KEY_COMPANY_NAME, Setting::KEY_INVOICE_NUMBER_PREFIX, + Setting::KEY_PRIMARY_COLOR, + Setting::KEY_ACCENT_COLOR, + Setting::KEY_FONT_FAMILY, + Setting::KEY_FONT_SIZE, Setting::KEY_CURRENCY_CODE, Setting::KEY_DASHBOARD_SHOW_REVENUE_CHART, Setting::KEY_CRON_FREQUENCY, diff --git a/Modules/Core/Models/Setting.php b/Modules/Core/Models/Setting.php index 083423871..c991a8155 100644 --- a/Modules/Core/Models/Setting.php +++ b/Modules/Core/Models/Setting.php @@ -42,6 +42,14 @@ class Setting extends Model public const KEY_INVOICE_NUMBER_PREFIX = 'invoice_number_prefix'; + public const KEY_PRIMARY_COLOR = 'primary_color'; + + public const KEY_ACCENT_COLOR = 'accent_color'; + + public const KEY_FONT_FAMILY = 'font_family'; + + public const KEY_FONT_SIZE = 'font_size'; + public const KEY_CURRENCY_CODE = 'currency_code'; public const KEY_DASHBOARD_SHOW_REVENUE_CHART = 'dashboard_show_revenue_chart'; diff --git a/Modules/Core/Tests/Feature/CompanySettingsTest.php b/Modules/Core/Tests/Feature/CompanySettingsTest.php index 4133cef3e..39e048135 100644 --- a/Modules/Core/Tests/Feature/CompanySettingsTest.php +++ b/Modules/Core/Tests/Feature/CompanySettingsTest.php @@ -84,6 +84,27 @@ public function it_persists_a_long_text_setting(): void $this->assertSame($text, Setting::getForCompany($this->company->id, Setting::KEY_INVOICE_DEFAULT_TERMS)); } + #[Test] + #[Group('per-company')] + public function it_persists_company_branding_settings(): void + { + /* Act */ + Livewire::actingAs($this->user) + ->test(CompanySettings::class) + ->set('data.' . Setting::KEY_PRIMARY_COLOR, '#ff0000') + ->set('data.' . Setting::KEY_ACCENT_COLOR, '#00ff00') + ->set('data.' . Setting::KEY_FONT_FAMILY, 'Georgia') + ->set('data.' . Setting::KEY_FONT_SIZE, 16) + ->call('save') + ->assertHasNoErrors(); + + /* Assert */ + $this->assertSame('#ff0000', Setting::getForCompany($this->company->id, Setting::KEY_PRIMARY_COLOR)); + $this->assertSame('#00ff00', Setting::getForCompany($this->company->id, Setting::KEY_ACCENT_COLOR)); + $this->assertSame('Georgia', Setting::getForCompany($this->company->id, Setting::KEY_FONT_FAMILY)); + $this->assertSame('16', Setting::getForCompany($this->company->id, Setting::KEY_FONT_SIZE)); + } + #[Test] #[Group('per-company')] public function it_prefills_form_state_from_existing_settings(): void diff --git a/Modules/Invoices/Services/InvoiceService.php b/Modules/Invoices/Services/InvoiceService.php index e46b5f8cd..57e09d195 100644 --- a/Modules/Invoices/Services/InvoiceService.php +++ b/Modules/Invoices/Services/InvoiceService.php @@ -5,11 +5,13 @@ use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Mail; +use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; use InvalidArgumentException; use Modules\Clients\Enums\CommunicationType; use Modules\Core\Enums\MailType; use Modules\Core\Models\EmailTemplate; +use Modules\Core\Models\Setting; use Modules\Core\Services\BaseService; use Modules\Core\Support\DateHelpers; use Modules\Core\Support\EmailTemplatePreview; @@ -328,7 +330,10 @@ public function renderHtml(Invoice $invoice): string { $invoice->loadMissing(['company', 'customer', 'invoiceItems']); - return view('invoices::pdf.invoice', ['invoice' => $invoice])->render(); + return view('invoices::pdf.invoice', [ + 'invoice' => $invoice, + 'branding' => $this->resolveBranding($invoice), + ])->render(); } /** @@ -411,6 +416,29 @@ public function createCreditNote(Invoice $invoice): Invoice }); } + /** + * Company branding for the invoice PDF/preview: colors, font, and logo. + * Falls back to the current hardcoded look when a company hasn't set + * any branding, so existing invoices render unchanged. + * + * @return array{primary_color: string, accent_color: string, font_family: string, font_size: string, logo_path: ?string} + */ + private function resolveBranding(Invoice $invoice): array + { + $companyId = $invoice->company_id; + + $logoPath = Setting::getForCompany($companyId, Setting::KEY_INVOICE_LOGO); + $logoDisk = Storage::disk(config('filament.default_filesystem_disk')); + + return [ + 'primary_color' => Setting::getForCompany($companyId, Setting::KEY_PRIMARY_COLOR) ?: '#1f2937', + 'accent_color' => Setting::getForCompany($companyId, Setting::KEY_ACCENT_COLOR) ?: '#6b7280', + 'font_family' => Setting::getForCompany($companyId, Setting::KEY_FONT_FAMILY) ?: 'DejaVu Sans, Helvetica, Arial, sans-serif', + 'font_size' => Setting::getForCompany($companyId, Setting::KEY_FONT_SIZE) ?: '12', + 'logo_path' => $logoPath && $logoDisk->exists($logoPath) ? $logoDisk->path($logoPath) : null, + ]; + } + /** * Shared resolution logic for the "Email Invoice" and "Send Reminder" * modals: loads the named company EmailTemplate once, renders its diff --git a/Modules/Invoices/Tests/Feature/InvoicePdfAndCreditNoteTest.php b/Modules/Invoices/Tests/Feature/InvoicePdfAndCreditNoteTest.php index deb7f6d3a..5bbbc17a1 100644 --- a/Modules/Invoices/Tests/Feature/InvoicePdfAndCreditNoteTest.php +++ b/Modules/Invoices/Tests/Feature/InvoicePdfAndCreditNoteTest.php @@ -2,6 +2,8 @@ namespace Modules\Invoices\Tests\Feature; +use Illuminate\Http\UploadedFile; +use Illuminate\Support\Facades\Storage; use InvalidArgumentException; use Livewire\Livewire; use Modules\Clients\Models\Relation; @@ -9,6 +11,7 @@ use Modules\Core\Database\Seeders\RolesSeeder; use Modules\Core\Enums\UserRole; use Modules\Core\Models\Numbering; +use Modules\Core\Models\Setting; use Modules\Core\Support\PDF\PDFFactory; use Modules\Core\Tests\AbstractCompanyPanelTestCase; use Modules\Invoices\Enums\InvoiceStatus; @@ -63,6 +66,62 @@ public function it_renders_invoice_html_with_number_and_customer(): void $this->assertStringNotContainsString('createInvoice(InvoiceStatus::SENT, ['footer' => 'Thank you for your business.']); + + /* Act */ + $html = $this->service->renderHtml($invoice); + + /* Assert */ + $this->assertStringContainsString('#1f2937', $html); + $this->assertStringContainsString('#6b7280', $html); + $this->assertStringNotContainsString('company->id, Setting::KEY_PRIMARY_COLOR, '#112233'); + Setting::saveForCompany($this->company->id, Setting::KEY_ACCENT_COLOR, '#445566'); + Setting::saveForCompany($this->company->id, Setting::KEY_FONT_FAMILY, 'Georgia'); + Setting::saveForCompany($this->company->id, Setting::KEY_FONT_SIZE, '16'); + + $invoice = $this->createInvoice(InvoiceStatus::SENT, ['footer' => 'Thank you for your business.']); + + /* Act */ + $html = $this->service->renderHtml($invoice); + + /* Assert */ + $this->assertStringContainsString('#112233', $html); + $this->assertStringContainsString('#445566', $html); + $this->assertStringContainsString('Georgia', $html); + $this->assertStringContainsString('16px', $html); + } + + #[Test] + #[Group('crud')] + public function it_renders_the_company_logo_when_set(): void + { + /* Arrange */ + Storage::fake('local'); + $path = UploadedFile::fake()->image('logo.png')->store('invoice-logos', 'local'); + Setting::saveForCompany($this->company->id, Setting::KEY_INVOICE_LOGO, $path); + + $invoice = $this->createInvoice(InvoiceStatus::SENT); + + /* Act */ + $html = $this->service->renderHtml($invoice); + + /* Assert */ + $this->assertStringContainsString(' +@php + $primaryColor = $branding['primary_color']; + $accentColor = $branding['accent_color']; +@endphp +
+ @if ($branding['logo_path']) + {{ $invoice->company?->name }} + @endif
{{ $invoice->company?->name }}
@@ -31,11 +38,11 @@ - - - - - + + + + + @@ -44,7 +51,7 @@ @@ -72,8 +79,8 @@ @endif - - + +
{{ trans('ip.item') }}{{ trans('ip.quantity') }}{{ trans('ip.price') }}{{ trans('ip.discount') }}{{ trans('ip.subtotal') }}{{ trans('ip.item') }}{{ trans('ip.quantity') }}{{ trans('ip.price') }}{{ trans('ip.discount') }}{{ trans('ip.subtotal') }}
{{ $item->item_name }} @if ($item->description) -
{{ $item->description }}
+
{{ $item->description }}
@endif
{{ $item->quantity + 0 }}
{{ trans('ip.total') }}{{ number_format((float) $invoice->invoice_total, 2) }}{{ trans('ip.total') }}{{ number_format((float) $invoice->invoice_total, 2) }}
@@ -92,6 +99,6 @@ @endif @if ($invoice->footer) -
{{ $invoice->footer }}
+
{{ $invoice->footer }}
@endif diff --git a/resources/lang/en/ip.php b/resources/lang/en/ip.php index 6532658e5..399b6fde1 100644 --- a/resources/lang/en/ip.php +++ b/resources/lang/en/ip.php @@ -903,29 +903,29 @@ #endregion #region NUMBERING - 'numbering' => 'Numbering', - 'numberings' => 'Numberings', - 'numbering_company' => 'Company', - 'numbering_company_assignment' => 'Company Assignment', - 'numbering_select_company_help' => 'Select which company this numbering scheme belongs to', - 'numbering_type' => 'Type', - 'numbering_name' => 'Name', - 'numbering_next_id' => 'Next ID', - 'numbering_next_id_help' => 'Can be adjusted to troubleshoot numbering issues', - 'numbering_left_pad' => 'Left Pad', - 'numbering_prefix' => 'Prefix', - 'numbering_format' => 'Format', - 'numbering_format_placeholder' => '{{prefix}}-{{number}}', - 'numbering_format_help' => 'Use {{prefix}}, {{number}}, {{year}}, {{yy}}, {{month}}, {{day}} as placeholders, or click a token below to insert it. Only dash (-) or underscore (_) separators allowed.', - 'numbering_format_helper' => 'You can customize the format using placeholders: {{prefix}} for prefix, {{number}} for sequential number, {{year}} for 4-digit year, {{yy}} for 2-digit year, {{month}} for month, {{day}} for day. The number will be left-padded according to the Left Pad setting.', - 'numbering_format_help_label' => 'Format Help', - 'numbering_group_identifier_format' => 'Group Identifier Format', + 'numbering' => 'Numbering', + 'numberings' => 'Numberings', + 'numbering_company' => 'Company', + 'numbering_company_assignment' => 'Company Assignment', + 'numbering_select_company_help' => 'Select which company this numbering scheme belongs to', + 'numbering_type' => 'Type', + 'numbering_name' => 'Name', + 'numbering_next_id' => 'Next ID', + 'numbering_next_id_help' => 'Can be adjusted to troubleshoot numbering issues', + 'numbering_left_pad' => 'Left Pad', + 'numbering_prefix' => 'Prefix', + 'numbering_format' => 'Format', + 'numbering_format_placeholder' => '{{prefix}}-{{number}}', + 'numbering_format_help' => 'Use {{prefix}}, {{number}}, {{year}}, {{yy}}, {{month}}, {{day}} as placeholders, or click a token below to insert it. Only dash (-) or underscore (_) separators allowed.', + 'numbering_format_helper' => 'You can customize the format using placeholders: {{prefix}} for prefix, {{number}} for sequential number, {{year}} for 4-digit year, {{yy}} for 2-digit year, {{month}} for month, {{day}} for day. The number will be left-padded according to the Left Pad setting.', + 'numbering_format_help_label' => 'Format Help', + 'numbering_group_identifier_format' => 'Group Identifier Format', 'numbering_group_identifier_format_placeholder' => '{{prefix}}-{{year}}-{{number}}', 'numbering_group_identifier_format_help' => 'A separate, optional format used only to group related numbers together (e.g. by year or batch) -- distinct from the main Format above. Uses the same placeholders: {{prefix}}, {{number}}, {{year}}, {{yy}}, {{month}}, {{day}}.', - 'duplicate_invoice_number' => 'Duplicate invoice number :number for company :company', - 'duplicate_quote_number' => 'Duplicate quote number :number for company :company', - 'quote_created_with_number' => 'Quote :number created', - 'invoice_created_with_number' => 'Invoice :number created', + 'duplicate_invoice_number' => 'Duplicate invoice number :number for company :company', + 'duplicate_quote_number' => 'Duplicate quote number :number for company :company', + 'quote_created_with_number' => 'Quote :number created', + 'invoice_created_with_number' => 'Invoice :number created', #endregion #region REPORT BUILDER @@ -1288,17 +1288,22 @@ #endregion #region COMPANY SETTINGS (2026-07-19, epic #508) - 'dashboard_show_revenue_chart' => 'Show Revenue Chart on Dashboard', - 'cron_frequency' => 'Recurring Invoice Frequency', - 'cron_frequency_daily' => 'Daily', - 'cron_frequency_weekly' => 'Weekly', - 'cron_frequency_monthly' => 'Monthly', - 'time_format' => 'Time Format', - 'invoice_numbering' => 'Numbering', - 'invoice_number_prefix' => 'Invoice Number Prefix', - 'invoice_number_prefix_help' => 'Prepended to every auto-generated invoice number for this company (e.g. INV-).', - 'invoice_email_subject' => 'Invoice Email Subject', - 'default_invoice_footer' => 'Default Invoice Footer', - 'default_quote_tax_rate' => 'Default Quote Tax Rate', + 'dashboard_show_revenue_chart' => 'Show Revenue Chart on Dashboard', + 'cron_frequency' => 'Recurring Invoice Frequency', + 'cron_frequency_daily' => 'Daily', + 'cron_frequency_weekly' => 'Weekly', + 'cron_frequency_monthly' => 'Monthly', + 'time_format' => 'Time Format', + 'invoice_numbering' => 'Numbering', + 'invoice_number_prefix' => 'Invoice Number Prefix', + 'invoice_number_prefix_help' => 'Prepended to every auto-generated invoice number for this company (e.g. INV-).', + 'company_branding' => 'Company Branding', + 'primary_color' => 'Primary Color', + 'accent_color' => 'Accent Color', + 'font_family' => 'Font Family', + 'font_size' => 'Font Size', + 'invoice_email_subject' => 'Invoice Email Subject', + 'default_invoice_footer' => 'Default Invoice Footer', + 'default_quote_tax_rate' => 'Default Quote Tax Rate', #endregion ];