From b92fae8da27d1413cd4db9015152bd2d5171f658 Mon Sep 17 00:00:00 2001 From: ahmedraza Date: Sat, 12 Sep 2026 00:25:55 +0530 Subject: [PATCH 1/3] feat: add panel theme selection and apply company-specific styles - Introduced a new enum `PanelTheme` to manage available themes for the company panel. - Added middleware `ApplyCompanyTheme` to dynamically apply the selected theme based on the company settings. - Updated `CompanySettings` page to include a radio selection for theme choice, with descriptions for each theme. - Modified database configuration to support socket connections. - Updated environment variables for testing to use a new database password and socket. - Refactored CSS files to create a base theme and removed unused styles from the previous blue theme. - Updated tests to ensure theme persistence and correct application during requests. --- .env.testing | 3 +- Modules/Core/Enums/PanelTheme.php | 106 ++++++++ .../Company/Pages/CompanySettings.php | 33 +++ .../Http/Middleware/ApplyCompanyTheme.php | 78 ++++++ Modules/Core/Models/Setting.php | 2 + Modules/Core/Providers/AdminPanelProvider.php | 2 +- .../Core/Providers/CompanyPanelProvider.php | 6 +- Modules/Core/Providers/UserPanelProvider.php | 2 +- .../Tests/Feature/PanelThemeSettingTest.php | 206 +++++++++++++++ .../Invoices/Tables/InvoicesTable.php | 5 + .../Company/Widgets/RecentInvoicesWidget.php | 5 +- .../Resources/Quotes/Tables/QuotesTable.php | 5 + .../Company/Widgets/RecentQuotesWidget.php | 5 +- config/database.php | 4 +- phpunit.xml | 3 +- public/index.php | 1 + resources/css/filament/company/base.css | 138 ++++++++++ .../filament/company/invoiceplane-blue.css | 244 +++--------------- resources/lang/en/ip.php | 9 + vite.config.js | 1 + 20 files changed, 635 insertions(+), 223 deletions(-) create mode 100644 Modules/Core/Enums/PanelTheme.php create mode 100644 Modules/Core/Http/Middleware/ApplyCompanyTheme.php create mode 100644 Modules/Core/Tests/Feature/PanelThemeSettingTest.php create mode 100644 resources/css/filament/company/base.css diff --git a/.env.testing b/.env.testing index da0fbcc3c..7ecbda92d 100644 --- a/.env.testing +++ b/.env.testing @@ -24,9 +24,10 @@ LOG_LEVEL=debug DB_CONNECTION=mysql DB_HOST=127.0.0.1 DB_PORT=3306 +DB_SOCKET=/var/lib/mysql/mysql.sock DB_DATABASE=invoiceplane_test DB_USERNAME=root -DB_PASSWORD=root +DB_PASSWORD=Mysql@789789 SESSION_DRIVER=array SESSION_LIFETIME=120 diff --git a/Modules/Core/Enums/PanelTheme.php b/Modules/Core/Enums/PanelTheme.php new file mode 100644 index 000000000..5168e32ef --- /dev/null +++ b/Modules/Core/Enums/PanelTheme.php @@ -0,0 +1,106 @@ + + */ + public static function options(): array + { + $options = []; + + foreach (self::cases() as $case) { + $options[$case->value] = $case->label(); + } + + return $options; + } + + /** + * Display label. These are theme names rather than UI copy, so they are + * not translated -- same convention as ReportBand::getLabel(). + */ + public function label(): string + { + return match ($this) { + self::BASE => 'Base', + self::INVOICEPLANE => 'InvoicePlane', + self::INVOICEPLANE_BLUE => 'InvoicePlane Blue', + self::NORD => 'Nord', + self::ORANGE => 'Orange', + self::REDDIT => 'Reddit', + }; + } + + /** + * One-line description of what the theme looks like, shown under the + * option in the settings form. + */ + public function description(): string + { + return match ($this) { + self::BASE => trans('ip.panel_theme_base_description'), + self::INVOICEPLANE => trans('ip.panel_theme_invoiceplane_description'), + self::INVOICEPLANE_BLUE => trans('ip.panel_theme_invoiceplane_blue_description'), + self::NORD => trans('ip.panel_theme_nord_description'), + self::ORANGE => trans('ip.panel_theme_orange_description'), + self::REDDIT => trans('ip.panel_theme_reddit_description'), + }; + } + + /** + * The Vite entrypoint passed to `Panel::viteTheme()`. + */ + public function viteEntrypoint(): string + { + return 'resources/css/filament/company/' . $this->value . '.css'; + } +} diff --git a/Modules/Core/Filament/Company/Pages/CompanySettings.php b/Modules/Core/Filament/Company/Pages/CompanySettings.php index bc0138fd7..6909b163d 100644 --- a/Modules/Core/Filament/Company/Pages/CompanySettings.php +++ b/Modules/Core/Filament/Company/Pages/CompanySettings.php @@ -6,6 +6,7 @@ use Filament\Actions\Action; use Filament\Forms\Components\ColorPicker; use Filament\Forms\Components\FileUpload; +use Filament\Forms\Components\Radio; use Filament\Forms\Components\Select; use Filament\Forms\Components\Textarea; use Filament\Forms\Components\TextInput; @@ -18,6 +19,7 @@ use Filament\Schemas\Components\Section; use Filament\Schemas\Components\Tabs; use Filament\Schemas\Components\Tabs\Tab; +use Modules\Core\Enums\PanelTheme; use Modules\Core\Enums\Permission; use Modules\Core\Models\Numbering; use Modules\Core\Models\Setting; @@ -89,6 +91,12 @@ public function mount(): void $defaults[Setting::KEY_QUOTE_PDF_MARK_SENT] ??= '0'; $defaults[Setting::KEY_SMTP_VERIFY_CERTS] ??= '1'; + // The panel always renders *some* theme, so the field should show the + // one currently in effect rather than nothing at all. + $defaults[Setting::KEY_PANEL_THEME] = PanelTheme::fromValue( + $defaults[Setting::KEY_PANEL_THEME] ?? null, + )->value; + $this->form->fill($defaults); } @@ -97,6 +105,12 @@ public function save(): void $state = $this->form->getState(); $companyId = $this->getCompanyId(); + // The stylesheet is a in the document head, so a Livewire + // round-trip cannot swap it. Note the change here and finish with a + // full page load below. + $themeChanged = PanelTheme::fromValue(Setting::getForCompany($companyId, Setting::KEY_PANEL_THEME)) + !== PanelTheme::fromValue($state[Setting::KEY_PANEL_THEME] ?? null); + foreach ($state as $key => $value) { // Skip foreign keys the form doesn't really own — e.g. unknown // keys leaked from form state. @@ -114,6 +128,12 @@ public function save(): void Setting::saveForCompany($companyId, $key, (string) $value); } + if ($themeChanged) { + $this->redirect(static::getUrl()); + + return; + } + $this->dispatch('saved'); } @@ -155,6 +175,18 @@ protected function getFormSchema(): array ->helperText(trans('ip.invoice_number_prefix_help')), ]), + Section::make(trans('ip.panel_appearance'))->columns(2)->schema([ + Radio::make(Setting::KEY_PANEL_THEME) + ->label(trans('ip.panel_theme')) + ->options(PanelTheme::options()) + ->descriptions(array_map( + fn (PanelTheme $theme): string => $theme->description(), + array_column(PanelTheme::cases(), null, 'value'), + )) + ->helperText(trans('ip.panel_theme_help')) + ->columnSpanFull(), + ]), + Section::make(trans('ip.company_branding'))->columns(2)->schema([ ColorPicker::make(Setting::KEY_PRIMARY_COLOR) ->label(trans('ip.primary_color')), @@ -445,6 +477,7 @@ private function allKeys(): array Setting::KEY_ACCENT_COLOR, Setting::KEY_FONT_FAMILY, Setting::KEY_FONT_SIZE, + Setting::KEY_PANEL_THEME, Setting::KEY_CURRENCY_CODE, Setting::KEY_DASHBOARD_SHOW_REVENUE_CHART, Setting::KEY_CRON_FREQUENCY, diff --git a/Modules/Core/Http/Middleware/ApplyCompanyTheme.php b/Modules/Core/Http/Middleware/ApplyCompanyTheme.php new file mode 100644 index 000000000..042c9ee61 --- /dev/null +++ b/Modules/Core/Http/Middleware/ApplyCompanyTheme.php @@ -0,0 +1,78 @@ +getTheme()` is evaluated inside + * the layout's `` (see `filament::components.layout.base`). So mutating + * the panel from middleware is enough, provided the middleware runs before + * the response is rendered. + * + * It is registered in the company panel's `tenantMiddleware`, which Filament + * always prefixes with its own `IdentifyTenant`, so the tenant is resolved by + * the time this runs. + * + * The theme is set unconditionally, including when the company has not chosen + * one: the panel object is a singleton, so under a persistent worker (Octane) + * an early return would leave the previous request's company theme in place + * for the next one. + */ +class ApplyCompanyTheme +{ + public function handle(Request $request, Closure $next): Response + { + $panel = Filament::getCurrentPanel(); + + if ($panel === null) { + return $next($request); + } + + $companyId = $this->resolveCompanyId(); + + $theme = $companyId === null + ? PanelTheme::default() + : PanelTheme::fromValue(Setting::getForCompany($companyId, Setting::KEY_PANEL_THEME)); + + $panel->viteTheme($theme->viteEntrypoint()); + + return $next($request); + } + + /** + * Tenant first, then the session, then the user's first company -- the + * same order the tenant middleware chain resolves in, so a request that + * arrives before the tenant is on the route still themes correctly. + */ + private function resolveCompanyId(): ?int + { + $tenant = Filament::getTenant(); + + if ($tenant !== null) { + return (int) $tenant->getKey(); + } + + $sessionId = session('current_company_id'); + + if ($sessionId !== null) { + return (int) $sessionId; + } + + $company = Auth::user()?->companies()->first(); + + return $company === null ? null : (int) $company->getKey(); + } +} diff --git a/Modules/Core/Models/Setting.php b/Modules/Core/Models/Setting.php index c991a8155..74390ae74 100644 --- a/Modules/Core/Models/Setting.php +++ b/Modules/Core/Models/Setting.php @@ -50,6 +50,8 @@ class Setting extends Model public const KEY_FONT_SIZE = 'font_size'; + public const KEY_PANEL_THEME = 'panel_theme'; + public const KEY_CURRENCY_CODE = 'currency_code'; public const KEY_DASHBOARD_SHOW_REVENUE_CHART = 'dashboard_show_revenue_chart'; diff --git a/Modules/Core/Providers/AdminPanelProvider.php b/Modules/Core/Providers/AdminPanelProvider.php index 5fca3927e..14824c2d4 100644 --- a/Modules/Core/Providers/AdminPanelProvider.php +++ b/Modules/Core/Providers/AdminPanelProvider.php @@ -39,7 +39,7 @@ public function panel(Panel $panel): Panel return $panel ->id('admin') ->path('admin') - ->viteTheme('resources/css/filament/company/nord.css') + ->viteTheme('resources/css/filament/company/base.css') ->login(Login::class) ->profile(EditProfile::class, isSimple: false) ->passwordReset() diff --git a/Modules/Core/Providers/CompanyPanelProvider.php b/Modules/Core/Providers/CompanyPanelProvider.php index 506e88438..4f9c98038 100644 --- a/Modules/Core/Providers/CompanyPanelProvider.php +++ b/Modules/Core/Providers/CompanyPanelProvider.php @@ -32,6 +32,7 @@ use Modules\Core\Filament\Company\Resources\EmailTemplates\EmailTemplateResource; use Modules\Core\Filament\Company\Resources\NoteTemplates\NoteTemplateResource; use Modules\Core\Filament\Pages\Auth\Login; +use Modules\Core\Http\Middleware\ApplyCompanyTheme; use Modules\Core\Http\Middleware\ConfigureTenant; use Modules\Core\Http\Middleware\EnsureUserCanAccessCompany; use Modules\Core\Http\Middleware\SetTenantFromQueryString; @@ -60,7 +61,7 @@ public function panel(Panel $panel): Panel ->default() ->id('company') ->path('') - ->viteTheme('resources/css/filament/company/nord.css') + ->viteTheme('resources/css/filament/company/base.css') ->login(Login::class) ->passwordReset() ->emailVerification() @@ -88,6 +89,9 @@ public function panel(Panel $panel): Panel SetTenantFromQueryString::class, ConfigureTenant::class, EnsureUserCanAccessCompany::class, + // Re-points viteTheme() at the company's chosen stylesheet; + // must stay last, after the tenant is settled. + ApplyCompanyTheme::class, ], isPersistent: true) // #endregion diff --git a/Modules/Core/Providers/UserPanelProvider.php b/Modules/Core/Providers/UserPanelProvider.php index e646492a3..d0f163a34 100644 --- a/Modules/Core/Providers/UserPanelProvider.php +++ b/Modules/Core/Providers/UserPanelProvider.php @@ -26,7 +26,7 @@ public function panel(Panel $panel): Panel return $panel ->id('user') ->path('user') - ->viteTheme('resources/css/filament/company/nord.css') + ->viteTheme('resources/css/filament/company/base.css') ->login() ->passwordReset() ->emailVerification() diff --git a/Modules/Core/Tests/Feature/PanelThemeSettingTest.php b/Modules/Core/Tests/Feature/PanelThemeSettingTest.php new file mode 100644 index 000000000..0d6f87c52 --- /dev/null +++ b/Modules/Core/Tests/Feature/PanelThemeSettingTest.php @@ -0,0 +1,206 @@ +assertFileExists( + base_path($theme->viteEntrypoint()), + $theme->value . ' has no stylesheet', + ); + } + } + + #[Test] + #[Group('theme')] + public function every_case_is_a_built_vite_entrypoint(): void + { + /* Arrange */ + $config = file_get_contents(base_path('vite.config.js')); + + /* Assert: an entrypoint missing from vite.config.js is not in the + manifest, so selecting it would 500 the panel. */ + foreach (PanelTheme::cases() as $theme) { + $this->assertStringContainsString( + "'" . $theme->viteEntrypoint() . "'", + $config, + $theme->value . ' is not a Vite entrypoint', + ); + } + } + + #[Test] + #[Group('theme')] + public function from_value_falls_back_to_the_default_for_unusable_values(): void + { + /* Act & Assert */ + $this->assertSame(PanelTheme::default(), PanelTheme::fromValue(null)); + $this->assertSame(PanelTheme::default(), PanelTheme::fromValue('')); + $this->assertSame(PanelTheme::default(), PanelTheme::fromValue('../../etc/passwd')); + $this->assertSame(PanelTheme::default(), PanelTheme::fromValue('a-theme-that-was-deleted')); + $this->assertSame(PanelTheme::NORD, PanelTheme::fromValue('nord')); + } + # endregion + + # region settings form + #[Test] + #[Group('theme')] + public function it_persists_the_chosen_theme_for_the_current_company_only(): void + { + /* Arrange */ + $other = Company::factory()->create(); + + /* Act */ + Livewire::actingAs($this->user) + ->test(CompanySettings::class) + ->set('data.' . Setting::KEY_PANEL_THEME, PanelTheme::NORD->value) + ->call('save') + ->assertHasNoErrors(); + + /* Assert */ + $this->assertSame('nord', Setting::getForCompany($this->company->id, Setting::KEY_PANEL_THEME)); + $this->assertNull(Setting::getForCompany($other->id, Setting::KEY_PANEL_THEME, null, true)); + } + + #[Test] + #[Group('theme')] + public function it_prefills_the_default_theme_when_the_company_has_never_chosen_one(): void + { + /* Act */ + $data = Livewire::actingAs($this->user) + ->test(CompanySettings::class) + ->get('data'); + + /* Assert */ + $this->assertSame(PanelTheme::default()->value, $data[Setting::KEY_PANEL_THEME] ?? null); + } + + #[Test] + #[Group('theme')] + public function it_prefills_the_stored_theme(): void + { + /* Arrange */ + Setting::saveForCompany($this->company->id, Setting::KEY_PANEL_THEME, PanelTheme::REDDIT->value); + + /* Act */ + $data = Livewire::actingAs($this->user) + ->test(CompanySettings::class) + ->get('data'); + + /* Assert */ + $this->assertSame('reddit', $data[Setting::KEY_PANEL_THEME] ?? null); + } + + #[Test] + #[Group('theme')] + public function changing_the_theme_reloads_the_page_so_the_stylesheet_swaps(): void + { + /* Act & Assert: the lives in the document head, which a + Livewire round-trip cannot rewrite. */ + Livewire::actingAs($this->user) + ->test(CompanySettings::class) + ->set('data.' . Setting::KEY_PANEL_THEME, PanelTheme::ORANGE->value) + ->call('save') + ->assertRedirect(CompanySettings::getUrl()); + } + + #[Test] + #[Group('theme')] + public function saving_without_touching_the_theme_does_not_reload(): void + { + /* Act & Assert */ + Livewire::actingAs($this->user) + ->test(CompanySettings::class) + ->set('data.' . Setting::KEY_COMPANY_NAME, 'Acme Corp') + ->call('save') + ->assertNoRedirect() + ->assertDispatched('saved'); + } + # endregion + + # region middleware + #[Test] + #[Group('theme')] + public function the_middleware_points_the_panel_at_the_companys_theme(): void + { + /* Arrange */ + $this->actingAs($this->user); + Setting::saveForCompany($this->company->id, Setting::KEY_PANEL_THEME, PanelTheme::NORD->value); + Filament::setCurrentPanel('company'); + + /* Act */ + (new ApplyCompanyTheme())->handle(Request::create('/'), fn (Request $request) => response('')); + + /* Assert */ + $this->assertSame( + PanelTheme::NORD->viteEntrypoint(), + Filament::getCurrentPanel()->getViteTheme(), + ); + } + + #[Test] + #[Group('theme')] + public function a_real_panel_request_runs_the_middleware(): void + { + /* Arrange: the unit tests above invoke the middleware directly, which + proves nothing about whether it is wired into the routing stack. */ + Setting::saveForCompany($this->company->id, Setting::KEY_PANEL_THEME, PanelTheme::NORD->value); + + /* Act */ + $response = $this->actingAs($this->user)->get( + route('filament.company.pages.dashboard', ['tenant' => 'IVPLV2']), + ); + + /* Assert: the panel is a container singleton, so the instance the + request mutated is the one still registered afterwards. */ + $response->assertSuccessful(); + $this->assertSame( + PanelTheme::NORD->viteEntrypoint(), + Filament::getPanel('company')->getViteTheme(), + ); + } + + #[Test] + #[Group('theme')] + public function the_middleware_falls_back_to_the_default_theme(): void + { + /* Arrange: a value no longer backed by a case -- e.g. a theme removed + after a company had selected it. */ + $this->actingAs($this->user); + Setting::saveForCompany($this->company->id, Setting::KEY_PANEL_THEME, 'retired-theme'); + Filament::setCurrentPanel('company'); + + /* Act */ + (new ApplyCompanyTheme())->handle(Request::create('/'), fn (Request $request) => response('')); + + /* Assert */ + $this->assertSame( + PanelTheme::default()->viteEntrypoint(), + Filament::getCurrentPanel()->getViteTheme(), + ); + } + # endregion +} diff --git a/Modules/Invoices/Filament/Company/Resources/Invoices/Tables/InvoicesTable.php b/Modules/Invoices/Filament/Company/Resources/Invoices/Tables/InvoicesTable.php index 2d065714f..02d2a2c6b 100644 --- a/Modules/Invoices/Filament/Company/Resources/Invoices/Tables/InvoicesTable.php +++ b/Modules/Invoices/Filament/Company/Resources/Invoices/Tables/InvoicesTable.php @@ -24,6 +24,7 @@ use Modules\Invoices\Enums\InvoiceStatus; use Modules\Invoices\Filament\Company\Actions\EmailInvoiceAction; use Modules\Invoices\Filament\Company\Actions\SendReminderAction; +use Modules\Invoices\Filament\Company\Resources\Invoices\InvoiceResource; use Modules\Invoices\Models\Invoice; use Modules\Invoices\Services\InvoiceCopyService; use Modules\Invoices\Services\InvoiceService; @@ -35,6 +36,9 @@ class InvoicesTable public static function configure(Table $table): Table { return $table + ->recordUrl(fn (Invoice $record): ?string => auth()->user()?->can(Permission::EDIT_INVOICES->value) + ? InvoiceResource::getUrl('edit', ['record' => $record]) + : null) ->columns([ TextColumn::make('invoice_status') ->badge() @@ -100,6 +104,7 @@ public static function configure(Table $table): Table ActionGroup::make([ EditAction::make() ->visible(fn () => auth()->user()?->can(Permission::EDIT_INVOICES->value)) + ->url(fn (Invoice $record): string => InvoiceResource::getUrl('edit', ['record' => $record])) ->mutateDataUsing(function (array $data, Invoice $record) { $data['invoiceItems'] = $record->invoiceItems()->get()->map(function ($item) { $product = $item->product; diff --git a/Modules/Invoices/Filament/Company/Widgets/RecentInvoicesWidget.php b/Modules/Invoices/Filament/Company/Widgets/RecentInvoicesWidget.php index aee005d9b..4f896c387 100644 --- a/Modules/Invoices/Filament/Company/Widgets/RecentInvoicesWidget.php +++ b/Modules/Invoices/Filament/Company/Widgets/RecentInvoicesWidget.php @@ -34,11 +34,8 @@ public function getTableHeaderActions(): array public function table(Table $table): Table { - // InvoiceResource only registers an 'index' page — editing happens - // via a modal action on that page's table, not a dedicated edit/view - // page — so this is the most specific URL a row can link to. return parent::table($table) - ->recordUrl(fn (Invoice $record): string => InvoiceResource::getUrl('index')); + ->recordUrl(fn (Invoice $record): string => InvoiceResource::getUrl('edit', ['record' => $record])); } protected function getTableQuery(): Builder|Relation|null diff --git a/Modules/Quotes/Filament/Company/Resources/Quotes/Tables/QuotesTable.php b/Modules/Quotes/Filament/Company/Resources/Quotes/Tables/QuotesTable.php index 269051159..dea76382e 100644 --- a/Modules/Quotes/Filament/Company/Resources/Quotes/Tables/QuotesTable.php +++ b/Modules/Quotes/Filament/Company/Resources/Quotes/Tables/QuotesTable.php @@ -16,6 +16,7 @@ use Modules\Core\Support\DateHelpers; use Modules\Quotes\Enums\QuoteStatus; use Modules\Quotes\Filament\Company\Actions\EmailQuoteAction; +use Modules\Quotes\Filament\Company\Resources\Quotes\QuoteResource; use Modules\Quotes\Models\Quote; use Modules\Quotes\Services\QuoteService; @@ -24,6 +25,9 @@ class QuotesTable public static function configure(Table $table): Table { return $table + ->recordUrl(fn (Quote $record): ?string => auth()->user()?->can(Permission::EDIT_QUOTES->value) + ? QuoteResource::getUrl('edit', ['record' => $record]) + : null) ->columns([ TextColumn::make('quote_status') ->label(trans('ip.quote_status')) @@ -68,6 +72,7 @@ public static function configure(Table $table): Table ActionGroup::make([ EditAction::make('edit') ->visible(fn () => auth()->user()?->can(Permission::EDIT_QUOTES->value)) + ->url(fn (Quote $record): string => QuoteResource::getUrl('edit', ['record' => $record])) ->action(function (Quote $record, array $data) { app(QuoteService::class)->updateQuote($record, $data); }) diff --git a/Modules/Quotes/Filament/Company/Widgets/RecentQuotesWidget.php b/Modules/Quotes/Filament/Company/Widgets/RecentQuotesWidget.php index 7b9f197db..58bc5a0e4 100644 --- a/Modules/Quotes/Filament/Company/Widgets/RecentQuotesWidget.php +++ b/Modules/Quotes/Filament/Company/Widgets/RecentQuotesWidget.php @@ -34,11 +34,8 @@ public function getTableHeaderActions(): array public function table(Table $table): Table { - // QuoteResource only registers an 'index' page — editing happens via - // a modal action on that page's table, not a dedicated edit/view - // page — so this is the most specific URL a row can link to. return parent::table($table) - ->recordUrl(fn (Quote $record): string => QuoteResource::getUrl('index')); + ->recordUrl(fn (Quote $record): string => QuoteResource::getUrl('edit', ['record' => $record])); } protected function getTableQuery(): Builder|Relation|null diff --git a/config/database.php b/config/database.php index aff8babc8..313ebfc14 100644 --- a/config/database.php +++ b/config/database.php @@ -56,7 +56,7 @@ 'strict' => true, 'engine' => null, 'options' => extension_loaded('pdo_mysql') ? array_filter([ - PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), + (defined('Pdo\Mysql::ATTR_SSL_CA') ? \Pdo\Mysql::ATTR_SSL_CA : (defined('PDO::MYSQL_ATTR_SSL_CA') ? PDO::MYSQL_ATTR_SSL_CA : null)) => env('MYSQL_ATTR_SSL_CA'), ]) : [], ], @@ -93,7 +93,7 @@ 'strict' => true, 'engine' => null, 'options' => extension_loaded('pdo_mysql') ? array_filter([ - PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), + (defined('Pdo\Mysql::ATTR_SSL_CA') ? \Pdo\Mysql::ATTR_SSL_CA : (defined('PDO::MYSQL_ATTR_SSL_CA') ? PDO::MYSQL_ATTR_SSL_CA : null)) => env('MYSQL_ATTR_SSL_CA'), ]) : [], ], diff --git a/phpunit.xml b/phpunit.xml index ef15fcc6e..859771c62 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -47,7 +47,8 @@ - + + diff --git a/public/index.php b/public/index.php index 86bfe7898..189b8e36d 100644 --- a/public/index.php +++ b/public/index.php @@ -1,4 +1,5 @@ it renders in . + */ +:root:root { + /* + * Neutrals -- a cool slate that sits under the brand blue. + * --gray-50 matches Niels' clean canvas neutral (#f8f9f9). + * The dark end is lifted off black (#0f1720, not #000) for clean layered surfaces. + */ + --gray-50: #f8f9f9; + --gray-100: #eef2f7; + --gray-200: #e0e6ef; + --gray-300: #c7d1de; + --gray-400: #94a2b6; + --gray-500: #6b7c94; + --gray-600: #4f6178; + --gray-700: #3a4a5e; + --gray-800: #26323f; + --gray-900: #1b2532; + --gray-950: #0f1720; + + /* + * Brand -- InvoicePlane modern blue palette. + * Primary 500: #0078d7 (Vibrant Brand Blue) + * Primary 700: #005a9e (Deep Blue) + * Primary 100: #deecf9 (Soft Ice Blue tint) + */ + --primary-50: #f2f7fd; + --primary-100: #deecf9; + --primary-200: #c1dff6; + --primary-300: #8fc0ee; + --primary-400: #2684d1; + --primary-500: #0078d7; + --primary-600: #006bc1; + --primary-700: #005a9e; + --primary-800: #004578; + --primary-900: #00335a; + --primary-950: #001f36; + + /* Danger -- red. */ + --danger-50: #fef2f2; + --danger-100: #fee2e2; + --danger-200: #fecaca; + --danger-300: #fca5a5; + --danger-400: #f87171; + --danger-500: #ef4444; + --danger-600: #dc2626; + --danger-700: #b91c1c; + --danger-800: #991b1b; + --danger-900: #7f1d1d; + --danger-950: #450a0a; + + /* Success -- emerald. */ + --success-50: #ecfdf5; + --success-100: #d1fae5; + --success-200: #a7f3d0; + --success-300: #6ee7b7; + --success-400: #34d399; + --success-500: #10b981; + --success-600: #059669; + --success-700: #047857; + --success-800: #065f46; + --success-900: #064e3b; + --success-950: #022c22; + + /* Warning -- amber. */ + --warning-50: #fffbeb; + --warning-100: #fef3c7; + --warning-200: #fde68a; + --warning-300: #fcd34d; + --warning-400: #fbbf24; + --warning-500: #f59e0b; + --warning-600: #d97706; + --warning-700: #b45309; + --warning-800: #92400e; + --warning-900: #78350f; + --warning-950: #451a03; + + /* Info -- sky, kept distinct from the brand blue. */ + --info-50: #f0f9ff; + --info-100: #e0f2fe; + --info-200: #bae6fd; + --info-300: #7dd3fc; + --info-400: #38bdf8; + --info-500: #0ea5e9; + --info-600: #0284c7; + --info-700: #0369a1; + --info-800: #075985; + --info-900: #0c4a6e; + --info-950: #082f49; +} + +/* + * Modern UI accents + */ + +/* + * Active navigation item: a clean brand-tinted pill makes the current page + * findable at a glance. + */ +.fi-sidebar-item.fi-active > .fi-sidebar-item-btn { + @apply bg-primary-100/80! text-primary-700! dark:bg-primary-400/10! dark:text-primary-300!; +} + +/* + * Table header: a subtle neutral tint separates headers cleanly from table rows. + */ +.fi-ta-table > thead { + @apply bg-gray-50 dark:bg-white/5; + + & .fi-ta-header-cell-label, + & .fi-ta-header-cell-sort-btn { + @apply text-gray-600 dark:text-gray-300 font-semibold text-xs tracking-wider uppercase; + } +} diff --git a/resources/css/filament/company/invoiceplane-blue.css b/resources/css/filament/company/invoiceplane-blue.css index cbaaccd09..3d9cd42e2 100644 --- a/resources/css/filament/company/invoiceplane-blue.css +++ b/resources/css/filament/company/invoiceplane-blue.css @@ -1,217 +1,45 @@ @import 'tailwindcss'; @import '../../../../vendor/filament/filament/resources/css/theme.css'; +@import '../../../../vendor/awcodes/mason/resources/css/plugin.css'; @source '../../../../Modules/**/resources/views/**/*'; @source '../../../../Modules/**/*.php'; +@source '../../../../vendor/awcodes/mason/resources/**/*.blade.php'; @source '../../../../resources/views/filament/tenant/**/*'; /* -.dark .fi-body { - @apply bg-slate-950; -} -*/ - -.fi-bg-color-600 { - @apply bg-blue-700; - @apply hover:bg-blue-500; -} - -.fi-topbar { - /* Background color */ - @apply bg-blue-500; - - /* Collapse icon */ - - .fi-topbar-close-collapse-sidebar-btn { - @apply text-white; - @apply hover:text-blue-600; - } - - .fi-topbar-open-collapse-sidebar-btn { - @apply text-white; - @apply hover:text-blue-600; - } - - .fi-logo { - @apply text-white; - } -} - -.fi-sidebar { - /* Background color */ - @apply bg-blue-500; - /* Text color */ - @apply text-white; - - .fi-icon { - @apply text-white; - } - - .fi-icon-btn { - @apply text-white; - } - - /* Sidebar items */ - - .fi-sidebar-group-label { - @apply text-white; - } - - .fi-sidebar-item-label { - @apply text-white; - } - - .fi-sidebar-item.fi-active { - @apply text-white; - @apply !bg-blue-700; - @apply rounded-lg; - } - - .fi-sidebar-header { - @apply bg-blue-500; - } -} - -/* - * Checkbox - */ -.fi-checkbox-input { - @apply ring-blue-700; - @apply focus:ring-blue-500; - @apply hover:ring-blue-500 -} - -.fi-header-heading { - @apply text-blue-700; -} - -.fi-checkbox-input:checked { - @apply bg-blue-700; - @apply hover:bg-blue-500; -} - -/* - * Open/collapse - */ -.fi-section-collapse-btn { - @apply text-blue-700; - @apply hover:text-blue-500; -} - -/* - * Modals - */ -.fi-modal-heading { - @apply text-blue-700; -} - -.fi-section-header-heading { - @apply text-blue-700; -} - -.fi-modal-close-btn { - @apply text-blue-700; - @apply hover:text-blue-500; -} - -.fi-fo-field-label-content { - @apply text-blue-700; -} - -.fi-modal-content .fi-input-wrp-content-ctn { - @apply text-blue-700; -} - -.fi-modal-content .fi-input-wrp-content-ctn { - @apply border border-blue-700 rounded-lg; -} - -.fi-modal-content .fi-in-entry-label { - @apply text-blue-700; -} - -/* - * Pagination - */ -.fi-pagination-records-per-page-select-ctn { - @apply border border-blue-700 rounded-lg; -} - -.fi-pagination-previous-btn { - @apply text-blue-700; - @apply hover:text-blue-500; -} - -.fi-pagination-next-btn { - @apply text-blue-700; - @apply hover:text-blue-500; -} - -/* - * Input - */ -.fi-input-wrp-label { - @apply text-blue-700; -} - -/* - * Breadcrums - */ -.fi-breadcrumbs { - @apply text-blue-700; - @apply hover:text-blue-500; - -} - -.fi-breadcrumbs-item-label { - @apply text-blue-700; - @apply hover:text-blue-500; -} - -.fi-breadcrumbs-item-separator { - @apply text-blue-700; - @apply hover:text-blue-500; -} - -/* - * Tables - */ -.fi-ta-header-cell-sort-btn { - @apply text-blue-700; -} - -.fi-ta-header-cell-sort-btn .fi-icon { - @apply text-blue-700; -} - -.fi-ta-row { - @apply border-b border-blue-700 rounded-lg; -} - -.fi-dropdown-trigger { - @apply text-blue-700; -} - -.fi-ta-header-toolbar .fi-icon { - @apply text-blue-700; - @apply hover:text-blue-500; -} - -/* - * Buttons - */ -.fi-ac-icon-btn-action { - @apply text-blue-700; - @apply hover:text-blue-500; -} - -.fi-ta-search-field .fi-icon { - @apply text-blue-700; -} - -/* - * User menu - */ -.fi-user-menu .fi-icon { - @apply text-blue-700; + * InvoicePlane Blue -- stock Filament, rebranded. + * + * Where base.css replaces the neutral ramp with a custom cool slate, this + * theme deliberately leaves Filament's own neutrals (Zinc) and its semantic + * ramps alone. It swaps a single thing -- `primary` -- to InvoicePlane blue, + * and then leans into that blue in the handful of places where Filament + * itself defaults to gray. + * + * That means every chrome decision stays Filament's: white topbar, gray-50 + * page, white sections, stock table and input styling. + */ +:root:root { + /* Brand -- InvoicePlane blue, driving buttons, links and active states. */ + --primary-50: #f2f7fd; + --primary-100: #e3effb; + --primary-200: #c1dff6; + --primary-300: #8fc0ee; + --primary-400: #429ae1; + --primary-500: #2684d1; + --primary-600: #1868b1; + --primary-700: #145390; + --primary-800: #154777; + --primary-900: #173c63; + --primary-950: #0f2742; +} + +/* Active navigation item: brand-tinted pill instead of gray-100. */ +.fi-sidebar-item.fi-active > .fi-sidebar-item-btn { + @apply bg-primary-50! dark:bg-primary-400/10!; +} + +/* Active topbar navigation item, when configured. */ +.fi-topbar-item.fi-active > .fi-topbar-item-btn { + @apply text-primary-600! dark:text-primary-400!; } diff --git a/resources/lang/en/ip.php b/resources/lang/en/ip.php index ba8d79e24..cee1f52cb 100644 --- a/resources/lang/en/ip.php +++ b/resources/lang/en/ip.php @@ -1362,5 +1362,14 @@ 'draw_signature' => 'Draw', 'type_signature' => 'Type', 'typed_signature_label' => 'Type your signature', + 'panel_appearance' => 'Panel Appearance', + 'panel_theme' => 'Theme', + 'panel_theme_help' => 'Applies to everyone in this company. Saving reloads the page so the new stylesheet takes effect.', + 'panel_theme_base_description' => 'Balanced slate neutrals with InvoicePlane blue accents. The default.', + 'panel_theme_invoiceplane_description' => 'Solid coloured sidebar and topbar in the InvoicePlane palette.', + 'panel_theme_invoiceplane_blue_description' => 'Stock Filament chrome, rebranded with InvoicePlane blue accents.', + 'panel_theme_nord_description' => 'Cool Nord palette with a dark sidebar and topbar.', + 'panel_theme_orange_description' => 'Warm orange accent theme.', + 'panel_theme_reddit_description' => 'Vibrant Reddit orange-red palette.', #endregion ]; diff --git a/vite.config.js b/vite.config.js index eb095ee12..3c50d3e84 100644 --- a/vite.config.js +++ b/vite.config.js @@ -10,6 +10,7 @@ export default defineConfig({ 'resources/css/guest.css', 'resources/js/app.js', 'resources/js/signature-pad.js', + 'resources/css/filament/company/base.css', 'resources/css/filament/company/invoiceplane.css', 'resources/css/filament/company/invoiceplane-blue.css', 'resources/css/filament/company/nord.css', From 8250e129170a34793f42d85bf4deeebdd5e17ea9 Mon Sep 17 00:00:00 2001 From: ahmedraza Date: Sat, 12 Sep 2026 00:32:37 +0530 Subject: [PATCH 2/3] done --- .env.testing | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.env.testing b/.env.testing index 7ecbda92d..9baaca9bf 100644 --- a/.env.testing +++ b/.env.testing @@ -27,7 +27,7 @@ DB_PORT=3306 DB_SOCKET=/var/lib/mysql/mysql.sock DB_DATABASE=invoiceplane_test DB_USERNAME=root -DB_PASSWORD=Mysql@789789 +DB_PASSWORD=password SESSION_DRIVER=array SESSION_LIFETIME=120 From 5e868ff75412b9b8bf5113066409170756cadbbc Mon Sep 17 00:00:00 2001 From: ahmedraza Date: Sat, 12 Sep 2026 01:06:48 +0530 Subject: [PATCH 3/3] feat: add CompanyStatsOverviewWidget and integrate into dashboard and company panel refactor: update invoice and quote forms for improved UI and functionality fix: enhance number formatting and currency display across invoices and quotes style: improve CSS for modern minimal UI accents and component overrides test: implement tests for CompanyStatsOverviewWidget functionality --- .../Core/Filament/Company/Pages/Dashboard.php | 9 +- .../Widgets/CompanyStatsOverviewWidget.php | 85 +++++++++++++++++ .../Core/Providers/CompanyPanelProvider.php | 1 + Modules/Core/Support/NumberFormatter.php | 26 +++++- .../CompanyStatsOverviewWidgetTest.php | 92 +++++++++++++++++++ .../Invoices/Schemas/InvoiceForm.php | 9 +- .../Invoices/Tables/InvoicesTable.php | 4 + .../Company/Widgets/RecentInvoicesWidget.php | 7 +- .../Resources/Quotes/Schemas/QuoteForm.php | 4 +- .../Resources/Quotes/Tables/QuotesTable.php | 9 +- .../Company/Widgets/RecentQuotesWidget.php | 7 +- OG-invoice-plane | 1 + phpunit.xml | 2 +- resources/css/filament/company/base.css | 59 ++++++++++-- resources/lang/en/ip.php | 6 ++ 15 files changed, 298 insertions(+), 23 deletions(-) create mode 100644 Modules/Core/Filament/Company/Widgets/CompanyStatsOverviewWidget.php create mode 100644 Modules/Core/Tests/Feature/CompanyStatsOverviewWidgetTest.php create mode 160000 OG-invoice-plane diff --git a/Modules/Core/Filament/Company/Pages/Dashboard.php b/Modules/Core/Filament/Company/Pages/Dashboard.php index abe918445..a5a2989c3 100644 --- a/Modules/Core/Filament/Company/Pages/Dashboard.php +++ b/Modules/Core/Filament/Company/Pages/Dashboard.php @@ -4,6 +4,7 @@ use Filament\Pages\Page; use Filament\Panel; +use Modules\Core\Filament\Company\Widgets\CompanyStatsOverviewWidget; use Modules\Invoices\Filament\Company\Widgets\RecentInvoicesWidget; use Modules\Quotes\Filament\Company\Widgets\RecentQuotesWidget; @@ -14,11 +15,17 @@ public static function getSlug(?Panel $panel = null): string return 'dashboard'; } + public function getHeaderWidgetsColumns(): int|array + { + return 2; + } + public function getHeaderWidgets(): array { return [ - RecentQuotesWidget::class, + CompanyStatsOverviewWidget::class, RecentInvoicesWidget::class, + RecentQuotesWidget::class, //RecentProjectsWidget::class, //RecentTasksWidget::class, //RecentExpensesWidget::class, diff --git a/Modules/Core/Filament/Company/Widgets/CompanyStatsOverviewWidget.php b/Modules/Core/Filament/Company/Widgets/CompanyStatsOverviewWidget.php new file mode 100644 index 000000000..d7d354bab --- /dev/null +++ b/Modules/Core/Filament/Company/Widgets/CompanyStatsOverviewWidget.php @@ -0,0 +1,85 @@ +where('invoice_status', InvoiceStatus::PAID); + $paidCount = $paidInvoicesQuery->count(); + $paidTotal = (float) $paidInvoicesQuery->sum('invoice_total'); + + // 2. Pending / awaiting payment + $pendingQuery = Invoice::query()->whereIn('invoice_status', [ + InvoiceStatus::SENT, + InvoiceStatus::VIEWED, + InvoiceStatus::PARTIALLY_PAID, + ]); + $pendingCount = $pendingQuery->count(); + $pendingTotal = (float) $pendingQuery->sum('invoice_total'); + + // 3. Overdue invoices + $overdueQuery = Invoice::query()->where(function ($query) { + $query->where('invoice_status', InvoiceStatus::OVERDUE) + ->orWhere(function ($sub) { + $sub->whereNotIn('invoice_status', [InvoiceStatus::PAID, InvoiceStatus::DRAFT]) + ->whereNotNull('invoice_due_at') + ->where('invoice_due_at', '<', now()->startOfDay()); + }); + }); + $overdueCount = $overdueQuery->count(); + $overdueTotal = (float) $overdueQuery->sum('invoice_total'); + + // 4. Quotes pipeline + $quotesQuery = Quote::query()->whereIn('quote_status', [ + QuoteStatus::DRAFT, + QuoteStatus::SENT, + QuoteStatus::VIEWED, + ]); + $quotesCount = $quotesQuery->count(); + $quotesTotal = (float) $quotesQuery->sum('quote_total'); + + $invoicesUrl = InvoiceResource::getUrl('index'); + $quotesUrl = QuoteResource::getUrl('index'); + + return [ + Stat::make(trans('ip.invoice_status_paid'), NumberFormatter::formatCurrency($paidTotal)) + ->description(trans('ip.paid_invoices_count', ['count' => $paidCount])) + ->descriptionIcon('heroicon-m-check-circle') + ->color('success') + ->url($invoicesUrl), + + Stat::make(trans('ip.awaiting_payment'), NumberFormatter::formatCurrency($pendingTotal)) + ->description(trans('ip.pending_invoices_count', ['count' => $pendingCount])) + ->descriptionIcon('heroicon-m-clock') + ->color('warning') + ->url($invoicesUrl), + + Stat::make(trans('ip.invoice_status_overdue'), NumberFormatter::formatCurrency($overdueTotal)) + ->description(trans('ip.overdue_invoices_count', ['count' => $overdueCount])) + ->descriptionIcon('heroicon-m-exclamation-triangle') + ->color($overdueCount > 0 ? 'danger' : 'gray') + ->url($invoicesUrl), + + Stat::make(trans('ip.quotes_pipeline'), NumberFormatter::formatCurrency($quotesTotal)) + ->description(trans('ip.active_quotes_count', ['count' => $quotesCount])) + ->descriptionIcon('heroicon-m-document-text') + ->color('primary') + ->url($quotesUrl), + ]; + } +} diff --git a/Modules/Core/Providers/CompanyPanelProvider.php b/Modules/Core/Providers/CompanyPanelProvider.php index 4f9c98038..9dca16ffc 100644 --- a/Modules/Core/Providers/CompanyPanelProvider.php +++ b/Modules/Core/Providers/CompanyPanelProvider.php @@ -192,6 +192,7 @@ public function panel(Panel $panel): Panel CompanySettings::class, ]) ->widgets([ + CompanyStatsOverviewWidget::class, RecentQuotesWidget::class, RecentInvoicesWidget::class, //RecentProjectsWidget::class, diff --git a/Modules/Core/Support/NumberFormatter.php b/Modules/Core/Support/NumberFormatter.php index b06453d2e..1127d5219 100644 --- a/Modules/Core/Support/NumberFormatter.php +++ b/Modules/Core/Support/NumberFormatter.php @@ -7,9 +7,25 @@ class NumberFormatter public static function format($number, $currency = null, $decimalPlaces = null): float|string { $currency = $currency ?: config('ip.currency'); - $decimalPlaces ??= config('ip.amountDecimals'); + $decimalPlaces ??= config('ip.amountDecimals') ?? 2; + $decimal = is_object($currency) ? ($currency->decimal ?? '.') : '.'; + $thousands = is_object($currency) ? ($currency->thousands ?? ',') : ','; - return number_format($number, $decimalPlaces, $currency->decimal, $currency->thousands); + return number_format((float) $number, (int) $decimalPlaces, $decimal, $thousands); + } + + public static function formatCurrency($number, ?string $currencyCode = null): string + { + $formatted = self::format($number); + $code = $currencyCode ?: (string) (config('ip.currency_code') ?: 'USD'); + + return match ($code) { + 'USD' => '$' . $formatted, + 'EUR' => '€' . $formatted, + 'GBP' => '£' . $formatted, + 'JPY' => '¥' . $formatted, + default => $code . ' ' . $formatted, + }; } public static function formatTrimmed(float $number, int $decimalPlaces = 4): string @@ -21,9 +37,11 @@ public static function formatTrimmed(float $number, int $decimalPlaces = 4): str public static function unformat($number, $currency = null): float|string { - $currency = $currency ?: config('ip.currency'); + $currency = $currency ?: config('ip.currency'); + $decimal = is_object($currency) ? ($currency->decimal ?? '.') : '.'; + $thousands = is_object($currency) ? ($currency->thousands ?? ',') : ','; - $number = str_replace([$currency->decimal, $currency->thousands, 'D'], ['D', '', '.'], $number); + $number = str_replace([$decimal, $thousands, 'D'], ['D', '', '.'], (string) $number); return $number; } diff --git a/Modules/Core/Tests/Feature/CompanyStatsOverviewWidgetTest.php b/Modules/Core/Tests/Feature/CompanyStatsOverviewWidgetTest.php new file mode 100644 index 000000000..d98151c20 --- /dev/null +++ b/Modules/Core/Tests/Feature/CompanyStatsOverviewWidgetTest.php @@ -0,0 +1,92 @@ +user) + ->test(CompanyStatsOverviewWidget::class); + + $component->assertSuccessful(); + $component->assertSee('$0.00'); + } + + #[Test] + public function it_calculates_and_displays_paid_and_pending_stats(): void + { + $customer = Relation::factory()->for($this->company)->customer()->create(); + + // 1. Paid invoice ($500) + Invoice::factory()->for($this->company)->create([ + 'customer_id' => $customer->id, + 'user_id' => $this->user->id, + 'invoice_status' => InvoiceStatus::PAID, + 'invoice_total' => 500.00, + ]); + + // 2. Pending invoice ($250) + Invoice::factory()->for($this->company)->create([ + 'customer_id' => $customer->id, + 'user_id' => $this->user->id, + 'invoice_status' => InvoiceStatus::SENT, + 'invoice_total' => 250.00, + 'invoice_due_at' => now()->addDays(7), + ]); + + // 3. Active quote ($1200) + Quote::factory()->for($this->company)->create([ + 'prospect_id' => $customer->id, + 'user_id' => $this->user->id, + 'quote_status' => QuoteStatus::SENT, + 'quote_total' => 1200.00, + ]); + + $component = Livewire::actingAs($this->user) + ->test(CompanyStatsOverviewWidget::class); + + $component->assertSuccessful(); + $component->assertSee('$500.00'); + $component->assertSee('$250.00'); + $component->assertSee('$1,200.00'); + } + + #[Test] + public function it_scopes_stats_strictly_to_the_current_company(): void + { + $customer = Relation::factory()->for($this->company)->customer()->create(); + $otherCompany = Company::factory()->create(['search_code' => 'othercorp']); + $otherCustomer = Relation::factory()->for($otherCompany)->customer()->create(); + + // Invoice for other company ($9999.00) + Invoice::factory()->for($otherCompany)->create([ + 'customer_id' => $otherCustomer->id, + 'user_id' => $this->user->id, + 'invoice_status' => InvoiceStatus::PAID, + 'invoice_total' => 9999.00, + ]); + + $component = Livewire::actingAs($this->user) + ->test(CompanyStatsOverviewWidget::class); + + $component->assertSuccessful(); + $component->assertDontSee('$9,999.00'); + } +} diff --git a/Modules/Invoices/Filament/Company/Resources/Invoices/Schemas/InvoiceForm.php b/Modules/Invoices/Filament/Company/Resources/Invoices/Schemas/InvoiceForm.php index fdefbdcf9..6def46f2f 100644 --- a/Modules/Invoices/Filament/Company/Resources/Invoices/Schemas/InvoiceForm.php +++ b/Modules/Invoices/Filament/Company/Resources/Invoices/Schemas/InvoiceForm.php @@ -179,7 +179,7 @@ public static function configure(Schema $schema): Schema // // Invoice Items Section::make(trans('ip.invoice_items')) - ->collapsed() + ->collapsible() ->schema([ Repeater::make('invoiceItems') ->defaultItems(0) @@ -200,25 +200,30 @@ public static function configure(Schema $schema): Schema ->dehydrated(), TextEntry::make('product_name') + ->label(trans('ip.product_name')) ->state(fn ($get) => Product::query()->find($get('product_id'))?->product_name) ->disabled(), TextInput::make('quantity') + ->label(trans('ip.quantity')) ->numeric() ->required() ->dehydrated(), TextInput::make('price') + ->label(trans('ip.price')) ->numeric() ->required() ->dehydrated(), TextInput::make('discount') + ->label(trans('ip.discount')) ->numeric() ->default(0) ->dehydrated(), TextInput::make('subtotal') + ->label(trans('ip.subtotal')) ->numeric() ->default(0) ->dehydrated() @@ -282,7 +287,7 @@ public static function configure(Schema $schema): Schema ]), ]), ]) - ->collapsed() + ->collapsible() ->columnSpanFull(), // Notes & Attachments diff --git a/Modules/Invoices/Filament/Company/Resources/Invoices/Tables/InvoicesTable.php b/Modules/Invoices/Filament/Company/Resources/Invoices/Tables/InvoicesTable.php index 02d2a2c6b..3d15d8ad1 100644 --- a/Modules/Invoices/Filament/Company/Resources/Invoices/Tables/InvoicesTable.php +++ b/Modules/Invoices/Filament/Company/Resources/Invoices/Tables/InvoicesTable.php @@ -21,6 +21,7 @@ use Modules\Core\Enums\Permission; use Modules\Core\Models\Numbering; use Modules\Core\Support\DateHelpers; +use Modules\Core\Support\NumberFormatter; use Modules\Invoices\Enums\InvoiceStatus; use Modules\Invoices\Filament\Company\Actions\EmailInvoiceAction; use Modules\Invoices\Filament\Company\Actions\SendReminderAction; @@ -87,6 +88,9 @@ public static function configure(Table $table): Table ->sortable() ->toggleable(), TextColumn::make('invoice_total') + ->label(trans('ip.total')) + ->formatStateUsing(fn ($state) => NumberFormatter::formatCurrency($state)) + ->alignEnd() ->searchable() ->sortable() ->toggleable(), diff --git a/Modules/Invoices/Filament/Company/Widgets/RecentInvoicesWidget.php b/Modules/Invoices/Filament/Company/Widgets/RecentInvoicesWidget.php index 4f896c387..26ca48768 100644 --- a/Modules/Invoices/Filament/Company/Widgets/RecentInvoicesWidget.php +++ b/Modules/Invoices/Filament/Company/Widgets/RecentInvoicesWidget.php @@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Relations\Relation; use Modules\Core\Support\DateHelpers; +use Modules\Core\Support\NumberFormatter; use Modules\Invoices\Filament\Company\Resources\Invoices\InvoiceResource; use Modules\Invoices\Models\Invoice; @@ -55,7 +56,7 @@ protected function getTableColumns(): array ->formatStateUsing(fn ($state) => $state?->label() ?? '-') ->color(fn ($state) => $state?->color() ?? 'secondary'), TextColumn::make('invoice_number')->label(trans('ip.invoice_number')), - TextColumn::make('customer.company_name')->limit(10)->label(trans('ip.customer_name')), + TextColumn::make('customer.company_name')->limit(15)->label(trans('ip.customer_name')), TextColumn::make('invoice_due_at') ->label(trans('ip.invoice_due_at')) ->color(fn ($state, $record) => $record?->due_intensity ?? 'secondary') @@ -70,6 +71,10 @@ protected function getTableColumns(): array return DateHelpers::formatDate($state); }), + TextColumn::make('invoice_total') + ->label(trans('ip.total')) + ->formatStateUsing(fn ($state) => NumberFormatter::formatCurrency($state)) + ->alignEnd(), ]; } } diff --git a/Modules/Quotes/Filament/Company/Resources/Quotes/Schemas/QuoteForm.php b/Modules/Quotes/Filament/Company/Resources/Quotes/Schemas/QuoteForm.php index d5eb2d7e2..a04ce7655 100644 --- a/Modules/Quotes/Filament/Company/Resources/Quotes/Schemas/QuoteForm.php +++ b/Modules/Quotes/Filament/Company/Resources/Quotes/Schemas/QuoteForm.php @@ -216,7 +216,7 @@ public static function configure(Schema $schema): Schema ->defaultItems(0) ->afterStateUpdated(function (callable $set, $get, $state) {}), ]) - ->collapsed() + ->collapsible() ->columnSpanFull(), Section::make(trans('ip.quote_totals')) @@ -256,7 +256,7 @@ public static function configure(Schema $schema): Schema ]), ]), ]) - ->collapsed() + ->collapsible() ->columns(2), Section::make(trans('ip.quote_notes')) diff --git a/Modules/Quotes/Filament/Company/Resources/Quotes/Tables/QuotesTable.php b/Modules/Quotes/Filament/Company/Resources/Quotes/Tables/QuotesTable.php index dea76382e..b472eb3f3 100644 --- a/Modules/Quotes/Filament/Company/Resources/Quotes/Tables/QuotesTable.php +++ b/Modules/Quotes/Filament/Company/Resources/Quotes/Tables/QuotesTable.php @@ -14,6 +14,7 @@ use Modules\Core\Enums\Permission; use Modules\Core\Helpers\EnumHelper; use Modules\Core\Support\DateHelpers; +use Modules\Core\Support\NumberFormatter; use Modules\Quotes\Enums\QuoteStatus; use Modules\Quotes\Filament\Company\Actions\EmailQuoteAction; use Modules\Quotes\Filament\Company\Resources\Quotes\QuoteResource; @@ -65,7 +66,13 @@ public static function configure(Table $table): Table ->searchable() ->sortable() ->toggleable(), - TextColumn::make('quote_total')->searchable()->sortable()->toggleable(), + TextColumn::make('quote_total') + ->label(trans('ip.total')) + ->formatStateUsing(fn ($state) => NumberFormatter::formatCurrency($state)) + ->alignEnd() + ->searchable() + ->sortable() + ->toggleable(), ]) ->filters([]) ->recordActions([ diff --git a/Modules/Quotes/Filament/Company/Widgets/RecentQuotesWidget.php b/Modules/Quotes/Filament/Company/Widgets/RecentQuotesWidget.php index 58bc5a0e4..50fa53419 100644 --- a/Modules/Quotes/Filament/Company/Widgets/RecentQuotesWidget.php +++ b/Modules/Quotes/Filament/Company/Widgets/RecentQuotesWidget.php @@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Relations\Relation; use Modules\Core\Support\DateHelpers; +use Modules\Core\Support\NumberFormatter; use Modules\Quotes\Filament\Company\Resources\Quotes\QuoteResource; use Modules\Quotes\Models\Quote; @@ -55,7 +56,7 @@ protected function getTableColumns(): array ->formatStateUsing(fn ($state) => $state?->label() ?? '-') ->color(fn ($state) => $state?->color() ?? 'secondary'), TextColumn::make('quote_number')->label(trans('ip.quote_number')), - TextColumn::make('prospect.company_name')->limit(10)->label(trans('ip.prospect_name')), + TextColumn::make('prospect.company_name')->limit(15)->label(trans('ip.prospect_name')), TextColumn::make('quote_expires_at') ->label(trans('ip.quote_expires_at')) ->color(fn ($state, $record) => $record?->expires_intensity ?? 'secondary') @@ -70,6 +71,10 @@ protected function getTableColumns(): array return DateHelpers::formatDate($state); }), + TextColumn::make('quote_total') + ->label(trans('ip.total')) + ->formatStateUsing(fn ($state) => NumberFormatter::formatCurrency($state)) + ->alignEnd(), ]; } } diff --git a/OG-invoice-plane b/OG-invoice-plane new file mode 160000 index 000000000..edc5f995d --- /dev/null +++ b/OG-invoice-plane @@ -0,0 +1 @@ +Subproject commit edc5f995d5afe94e47c0c6704a52c82753003ce4 diff --git a/phpunit.xml b/phpunit.xml index 859771c62..3dfbb6b15 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -47,7 +47,7 @@ - + diff --git a/resources/css/filament/company/base.css b/resources/css/filament/company/base.css index 535d5e17a..95daa069f 100644 --- a/resources/css/filament/company/base.css +++ b/resources/css/filament/company/base.css @@ -114,25 +114,64 @@ } /* - * Modern UI accents + * Modern Minimal UI Accents & Component Overrides */ -/* - * Active navigation item: a clean brand-tinted pill makes the current page - * findable at a glance. - */ +/* Active navigation item: clean brand-tinted pill */ .fi-sidebar-item.fi-active > .fi-sidebar-item-btn { - @apply bg-primary-100/80! text-primary-700! dark:bg-primary-400/10! dark:text-primary-300!; + @apply bg-primary-100/80! text-primary-700! font-medium dark:bg-primary-400/10! dark:text-primary-300!; +} + +/* Sections & Cards: modern minimal rounded corners with clean subtle border */ +.fi-section { + @apply rounded-xl border border-gray-200/80 shadow-xs bg-white dark:bg-gray-900 dark:border-gray-800 transition-all duration-150; +} + +.fi-section-header { + @apply px-6 py-4 border-b border-gray-100 dark:border-gray-800; +} + +/* Stats Overview Cards: minimal SaaS KPI styling */ +.fi-wi-stats-overview-stat { + @apply rounded-xl border border-gray-200/80 bg-white p-6 shadow-xs transition-all duration-200 hover:shadow-md hover:border-gray-300/80 dark:bg-gray-900 dark:border-gray-800 dark:hover:border-gray-700; +} + +.fi-wi-stats-overview-stat-label { + @apply text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400; +} + +.fi-wi-stats-overview-stat-value { + @apply text-2xl font-bold tracking-tight text-gray-900 dark:text-white mt-1; +} + +.fi-wi-stats-overview-stat-description { + @apply text-xs font-medium text-gray-500 dark:text-gray-400 mt-2 flex items-center gap-1.5; +} + +/* Table styling: clean minimal headers, subtle borders, comfortable row padding */ +.fi-ta-ctn { + @apply rounded-xl border border-gray-200/80 shadow-xs overflow-hidden dark:border-gray-800; } -/* - * Table header: a subtle neutral tint separates headers cleanly from table rows. - */ .fi-ta-table > thead { - @apply bg-gray-50 dark:bg-white/5; + @apply bg-gray-50/90 dark:bg-gray-800/60 border-b border-gray-200/80 dark:border-gray-800; & .fi-ta-header-cell-label, & .fi-ta-header-cell-sort-btn { @apply text-gray-600 dark:text-gray-300 font-semibold text-xs tracking-wider uppercase; } } + +.fi-ta-row { + @apply transition-colors duration-100 hover:bg-gray-50/60 dark:hover:bg-gray-800/40; +} + +/* Badges: sleek minimal pills */ +.fi-badge { + @apply rounded-full font-medium text-xs px-2.5 py-0.5 tracking-normal; +} + +/* Primary buttons: Niels' brand blue with smooth transition */ +.fi-btn-primary { + @apply shadow-xs hover:shadow-sm transition-all duration-150 active:scale-[0.99]; +} diff --git a/resources/lang/en/ip.php b/resources/lang/en/ip.php index cee1f52cb..d83d4a676 100644 --- a/resources/lang/en/ip.php +++ b/resources/lang/en/ip.php @@ -47,6 +47,12 @@ #endregion #region CORE + 'awaiting_payment' => 'Awaiting Payment', + 'paid_invoices_count' => ':count paid invoices', + 'pending_invoices_count' => ':count awaiting payment', + 'overdue_invoices_count' => ':count overdue', + 'quotes_pipeline' => 'Quotes Pipeline', + 'active_quotes_count' => ':count active in pipeline', 'user_not_in_company' => 'You do not have access to this company.', 'Q1' => 'Q1', 'Q2' => 'Q2',