diff --git a/.claude/skills/filament-panel-setup/SKILL.md b/.claude/skills/filament-panel-setup/SKILL.md
index 7b35cf62c..cd01c1f94 100644
--- a/.claude/skills/filament-panel-setup/SKILL.md
+++ b/.claude/skills/filament-panel-setup/SKILL.md
@@ -32,20 +32,6 @@ Add a `->discoverResources()` call per module in `CompanyPanelProvider`:
The `in` path is a filesystem path, `for` is the PHP namespace prefix. Both must
match the module's actual directory and namespace exactly.
-## viteTheme Guard
-
-`->viteTheme()` calls `app(Vite::class)($theme)` which reads `public/build/manifest.json`.
-In test environments there is no built manifest, so wrap it:
-
-```php
-->when(
- ! app()->runningUnitTests(),
- fn (Panel $panel) => $panel->viteTheme('resources/css/filament/company/nord.css')
-)
-```
-
-`app()->runningUnitTests()` returns `true` when `APP_ENV=testing` (set in `phpunit.xml`).
-
## Tenant Panel Required Config
```php
@@ -65,13 +51,8 @@ In test environments there is no built manifest, so wrap it:
## Colors and Font
-Both panels use:
-```php
-->colors(['primary' => Color::hex('#88c0d0')])
-```
-
-Company panel: `Poppins` via `GoogleFontProvider`
-Admin panel: `Albert Sans` via `GoogleFontProvider`
+All three panels use Filament's stock default color palette (no custom `->colors()` override,
+no custom `->viteTheme()`) and `Poppins` via `GoogleFontProvider`.
## SPA Mode
diff --git a/.github/THEMES.md b/.github/THEMES.md
deleted file mode 100644
index fd99a373e..000000000
--- a/.github/THEMES.md
+++ /dev/null
@@ -1,226 +0,0 @@
-# Filament Theme Documentation
-
-## Available Themes
-
-InvoicePlane v2 comes with multiple pre-built themes that can be applied to any Filament panel:
-
-### 1. InvoicePlane (Default)
-**File:** `invoiceplane.css`
-
-The default InvoicePlane theme uses the primary color palette defined in the panel configuration. It provides a clean, professional interface that adapts to the primary colors set in your panel provider.
-
-**Key Features:**
-- Uses Filament's primary color system
-- Flexible and customizable through panel color configuration
-- Professional and clean design
-- Good contrast for readability
-
-### 2. InvoicePlane Blue
-**File:** `invoiceplane-blue.css`
-
-A blue variant of the InvoicePlane theme with a vibrant blue color scheme.
-
-**Key Colors:**
-- Primary: Blue-500 (#3B82F6)
-- Sidebar: Blue-500
-- Active states: Blue-700
-- Hover states: Blue-500
-
-**Best For:** Users who prefer a traditional blue business interface
-
-### 3. Nord
-**File:** `nord.css`
-
-Based on the popular Nord color palette, this theme features cool, arctic-inspired colors with excellent contrast and readability.
-
-**Key Colors:**
-- **Polar Night** (Backgrounds): #2e3440, #3b4252
-- **Snow Storm** (Text): #eceff4, #e5e9f0
-- **Frost** (Accents): #88c0d0, #5e81ac
-- **Aurora** (Semantic):
- - Danger: #bf616a (red)
- - Warning: #ebcb8b (yellow)
- - Success: #a3be8c (green)
-
-**Best For:** Developers who prefer the Nord color scheme, or anyone preferring a cool, calming interface
-
-### 4. Orange
-**File:** `orange.css`
-
-A vibrant orange theme using Tailwind's orange color palette.
-
-**Key Colors:**
-- Primary: Orange-500 (#F97316)
-- Sidebar: Orange-500
-- Active states: Orange-700
-- Hover states: Orange-500
-
-**Best For:** Creative professionals, agencies, or those wanting a warm, energetic interface
-
-### 5. Reddit
-**File:** `reddit.css`
-
-Inspired by Reddit's iconic branding, this theme uses Reddit's signature orange.
-
-**Key Colors:**
-- Primary: #FF4500 (Reddit Orange)
-- Sidebar: #FF4500
-- Active states: #d93900 (darker orange)
-- Hover states: #ff5722 (lighter orange)
-
-**Best For:** Reddit enthusiasts or those wanting a bold, recognizable orange theme
-
-## Theme Files Location
-
-All themes are located in:
-```
-resources/css/filament/company/
-```
-
-Available theme files:
-- `invoiceplane.css`
-- `invoiceplane-blue.css`
-- `nord.css`
-- `orange.css`
-- `reddit.css`
-
-## How to Apply a Theme
-
-### Changing Theme for a Panel
-
-To apply a theme to a Filament panel, update the panel provider file and set the `viteTheme()` method:
-
-```php
-// Example: Modules/Core/Providers/CompanyPanelProvider.php
-
-public function panel(Panel $panel): Panel
-{
- return $panel
- ->id('company')
- ->path('')
- ->viteTheme('resources/css/filament/company/nord.css') // Change this line
- ->login()
- // ... other configuration
-}
-```
-
-### Building the Themes
-
-After changing a theme or modifying theme files, you need to rebuild the assets:
-
-```bash
-npm run build
-```
-
-For development with hot reload:
-```bash
-npm run dev
-```
-
-## Theme Structure
-
-Each theme includes styling for:
-- Topbar (background, navigation, logo)
-- Sidebar (background, navigation items, active states)
-- Form elements (checkboxes, inputs, labels)
-- Modals and dialogs
-- Tables and pagination
-- Buttons and icons
-- Breadcrumbs
-- User menu
-
-## Creating a Custom Theme
-
-To create a new custom theme:
-
-1. Create a new CSS file in `resources/css/filament/company/`:
- ```bash
- touch resources/css/filament/company/my-custom-theme.css
- ```
-
-2. Copy the content from an existing theme (e.g., `invoiceplane.css`) as a starting point
-
-3. Update the colors and styles to match your desired theme
-
-4. Register the theme in `vite.config.js`:
- ```javascript
- input: [
- 'resources/css/app.css',
- 'resources/js/app.js',
- // ... existing themes
- 'resources/css/filament/company/my-custom-theme.css' // Add your theme
- ],
- ```
-
-5. Build the assets:
- ```bash
- npm run build
- ```
-
-6. Update your panel provider to use the new theme:
- ```php
- ->viteTheme('resources/css/filament/company/my-custom-theme.css')
- ```
-
-## Nord Theme Colors
-
-The Nord theme uses the following color palette:
-
-- **Polar Night** - Dark backgrounds and UI elements
- - `--color-polarnight-800: #2e3440` (Primary dark background)
- - `--color-polarnight-700: #3b4252` (Secondary dark background)
-
-- **Snow Storm** - Light text and highlights
- - `--color-snowstorm-600: #eceff4` (Primary light text)
-
-- **Frost** - Primary accent colors
- - `--color-frost-500: #88c0d0` (Primary accent)
- - `--color-frost-700: #5e81ac` (Secondary accent)
-
-- **Aurora** - Semantic colors
- - `--color-aurora-danger: #bf616a` (Error/danger)
- - `--color-aurora-warning: #ebcb8b` (Warning)
- - `--color-aurora-success: #a3be8c` (Success)
-
-## Notes
-
-- All themes are designed to work with Filament 4.0+
-- Themes use Tailwind CSS utility classes where possible
-- Custom CSS variables (like those in the Nord theme) are defined using the `@theme` directive
-- Each theme is self-contained and can be switched independently per panel
-
-## Quick Reference: Switching Themes
-
-To quickly switch themes for a panel, update the `viteTheme()` method in the appropriate panel provider:
-
-**Admin Panel** (`Modules/Core/Providers/AdminPanelProvider.php`):
-```php
-->viteTheme('resources/css/filament/company/invoiceplane.css') // Default
-->viteTheme('resources/css/filament/company/invoiceplane-blue.css') // Blue
-->viteTheme('resources/css/filament/company/nord.css') // Nord
-->viteTheme('resources/css/filament/company/orange.css') // Orange
-->viteTheme('resources/css/filament/company/reddit.css') // Reddit
-```
-
-**Company Panel** (`Modules/Core/Providers/CompanyPanelProvider.php`):
-```php
-->viteTheme('resources/css/filament/company/invoiceplane.css') // Default
-->viteTheme('resources/css/filament/company/invoiceplane-blue.css') // Blue
-->viteTheme('resources/css/filament/company/nord.css') // Nord
-->viteTheme('resources/css/filament/company/orange.css') // Orange
-->viteTheme('resources/css/filament/company/reddit.css') // Reddit
-```
-
-**User Panel** (`Modules/Core/Providers/UserPanelProvider.php`):
-```php
-->viteTheme('resources/css/filament/company/invoiceplane.css') // Default
-->viteTheme('resources/css/filament/company/invoiceplane-blue.css') // Blue
-->viteTheme('resources/css/filament/company/nord.css') // Nord
-->viteTheme('resources/css/filament/company/orange.css') // Orange
-->viteTheme('resources/css/filament/company/reddit.css') // Reddit
-```
-
-After changing the theme, rebuild assets:
-```bash
-npm run build
-```
diff --git a/Modules/Clients/Filament/Company/Resources/Relations/Pages/ListRelations.php b/Modules/Clients/Filament/Company/Resources/Relations/Pages/ListRelations.php
index df4d6334e..70e6b261e 100644
--- a/Modules/Clients/Filament/Company/Resources/Relations/Pages/ListRelations.php
+++ b/Modules/Clients/Filament/Company/Resources/Relations/Pages/ListRelations.php
@@ -5,7 +5,6 @@
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
use Modules\Clients\Filament\Company\Resources\Relations\RelationResource;
-use Modules\Clients\Services\RelationService;
class ListRelations extends ListRecords
{
@@ -15,13 +14,7 @@ protected function getHeaderActions(): array
{
return [
CreateAction::make()
- ->mutateDataUsing(function (array $data) {
- return $data;
- })
- ->action(function (array $data) {
- app(RelationService::class)->createRelation($data);
- })
- ->modalWidth('full'),
+ ->url(fn (): string => RelationResource::getUrl('create')),
];
}
}
diff --git a/Modules/Clients/Filament/Company/Resources/Relations/Pages/ViewRelation.php b/Modules/Clients/Filament/Company/Resources/Relations/Pages/ViewRelation.php
index a339edb30..29a8a2a78 100644
--- a/Modules/Clients/Filament/Company/Resources/Relations/Pages/ViewRelation.php
+++ b/Modules/Clients/Filament/Company/Resources/Relations/Pages/ViewRelation.php
@@ -34,7 +34,10 @@ protected function getHeaderActions(): array
'customer_id' => $this->getRecord()->id,
])),
- EditAction::make(),
+ EditAction::make()
+ ->url(fn (): string => RelationResource::getUrl('edit', [
+ 'record' => $this->getRecord(),
+ ])),
DeleteAction::make()
->hidden(fn () => $this->getRecord()->hasLinkedRecords())
diff --git a/Modules/Clients/Filament/Company/Resources/Relations/RelationResource.php b/Modules/Clients/Filament/Company/Resources/Relations/RelationResource.php
index a4672962a..c7cd65984 100644
--- a/Modules/Clients/Filament/Company/Resources/Relations/RelationResource.php
+++ b/Modules/Clients/Filament/Company/Resources/Relations/RelationResource.php
@@ -12,6 +12,8 @@
use Illuminate\Database\Eloquent\Model;
use Modules\Clients\Enums\RelationStatus;
use Modules\Clients\Enums\RelationType;
+use Modules\Clients\Filament\Company\Resources\Relations\Pages\CreateRelation;
+use Modules\Clients\Filament\Company\Resources\Relations\Pages\EditRelation;
use Modules\Clients\Filament\Company\Resources\Relations\Pages\ListRelations;
use Modules\Clients\Filament\Company\Resources\Relations\Pages\ViewRelation;
use Modules\Clients\Filament\Company\Resources\Relations\RelationManagers\ExpensesRelationManager;
@@ -84,8 +86,10 @@ public static function getRelations(): array
public static function getPages(): array
{
return [
- 'index' => ListRelations::route('/'),
- 'view' => ViewRelation::route('/{record}'),
+ 'index' => ListRelations::route('/'),
+ 'create' => CreateRelation::route('/create'),
+ 'view' => ViewRelation::route('/{record}'),
+ 'edit' => EditRelation::route('/{record}/edit'),
];
}
diff --git a/Modules/Clients/Filament/Company/Resources/Relations/Schemas/RelationForm.php b/Modules/Clients/Filament/Company/Resources/Relations/Schemas/RelationForm.php
index 2c7b36566..63b71d183 100644
--- a/Modules/Clients/Filament/Company/Resources/Relations/Schemas/RelationForm.php
+++ b/Modules/Clients/Filament/Company/Resources/Relations/Schemas/RelationForm.php
@@ -3,19 +3,17 @@
namespace Modules\Clients\Filament\Company\Resources\Relations\Schemas;
use Filament\Forms\Components\DatePicker;
-use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TagsInput;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
-use Filament\Schemas;
-use Filament\Schemas\Components\Fieldset;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Group;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set;
use Filament\Schemas\Schema;
+use Filament\Support\Icons\Heroicon;
use Modules\Clients\Enums\RelationStatus;
use Modules\Clients\Enums\RelationType;
use Modules\Clients\Models\Contact;
@@ -28,45 +26,51 @@ public static function configure(Schema $schema): Schema
{
return $schema
->components([
- Grid::make(2)
+ Grid::make(3)
->columnSpanFull()
->schema([
- //
- // LEFT COLUMN: just a placeholder summary of “Client (Type)”
- //
+ // LEFT: company identity + registration/tax details.
Group::make()
->schema([
- Section::make()
+ Section::make(trans('ip.client_information'))
+ ->icon(Heroicon::OutlinedBuildingOffice2)
->schema([
- Grid::make(2)
- ->columns(2)
+ Grid::make(3)
->schema([
- Select::make('relation_status')
- ->label(trans('ip.status'))
+ Select::make('relation_type')
+ ->label(trans('ip.type'))
+ ->prefixIcon(Heroicon::OutlinedTag)
->options(
- collect(RelationStatus::cases())
- ->mapWithKeys(fn ($s) => [$s->value => $s->label()])
+ collect(RelationType::cases())
+ ->mapWithKeys(fn ($r) => [$r->value => $r->label()])
->toArray()
)
->searchable()
->required(),
- Select::make('relation_type')
- ->label(trans('ip.type'))
+ Select::make('relation_status')
+ ->label(trans('ip.status'))
+ ->prefixIcon(Heroicon::OutlinedCheckBadge)
->options(
- collect(RelationType::cases())
- ->mapWithKeys(fn ($r) => [$r->value => $r->label()])
+ collect(RelationStatus::cases())
+ ->mapWithKeys(fn ($s) => [$s->value => $s->label()])
->toArray()
)
->searchable()
->required(),
+ TextInput::make('relation_number')
+ ->label(trans('ip.relation_number'))
+ ->prefixIcon(Heroicon::OutlinedHashtag)
+ ->required()
+ ->maxLength(30),
+
TextInput::make('company_name')
->label(trans('ip.company_name'))
+ ->prefixIcon(Heroicon::OutlinedBuildingOffice)
->required()
- // relations.company_name
- // is varchar(150).
->maxLength(150)
+ ->columnSpan(2)
->live(debounce: 500)
->afterStateUpdated(function (Get $get, Set $set, ?string $state) {
if ( ! $get('trading_name')) {
@@ -74,14 +78,9 @@ public static function configure(Schema $schema): Schema
}
}),
- TextInput::make('email')
- ->label(trans('ip.email'))
- ->email(),
-
TextInput::make('trading_name')
->label(trans('ip.trading_name'))
- // relations.trading_name
- // is varchar(70).
+ ->prefixIcon(Heroicon::OutlinedBuildingStorefront)
->maxLength(70)
->live(debounce: 500)
->afterStateUpdated(function (Get $get, Set $set, ?string $state) {
@@ -90,10 +89,12 @@ public static function configure(Schema $schema): Schema
TextInput::make('unique_name')
->label(trans('ip.unique_name'))
- ->unique(\Modules\Clients\Models\Relation::class, 'unique_name', ignoreRecord: true)
+ ->prefixIcon(Heroicon::OutlinedLink)
+ ->unique(Relation::class, 'unique_name', ignoreRecord: true)
->required()
->readOnly()
->dehydrated()
+ ->columnSpanFull()
->helperText(trans('ip.unique_name_helper'))
->afterStateHydrated(function (Get $get, Set $set, ?string $state) {
if (empty($state)) {
@@ -103,102 +104,52 @@ public static function configure(Schema $schema): Schema
}
}
}),
-
- TextInput::make('relation_number')
- ->label(trans('ip.relation_number'))
- ->required()
- // relations.relation_number
- // is varchar(30).
- ->maxLength(30),
]),
]),
- ])
-
- ->columnSpan(1),
- //
- // RIGHT COLUMN: all the real inputs, in a 2-column grid
- //
- Schemas\Components\Group::make()
- ->schema([
- Section::make()
+ Section::make(trans('ip.registration_tax_details'))
+ ->icon(Heroicon::OutlinedDocumentText)
->schema([
Grid::make(2)
- ->columns(2)
->schema([
TextInput::make('id_number')
->label(trans('ip.id_number'))
- // relations.id_number is varchar(70).
+ ->prefixIcon(Heroicon::OutlinedIdentification)
->maxLength(70),
TextInput::make('coc_number')
->label(trans('ip.coc_number'))
- // relations.coc_number is varchar(70).
+ ->prefixIcon(Heroicon::OutlinedDocumentText)
->maxLength(70),
TextInput::make('vat_number')
->label(trans('ip.vat_id'))
- // relations.vat_number is varchar(70).
+ ->prefixIcon(Heroicon::OutlinedReceiptPercent)
->maxLength(70),
DatePicker::make('registered_at')
->label(trans('ip.registered_at'))
+ ->prefixIcon(Heroicon::OutlinedCalendarDays)
->required(),
]),
]),
])
- ->columnSpan(1),
- ]),
- Grid::make(2)
- ->columnSpanFull()
- ->schema([
- //
- // LEFT COLUMN: just a placeholder summary of “Client (Type)”
- //
+ ->columnSpan(2),
+
+ // RIGHT: contact details sidebar.
Group::make()
->schema([
- Fieldset::make(trans('ip.client_information'))
- ->extraAttributes([
- 'class' => '!border-curious-200 dark:!border-curious-600 rounded-2xl !p-4',
- ])
+ Section::make(trans('ip.contact_details'))
+ ->icon(Heroicon::OutlinedUserCircle)
->schema([
- Placeholder::make('company_name_display')
- ->label(trans('ip.company_name'))
- ->content(fn (Get $get) => $get('company_name') ?: '-'),
+ TextInput::make('email')
+ ->label(trans('ip.email'))
+ ->prefixIcon(Heroicon::OutlinedEnvelope)
+ ->email(),
- Placeholder::make('trading_name_display')
- ->label(trans('ip.trading_name'))
- ->content(fn (Get $get) => $get('trading_name') ?: '-'),
-
- Placeholder::make('relation_type_display')
- ->label(trans('ip.type'))
- ->content(function (Get $get) {
- $type = $get('relation_type');
- if ( ! $type) {
- return '-';
- }
-
- if ($type instanceof RelationType) {
- return $type->label();
- }
-
- $typeEnum = RelationType::tryFrom($type);
-
- return $typeEnum ? $typeEnum->label() : '-';
- }),
- ])->columnSpan(2),
- ])
- ->columnSpan(1),
-
- //
- // RIGHT COLUMN: all the real inputs, in a 2-column grid
- //
- Schemas\Components\Group::make()
- ->schema([
- Fieldset::make(trans('ip.contact_details'))
- ->schema([
Select::make('primary_contact_id')
->label(trans('ip.primary_contact'))
+ ->prefixIcon(Heroicon::OutlinedUser)
->options(
fn (): array => Contact::query()
->orderBy('first_name')
diff --git a/Modules/Clients/Filament/Company/Resources/Relations/Tables/RelationsTable.php b/Modules/Clients/Filament/Company/Resources/Relations/Tables/RelationsTable.php
index 63ea29b8d..9175ba3f2 100644
--- a/Modules/Clients/Filament/Company/Resources/Relations/Tables/RelationsTable.php
+++ b/Modules/Clients/Filament/Company/Resources/Relations/Tables/RelationsTable.php
@@ -19,6 +19,7 @@
use Modules\Clients\Enums\RelationStatus;
use Modules\Clients\Enums\RelationType;
use Modules\Clients\Exceptions\RelationHasLinkedRecordsException;
+use Modules\Clients\Filament\Company\Resources\Relations\RelationResource;
use Modules\Clients\Models\Relation;
use Modules\Clients\Services\RelationMergeService;
use Modules\Clients\Services\RelationService;
@@ -117,10 +118,9 @@ public static function configure(Table $table): Table
])),
EditAction::make('edit')
->visible(fn () => auth()->user()?->can(Permission::EDIT_RELATIONS->value))
- ->action(function (Relation $record, array $data) {
- app(RelationService::class)->updateRelation($record, $data);
- })
- ->modalWidth('full'),
+ ->url(fn (Relation $record): string => RelationResource::getUrl('edit', [
+ 'record' => $record,
+ ])),
DeleteAction::make('delete')
->visible(fn (Relation $record) => ! $record->hasLinkedRecords()
&& auth()->user()?->can(Permission::DELETE_RELATIONS->value))
diff --git a/Modules/Clients/Tests/Feature/RelationResourceAuthorizationTest.php b/Modules/Clients/Tests/Feature/RelationResourceAuthorizationTest.php
index 881dff4ad..5a1b6a0a8 100644
--- a/Modules/Clients/Tests/Feature/RelationResourceAuthorizationTest.php
+++ b/Modules/Clients/Tests/Feature/RelationResourceAuthorizationTest.php
@@ -13,14 +13,11 @@
/**
* Per #503: authorization tests for the Relations (Clients) resource.
*
- * Note: RelationResource::getPages() only registers 'index' -- there is no
- * routed Create/Edit/View page (CRUD happens via modal actions on the list,
- * see #402's investigation), so these tests exercise the actual
- * authorization gates (RelationResource::can*()) directly rather than the
- * issue's suggested Livewire::test(CreateInvoice::class) pattern, which
- * would target unreachable pages in this codebase. Import/export/duplicate
- * actions from the issue's checklist are not implemented in RelationsTable
- * yet, so there is nothing to test authorization for there.
+ * These tests exercise the authorization gates (RelationResource::can*())
+ * directly rather than the issue's suggested Livewire::test(CreateInvoice::class)
+ * pattern. Import/export/duplicate actions from the issue's checklist are
+ * not implemented in RelationsTable yet, so there is nothing to test
+ * authorization for there.
*/
#[CoversClass(RelationResource::class)]
class RelationResourceAuthorizationTest extends AbstractCompanyPanelTestCase
diff --git a/Modules/Core/Database/Seeders/RolesSeeder.php b/Modules/Core/Database/Seeders/RolesSeeder.php
index 03d996fb3..1654e859d 100644
--- a/Modules/Core/Database/Seeders/RolesSeeder.php
+++ b/Modules/Core/Database/Seeders/RolesSeeder.php
@@ -62,7 +62,7 @@ public function getDefaultRolePermissions(): array
{
$allPermissions = array_column(PermissionEnum::cases(), 'value');
- $customerResources = ['relations', 'contacts', 'invoices', 'quotes', 'payments', 'projects', 'tasks', 'products', 'expenses'];
+ $customerResources = ['relations', 'contacts', 'invoices', 'quotes', 'payments', 'projects', 'tasks', 'products', 'expenses', 'tax-rates'];
$customerSpecialPermissions = [
PermissionEnum::DOWNLOAD_INVOICES->value,
diff --git a/Modules/Core/Filament/Admin/Resources/Companies/CompanyResource.php b/Modules/Core/Filament/Admin/Resources/Companies/CompanyResource.php
index 8f84d7289..f2fa99a56 100644
--- a/Modules/Core/Filament/Admin/Resources/Companies/CompanyResource.php
+++ b/Modules/Core/Filament/Admin/Resources/Companies/CompanyResource.php
@@ -9,6 +9,8 @@
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Model;
use Modules\Core\Enums\Permission;
+use Modules\Core\Filament\Admin\Resources\Companies\Pages\CreateCompany;
+use Modules\Core\Filament\Admin\Resources\Companies\Pages\EditCompany;
use Modules\Core\Filament\Admin\Resources\Companies\Pages\ListCompanies;
use Modules\Core\Filament\Admin\Resources\Companies\Schemas\CompanyForm;
use Modules\Core\Filament\Admin\Resources\Companies\Tables\CompaniesTable;
@@ -41,7 +43,9 @@ public static function getRelations(): array
public static function getPages(): array
{
return [
- 'index' => ListCompanies::route('/'),
+ 'index' => ListCompanies::route('/'),
+ 'create' => CreateCompany::route('/create'),
+ 'edit' => EditCompany::route('/{record}/edit'),
];
}
diff --git a/Modules/Core/Filament/Admin/Resources/Companies/Pages/CreateCompany.php b/Modules/Core/Filament/Admin/Resources/Companies/Pages/CreateCompany.php
index 125de2e39..f46667e13 100644
--- a/Modules/Core/Filament/Admin/Resources/Companies/Pages/CreateCompany.php
+++ b/Modules/Core/Filament/Admin/Resources/Companies/Pages/CreateCompany.php
@@ -3,9 +3,45 @@
namespace Modules\Core\Filament\Admin\Resources\Companies\Pages;
use Filament\Resources\Pages\CreateRecord;
+use Illuminate\Database\Eloquent\Model;
use Modules\Core\Filament\Admin\Resources\Companies\CompanyResource;
+use Modules\Core\Services\CompanyService;
class CreateCompany extends CreateRecord
{
protected static string $resource = CompanyResource::class;
+
+ public function create(bool $another = false): void
+ {
+ $this->authorizeAccess();
+
+ $this->callHook('beforeValidate');
+ $data = $this->form->getState();
+ $this->callHook('afterValidate');
+
+ $data = $this->mutateFormDataBeforeCreate($data);
+ $this->callHook('beforeCreate');
+
+ $this->record = $this->handleRecordCreation($data);
+
+ $this->callHook('afterCreate');
+ $this->rememberData();
+
+ $this->getCreatedNotification()?->send();
+
+ if ($another) {
+ $this->form->model($this->getRecord()::class);
+ $this->record = null;
+ $this->fillForm();
+
+ return;
+ }
+
+ $this->redirect($this->getRedirectUrl());
+ }
+
+ protected function handleRecordCreation(array $data): Model
+ {
+ return app(CompanyService::class)->createCompany($data);
+ }
}
diff --git a/Modules/Core/Filament/Admin/Resources/Companies/Pages/ListCompanies.php b/Modules/Core/Filament/Admin/Resources/Companies/Pages/ListCompanies.php
index 366ea315e..14fcd022c 100644
--- a/Modules/Core/Filament/Admin/Resources/Companies/Pages/ListCompanies.php
+++ b/Modules/Core/Filament/Admin/Resources/Companies/Pages/ListCompanies.php
@@ -5,7 +5,6 @@
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
use Modules\Core\Filament\Admin\Resources\Companies\CompanyResource;
-use Modules\Core\Services\CompanyService;
class ListCompanies extends ListRecords
{
@@ -15,10 +14,7 @@ protected function getHeaderActions(): array
{
return [
CreateAction::make()
- ->action(function (array $data) {
- app(CompanyService::class)->createCompany($data);
- })
- ->modalWidth('full'),
+ ->url(fn (): string => CompanyResource::getUrl('create')),
];
}
}
diff --git a/Modules/Core/Filament/Admin/Resources/Companies/Schemas/CompanyForm.php b/Modules/Core/Filament/Admin/Resources/Companies/Schemas/CompanyForm.php
index 688df7705..be27d007e 100644
--- a/Modules/Core/Filament/Admin/Resources/Companies/Schemas/CompanyForm.php
+++ b/Modules/Core/Filament/Admin/Resources/Companies/Schemas/CompanyForm.php
@@ -7,6 +7,7 @@
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Utilities\Set;
use Filament\Schemas\Schema;
+use Filament\Support\Icons\Heroicon;
use Illuminate\Support\Str;
class CompanyForm
@@ -18,71 +19,82 @@ public static function configure(Schema $schema): Schema
*/
return $schema
->components([
- Grid::make(2)
+ Grid::make(3)
+ ->columnSpanFull()
->schema([
Section::make(trans('ip.basic'))
- ->columnSpan(1)
- ->columns(1)
+ ->icon(Heroicon::OutlinedBuildingOffice2)
+ ->columnSpan(2)
->schema([
- TextInput::make('name')
- ->label(trans('ip.name'))
- ->required()
- // companies.name also has a unique DB
- // constraint (like search_code below) —
- // without this, a duplicate name passes
- // client validation and blows up as an
- // unhandled SQL 500.
- ->unique(ignoreRecord: true)
- ->live(onBlur: true)
- ->afterStateUpdated(fn (Set $set, ?string $state) => $set('slug', Str::slug((string) $state, '_'))),
+ Grid::make(4)
+ ->schema([
+ TextInput::make('name')
+ ->label(trans('ip.name'))
+ ->prefixIcon(Heroicon::OutlinedTag)
+ ->required()
+ // companies.name also has a unique DB
+ // constraint (like search_code below) —
+ // without this, a duplicate name passes
+ // client validation and blows up as an
+ // unhandled SQL 500.
+ ->unique(ignoreRecord: true)
+ ->live(onBlur: true)
+ ->afterStateUpdated(fn (Set $set, ?string $state) => $set('slug', Str::slug((string) $state, '_')))
+ ->columnSpan(4),
+
+ TextInput::make('slug')
+ ->label(trans('ip.slug'))
+ ->prefixIcon(Heroicon::OutlinedLink)
+ ->required()
+ ->readOnly()
+ // companies.slug is also unique; two
+ // different names can still slugify to
+ // the same value (e.g. "Foo!" / "Foo?"),
+ // so this needs its own uniqueness check
+ // independent of the name check above.
+ ->unique(ignoreRecord: true)
+ ->dehydrated()
+ ->columnSpan(2),
- TextInput::make('slug')
- ->label(trans('ip.slug'))
- ->required()
- ->readOnly()
- // companies.slug is also unique; two
- // different names can still slugify to
- // the same value (e.g. "Foo!" / "Foo?"),
- // so this needs its own uniqueness check
- // independent of the name check above.
- ->unique(ignoreRecord: true)
- ->dehydrated(),
+ TextInput::make('search_code')
+ ->label(trans('ip.search_code'))
+ ->prefixIcon(Heroicon::OutlinedHashtag)
+ ->required()
+ // companies.search_code is varchar(10) —
+ // without this, a longer value passes
+ // client-side validation and then blows
+ // up as an unhandled 500 SQL truncation
+ // error instead of a form validation
+ // message.
+ ->maxLength(10)
+ // companies.search_code also has a
+ // unique DB constraint — without this,
+ // a duplicate hits the same "unhandled
+ // 500 instead of a validation message"
+ // failure mode as the length issue
+ // above.
+ ->unique(ignoreRecord: true)
+ ->columnSpan(2),
+ ]),
]),
- //
- // ─── RIGHT COLUMN (3/4 width) ────────────────────────────────
- //
Section::make(trans('ip.details'))
- ->columnSpan(1) // 3/4 of the total width
- ->columns(2) // two‐columns inside
+ ->icon(Heroicon::OutlinedIdentification)
+ ->columnSpan(1)
->schema([
- TextInput::make('search_code')
- ->label(trans('ip.search_code'))
- ->required()
- // companies.search_code is varchar(10) —
- // without this, a longer value passes
- // client-side validation and then blows
- // up as an unhandled 500 SQL truncation
- // error instead of a form validation
- // message.
- ->maxLength(10)
- // companies.search_code also has a
- // unique DB constraint — without this,
- // a duplicate hits the same "unhandled
- // 500 instead of a validation message"
- // failure mode as the length issue
- // above.
- ->unique(ignoreRecord: true),
TextInput::make('vat_number')
->label(trans('ip.vat_id'))
+ ->prefixIcon(Heroicon::OutlinedBuildingLibrary)
->nullable(),
TextInput::make('id_number')
->label(trans('ip.id_number'))
+ ->prefixIcon(Heroicon::OutlinedIdentification)
->nullable(),
TextInput::make('coc_number')
->label(trans('ip.coc_number'))
+ ->prefixIcon(Heroicon::OutlinedIdentification)
->nullable(),
]),
]),
diff --git a/Modules/Core/Filament/Admin/Resources/Companies/Tables/Actions/AssignTaxRateBulkAction.php b/Modules/Core/Filament/Admin/Resources/Companies/Tables/Actions/AssignTaxRateBulkAction.php
index 3675982d3..ecda0d863 100644
--- a/Modules/Core/Filament/Admin/Resources/Companies/Tables/Actions/AssignTaxRateBulkAction.php
+++ b/Modules/Core/Filament/Admin/Resources/Companies/Tables/Actions/AssignTaxRateBulkAction.php
@@ -24,13 +24,11 @@ protected function setUp(): void
Select::make('tax_rate_id')
->label(trans('ip.tax_rate'))
/**
- /*
* Tax rates are scoped to their owning company by the
* BelongsToCompany global scope, but an admin assigning
* tax rates across companies needs to pick from all of
* them regardless of the current session/tenant company.
*/
- */
->options(fn () => static::getTaxRateOptions())
->searchable()
->required(),
diff --git a/Modules/Core/Filament/Admin/Resources/Companies/Tables/CompaniesTable.php b/Modules/Core/Filament/Admin/Resources/Companies/Tables/CompaniesTable.php
index 3539ed3db..b21ab1348 100644
--- a/Modules/Core/Filament/Admin/Resources/Companies/Tables/CompaniesTable.php
+++ b/Modules/Core/Filament/Admin/Resources/Companies/Tables/CompaniesTable.php
@@ -9,6 +9,7 @@
use Filament\Actions\EditAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
+use Modules\Core\Filament\Admin\Resources\Companies\CompanyResource;
use Modules\Core\Filament\Admin\Resources\Companies\Tables\Actions\AssignEmailTemplateBulkAction;
use Modules\Core\Filament\Admin\Resources\Companies\Tables\Actions\AssignInvoiceGroupBulkAction;
use Modules\Core\Filament\Admin\Resources\Companies\Tables\Actions\AssignPaymentMethodBulkAction;
@@ -35,10 +36,9 @@ public static function configure(Table $table): Table
->recordActions([
ActionGroup::make([
EditAction::make('edit')
- ->action(function (Company $record, array $data) {
- app(CompanyService::class)->updateCompany($record, $data);
- })
- ->modalWidth('full'),
+ ->url(fn (Company $record): string => CompanyResource::getUrl('edit', [
+ 'record' => $record,
+ ])),
DeleteAction::make('delete')
->action(function (Company $record) {
app(CompanyService::class)->deleteCompany($record);
diff --git a/Modules/Core/Filament/Admin/Resources/EmailTemplates/EmailTemplateResource.php b/Modules/Core/Filament/Admin/Resources/EmailTemplates/EmailTemplateResource.php
index 17d0f8cfc..975c92682 100644
--- a/Modules/Core/Filament/Admin/Resources/EmailTemplates/EmailTemplateResource.php
+++ b/Modules/Core/Filament/Admin/Resources/EmailTemplates/EmailTemplateResource.php
@@ -9,6 +9,8 @@
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Model;
use Modules\Core\Enums\Permission;
+use Modules\Core\Filament\Admin\Resources\EmailTemplates\Pages\CreateEmailTemplate;
+use Modules\Core\Filament\Admin\Resources\EmailTemplates\Pages\EditEmailTemplate;
use Modules\Core\Filament\Admin\Resources\EmailTemplates\Pages\ListEmailTemplates;
use Modules\Core\Filament\Admin\Resources\EmailTemplates\Schemas\EmailTemplateForm;
use Modules\Core\Filament\Admin\Resources\EmailTemplates\Tables\EmailTemplatesTable;
@@ -39,7 +41,9 @@ public static function getRelations(): array
public static function getPages(): array
{
return [
- 'index' => ListEmailTemplates::route('/'),
+ 'index' => ListEmailTemplates::route('/'),
+ 'create' => CreateEmailTemplate::route('/create'),
+ 'edit' => EditEmailTemplate::route('/{record}/edit'),
];
}
diff --git a/Modules/Core/Filament/Admin/Resources/EmailTemplates/Pages/ListEmailTemplates.php b/Modules/Core/Filament/Admin/Resources/EmailTemplates/Pages/ListEmailTemplates.php
index 3c6db7ffa..b48f9adfa 100644
--- a/Modules/Core/Filament/Admin/Resources/EmailTemplates/Pages/ListEmailTemplates.php
+++ b/Modules/Core/Filament/Admin/Resources/EmailTemplates/Pages/ListEmailTemplates.php
@@ -5,7 +5,6 @@
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
use Modules\Core\Filament\Admin\Resources\EmailTemplates\EmailTemplateResource;
-use Modules\Core\Services\EmailTemplateService;
class ListEmailTemplates extends ListRecords
{
@@ -15,15 +14,7 @@ protected function getHeaderActions(): array
{
return [
CreateAction::make()
- ->mutateDataUsing(function (array $data) {
- $data['body'] ??= '';
-
- return $data;
- })
- ->action(function (array $data) {
- app(EmailTemplateService::class)->createEmailTemplate($data);
- })
- ->modalWidth('full'),
+ ->url(fn (): string => EmailTemplateResource::getUrl('create')),
];
}
}
diff --git a/Modules/Core/Filament/Admin/Resources/EmailTemplates/Schemas/EmailTemplateForm.php b/Modules/Core/Filament/Admin/Resources/EmailTemplates/Schemas/EmailTemplateForm.php
index a21ba0452..a0ebdcf9a 100644
--- a/Modules/Core/Filament/Admin/Resources/EmailTemplates/Schemas/EmailTemplateForm.php
+++ b/Modules/Core/Filament/Admin/Resources/EmailTemplates/Schemas/EmailTemplateForm.php
@@ -5,9 +5,11 @@
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
-use Filament\Schemas;
+use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Section;
+use Filament\Schemas\Components\Text;
use Filament\Schemas\Schema;
+use Filament\Support\Icons\Heroicon;
use Illuminate\Support\HtmlString;
use Modules\Core\Enums\EmailTemplateType;
use Modules\Core\Services\EmailTemplateVariableResolver;
@@ -18,57 +20,87 @@ public static function configure(Schema $schema): Schema
{
return $schema
->components([
- Schemas\Components\Group::make()
+ Grid::make(3)
+ ->columnSpanFull()
->schema([
- Section::make(heading:null)
+ Section::make(trans('ip.details'))
+ ->icon(Heroicon::OutlinedEnvelopeOpen)
+ ->columnSpan(2)
->schema([
- TextInput::make('title')
- ->label(trans('ip.title'))
- ->required()
- ->autofocus(),
- TextInput::make('from_name')
- ->label(trans('ip.from_name')),
- TextInput::make('from_email')
- ->label(trans('ip.from_email')),
- ])->columns(1),
- Section::make(heading:trans('ip.cc_and_bcc'))
+ Grid::make(4)
+ ->schema([
+ TextInput::make('title')
+ ->label(trans('ip.title'))
+ ->prefixIcon(Heroicon::OutlinedTag)
+ ->required()
+ ->autofocus()
+ ->columnSpan(4),
+
+ Select::make('type')
+ ->label(trans('ip.type'))
+ ->prefixIcon(Heroicon::OutlinedListBullet)
+ ->required()
+ ->options(EmailTemplateType::class)
+ ->default(null)
+ ->columnSpan(2),
+
+ TextInput::make('subject')
+ ->label(trans('ip.subject'))
+ ->prefixIcon(Heroicon::OutlinedChatBubbleLeftRight)
+ ->columnSpan(2),
+
+ TextInput::make('from_name')
+ ->label(trans('ip.from_name'))
+ ->prefixIcon(Heroicon::OutlinedUser)
+ ->columnSpan(2),
+
+ TextInput::make('from_email')
+ ->label(trans('ip.from_email'))
+ ->prefixIcon(Heroicon::OutlinedEnvelope)
+ ->columnSpan(2),
+ ]),
+ ]),
+
+ Section::make(trans('ip.cc_and_bcc'))
+ ->icon(Heroicon::OutlinedAtSymbol)
+ ->columnSpan(1)
->collapsed()
->schema([
- TextInput::make('cc')->label(trans('ip.cc')),
- TextInput::make('bcc')->label(trans('ip.bcc')),
- ])->columns(1),
+ TextInput::make('cc')
+ ->label(trans('ip.cc'))
+ ->prefixIcon(Heroicon::OutlinedAtSymbol),
+ TextInput::make('bcc')
+ ->label(trans('ip.bcc'))
+ ->prefixIcon(Heroicon::OutlinedAtSymbol),
+ ]),
]),
- Schemas\Components\Group::make()
+
+ Section::make(trans('ip.body'))
+ ->icon(Heroicon::OutlinedDocumentText)
+ ->columnSpanFull()
->schema([
- Section::make(heading:null)
- ->schema(components: [
- Select::make('type')
- ->label(trans('ip.type'))
- ->required()
- ->options(EmailTemplateType::class)
- ->default(null),
- TextInput::make('subject')
- ->label(trans('ip.subject')),
- Textarea::make('body')
- ->label(trans('ip.body'))
- ->rows(10)
- // email_templates.body is a NOT NULL
- // longText column with no default —
- // without this, a blank body passes
- // client validation and blows up as an
- // unhandled SQL 500. Shared by both the
- // admin and company panel resources.
- ->required(),
- ])->columns(1),
- Section::make(heading:trans('ip.available_variables'))
- ->collapsed()
- ->schema([
- Schemas\Components\Text::make(fn (): HtmlString => new HtmlString(
- collect(app(EmailTemplateVariableResolver::class)->variables())
- ->map(fn (string $description, string $tag): string => '
' . e($tag) . ' — ' . e($description) . '
')
- ->implode('')
- )),
- ])->columns(1),
+ Textarea::make('body')
+ ->label(trans('ip.body'))
+ ->rows(10)
+ // email_templates.body is a NOT NULL
+ // longText column with no default —
+ // without this, a blank body passes
+ // client validation and blows up as an
+ // unhandled SQL 500. Shared by both the
+ // admin and company panel resources.
+ ->required(),
+ ]),
+
+ Section::make(trans('ip.available_variables'))
+ ->icon(Heroicon::OutlinedInformationCircle)
+ ->collapsed()
+ ->columnSpanFull()
+ ->schema([
+ Text::make(fn (): HtmlString => new HtmlString(
+ collect(app(EmailTemplateVariableResolver::class)->variables())
+ ->map(fn (string $description, string $tag): string => '' . e($tag) . ' — ' . e($description) . '
')
+ ->implode('')
+ )),
]),
]);
}
diff --git a/Modules/Core/Filament/Admin/Resources/EmailTemplates/Tables/EmailTemplatesTable.php b/Modules/Core/Filament/Admin/Resources/EmailTemplates/Tables/EmailTemplatesTable.php
index d8f49827e..9c609ae2a 100644
--- a/Modules/Core/Filament/Admin/Resources/EmailTemplates/Tables/EmailTemplatesTable.php
+++ b/Modules/Core/Filament/Admin/Resources/EmailTemplates/Tables/EmailTemplatesTable.php
@@ -54,8 +54,9 @@ public static function configure(Table $table): Table
->recordActions([
ActionGroup::make([
EditAction::make()
- ->action(fn (EmailTemplate $record, array $data) => app(EmailTemplateService::class)->updateEmailTemplate($record, $data))
- ->modalWidth('full'),
+ ->url(fn (EmailTemplate $record, $livewire): string => $livewire::getResource()::getUrl('edit', [
+ 'record' => $record,
+ ])),
DeleteAction::make('delete')
->action(function (EmailTemplate $record, array $data) {
app(EmailTemplateService::class)->deleteEmailTemplate($record);
diff --git a/Modules/Core/Filament/Admin/Resources/Numberings/NumberingResource.php b/Modules/Core/Filament/Admin/Resources/Numberings/NumberingResource.php
index f9a870241..adcea1137 100644
--- a/Modules/Core/Filament/Admin/Resources/Numberings/NumberingResource.php
+++ b/Modules/Core/Filament/Admin/Resources/Numberings/NumberingResource.php
@@ -9,6 +9,8 @@
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Model;
use Modules\Core\Enums\Permission;
+use Modules\Core\Filament\Admin\Resources\Numberings\Pages\CreateNumbering;
+use Modules\Core\Filament\Admin\Resources\Numberings\Pages\EditNumbering;
use Modules\Core\Filament\Admin\Resources\Numberings\Pages\ListNumberings;
use Modules\Core\Filament\Admin\Resources\Numberings\Schemas\NumberingForm;
use Modules\Core\Filament\Admin\Resources\Numberings\Tables\NumberingsTable;
@@ -39,7 +41,9 @@ public static function getRelations(): array
public static function getPages(): array
{
return [
- 'index' => ListNumberings::route('/'),
+ 'index' => ListNumberings::route('/'),
+ 'create' => CreateNumbering::route('/create'),
+ 'edit' => EditNumbering::route('/{record}/edit'),
];
}
diff --git a/Modules/Core/Filament/Admin/Resources/Numberings/Pages/ListNumberings.php b/Modules/Core/Filament/Admin/Resources/Numberings/Pages/ListNumberings.php
index a40cc372b..7653e0eaf 100644
--- a/Modules/Core/Filament/Admin/Resources/Numberings/Pages/ListNumberings.php
+++ b/Modules/Core/Filament/Admin/Resources/Numberings/Pages/ListNumberings.php
@@ -5,7 +5,6 @@
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
use Modules\Core\Filament\Admin\Resources\Numberings\NumberingResource;
-use Modules\Core\Services\NumberingService;
class ListNumberings extends ListRecords
{
@@ -15,13 +14,7 @@ protected function getHeaderActions(): array
{
return [
CreateAction::make()
- ->mutateDataUsing(function (array $data) {
- return $data;
- })
- ->action(function (array $data) {
- app(NumberingService::class)->createNumbering($data);
- })
- ->modalWidth('full'),
+ ->url(fn (): string => NumberingResource::getUrl('create')),
];
}
}
diff --git a/Modules/Core/Filament/Admin/Resources/Numberings/Schemas/NumberingForm.php b/Modules/Core/Filament/Admin/Resources/Numberings/Schemas/NumberingForm.php
index d2774715e..a23afb651 100644
--- a/Modules/Core/Filament/Admin/Resources/Numberings/Schemas/NumberingForm.php
+++ b/Modules/Core/Filament/Admin/Resources/Numberings/Schemas/NumberingForm.php
@@ -3,13 +3,12 @@
namespace Modules\Core\Filament\Admin\Resources\Numberings\Schemas;
use Filament\Actions\Action;
-use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
-use Filament\Schemas;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
+use Filament\Support\Icons\Heroicon;
use Illuminate\Support\Collection;
use Modules\Core\Enums\NumberingType;
use Modules\Core\Models\Company;
@@ -23,31 +22,28 @@ public static function configure(Schema $schema): Schema
{
return $schema
->components([
- // Company selection (Admin can assign to any company)
- Section::make(trans('ip.numbering_company_assignment'))
+ Grid::make(3)
+ ->columnSpanFull()
->schema([
- Select::make('company_id')
- ->label(trans('ip.numbering_company'))
- ->options(Company::all()->pluck('name', 'id'))
- ->required()
- ->searchable()
- ->preload()
- ->helperText(trans('ip.numbering_select_company_help')),
- ])
- ->columnSpanFull(),
-
- //
- // Top: Type and Name
- //
- Section::make()
- ->schema([
- Grid::make(2)
+ Section::make(trans('ip.basic_information'))
+ ->icon(Heroicon::OutlinedIdentification)
+ ->columnSpan(2)
->schema([
- // ── LEFT: Type and Name
- Schemas\Components\Group::make()
+ Grid::make(4)
->schema([
+ Select::make('company_id')
+ ->label(trans('ip.numbering_company'))
+ ->prefixIcon(Heroicon::OutlinedBuildingOffice2)
+ ->options(Company::all()->pluck('name', 'id'))
+ ->required()
+ ->searchable()
+ ->preload()
+ ->helperText(trans('ip.numbering_select_company_help'))
+ ->columnSpan(4),
+
Select::make('type')
->label(trans('ip.numbering_type'))
+ ->prefixIcon(Heroicon::OutlinedRectangleStack)
->options(array_combine(
array_map(fn (NumberingType $case): string => $case->value, NumberingType::cases()),
array_map(fn (NumberingType $case): string => $case->label(), NumberingType::cases())
@@ -61,67 +57,59 @@ public static function configure(Schema $schema): Schema
$set('prefix', $type->prefix());
}
}
- }),
+ })
+ ->columnSpan(2),
+
TextInput::make('name')
->label(trans('ip.numbering_name'))
- ->required(),
- ])
- ->columnSpan(1),
+ ->prefixIcon(Heroicon::OutlinedTag)
+ ->required()
+ ->columnSpan(2),
- // ── RIGHT: Next ID / Left Pad
- Grid::make()
- ->schema([
TextInput::make('next_id')
->label(trans('ip.numbering_next_id'))
+ ->prefixIcon(Heroicon::OutlinedHashtag)
->numeric()
->required()
- ->default(1),
+ ->default(1)
+ ->columnSpan(2),
TextInput::make('left_pad')
->label(trans('ip.numbering_left_pad'))
->numeric()
- ->default(4),
- ])
- ->columnSpan(1),
+ ->default(4)
+ ->columnSpan(2),
+ ]),
]),
- ])
- ->columnSpanFull(),
- //
- // Below: Prefix and Format
- //
- Section::make()
- ->schema([
- Grid::make(2)
- ->columns(2)
+ Section::make(trans('ip.details'))
+ ->icon(Heroicon::OutlinedCalculator)
+ ->columnSpan(2)
->schema([
- Schemas\Components\Group::make()->schema([
- Select::make('prefix')
- ->label(trans('ip.numbering_prefix'))
- ->placeholder('INV')
- ->options(fn (?string $state): array => self::prefixOptions($state))
- ->searchable()
- ->native(false),
- TextInput::make('format')
- ->label(trans('ip.numbering_format'))
- ->placeholder(trans('ip.numbering_format_placeholder'))
- ->helperText(trans('ip.numbering_format_help'))
- ->suffixActions(self::tokenInsertActions('format')),
- TextInput::make('group_identifier_format')
- ->label(trans('ip.numbering_group_identifier_format'))
- ->placeholder(trans('ip.numbering_group_identifier_format_placeholder'))
- ->helperText(trans('ip.numbering_group_identifier_format_help'))
- ->suffixActions(self::tokenInsertActions('group_identifier_format')),
- ]),
- Schemas\Components\Group::make()->schema([
- Placeholder::make('format_helper')
- ->label('')
- ->content(trans('ip.numbering_format_helper'))
- ->columnSpanFull(),
- ]),
+ Select::make('prefix')
+ ->label(trans('ip.numbering_prefix'))
+ ->prefixIcon(Heroicon::OutlinedListBullet)
+ ->placeholder('INV')
+ ->options(fn (?string $state): array => self::prefixOptions($state))
+ ->searchable()
+ ->native(false)
+ ->columnSpanFull(),
+
+ TextInput::make('format')
+ ->label(trans('ip.numbering_format'))
+ ->placeholder(trans('ip.numbering_format_placeholder'))
+ ->helperText(trans('ip.numbering_format_help'))
+ ->suffixActions(self::tokenInsertActions('format'))
+ ->columnSpanFull(),
+
+ TextInput::make('group_identifier_format')
+ ->label(trans('ip.numbering_group_identifier_format'))
+ ->placeholder(trans('ip.numbering_group_identifier_format_placeholder'))
+ ->helperText(trans('ip.numbering_group_identifier_format_help'))
+ ->suffixActions(self::tokenInsertActions('group_identifier_format'))
+ ->columnSpanFull(),
]),
- ])
- ->columnSpanFull(),
+ ]),
]);
}
diff --git a/Modules/Core/Filament/Admin/Resources/Numberings/Tables/NumberingsTable.php b/Modules/Core/Filament/Admin/Resources/Numberings/Tables/NumberingsTable.php
index 4bc1302bb..0db1035c8 100644
--- a/Modules/Core/Filament/Admin/Resources/Numberings/Tables/NumberingsTable.php
+++ b/Modules/Core/Filament/Admin/Resources/Numberings/Tables/NumberingsTable.php
@@ -10,6 +10,7 @@
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Modules\Core\Enums\NumberingType;
+use Modules\Core\Filament\Admin\Resources\Numberings\NumberingResource;
use Modules\Core\Helpers\EnumHelper;
use Modules\Core\Models\Numbering;
use Modules\Core\Services\NumberingService;
@@ -60,7 +61,10 @@ public static function configure(Table $table): Table
->filters([])
->recordActions([
ActionGroup::make([
- EditAction::make(),
+ EditAction::make()
+ ->url(fn (Numbering $record): string => NumberingResource::getUrl('edit', [
+ 'record' => $record,
+ ])),
DeleteAction::make('delete')
->action(function (Numbering $record) {
app(NumberingService::class)->deleteNumbering($record);
diff --git a/Modules/Core/Filament/Admin/Resources/TaxRates/Pages/ListTaxRates.php b/Modules/Core/Filament/Admin/Resources/TaxRates/Pages/ListTaxRates.php
index 0e199ec2c..ebd5985ca 100644
--- a/Modules/Core/Filament/Admin/Resources/TaxRates/Pages/ListTaxRates.php
+++ b/Modules/Core/Filament/Admin/Resources/TaxRates/Pages/ListTaxRates.php
@@ -14,13 +14,7 @@ protected function getHeaderActions(): array
{
return [
CreateAction::make()
- ->mutateDataUsing(function (array $data) {
- return $data;
- })
- ->action(function (array $data) {
- app(\Modules\Core\Services\TaxRateService::class)->createTaxRate($data);
- })
- ->modalWidth('full'),
+ ->url(fn (): string => TaxRateResource::getUrl('create')),
];
}
}
diff --git a/Modules/Core/Filament/Admin/Resources/TaxRates/Schemas/TaxRateForm.php b/Modules/Core/Filament/Admin/Resources/TaxRates/Schemas/TaxRateForm.php
index c1a375506..f9e72cd03 100644
--- a/Modules/Core/Filament/Admin/Resources/TaxRates/Schemas/TaxRateForm.php
+++ b/Modules/Core/Filament/Admin/Resources/TaxRates/Schemas/TaxRateForm.php
@@ -8,6 +8,7 @@
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
+use Filament\Support\Icons\Heroicon;
use Modules\Core\Enums\TaxRateType;
class TaxRateForm
@@ -16,24 +17,25 @@ public static function configure(Schema $schema): Schema
{
return $schema
->components([
- Grid::make(2)
+ Grid::make(3)
->columnSpanFull()
->schema([
- //
- // LEFT COLUMN: Code, Name, Active
- //
Section::make(trans('ip.basic_information'))
+ ->icon(Heroicon::OutlinedIdentification)
+ ->columnSpan(2)
->schema([
- // 2-column grid for Code + Active
- Grid::make(2)
- ->columns(2)
- // <-- Center *every* grid cell vertically
- ->extraAttributes([
- 'class' => '!items-center',
- ])
+ Grid::make(4)
->schema([
+ TextInput::make('name')
+ ->label(trans('ip.name'))
+ ->prefixIcon(Heroicon::OutlinedTag)
+ ->required()
+ ->autofocus()
+ ->columnSpan(4),
+
TextInput::make('code')
->label(trans('ip.tax_rate_code'))
+ ->prefixIcon(Heroicon::OutlinedHashtag)
// tax_rates.code is NOT NULL with
// no DB default — leaving this
// blank (it looked optional,
@@ -64,56 +66,42 @@ public static function configure(Schema $schema): Schema
// codebase) — this is at worst
// stricter than the real DB
// constraint, never looser.
- ->unique(ignoreRecord: true),
+ ->unique(ignoreRecord: true)
+ ->columnSpan(2),
Toggle::make('is_active')
->label(trans('ip.is_active'))
->default(true)
- ->columnSpan(1)
- // no need for h-full, the grid will handle centering
- ->extraAttributes([
- 'class' => '!flex items-center',
- ]),
+ ->columnSpan(2),
]),
+ ]),
- // Name below, full width of the section
- TextInput::make('name')
- ->label(trans('ip.name'))
- ->required()
- ->autofocus(),
- ])
- ->columnSpan(1),
-
- //
- // RIGHT COLUMN: Type & Percentage
- //
Section::make(trans('ip.details'))
+ ->icon(Heroicon::OutlinedReceiptPercent)
+ ->columnSpan(1)
->schema([
- Grid::make(2)
- ->columns(2)
- ->schema([
- Select::make('tax_rate_type')
- ->label(trans('ip.tax_rate_type'))
- ->options(
- collect(TaxRateType::cases())
- ->mapWithKeys(fn (TaxRateType $type) => [
- $type->value => trans($type->label()),
- ])
- ->toArray()
- )
- ->required()
- ->searchable()
- ->preload()
- ->native(false),
+ Select::make('tax_rate_type')
+ ->label(trans('ip.tax_rate_type'))
+ ->prefixIcon(Heroicon::OutlinedListBullet)
+ ->options(
+ collect(TaxRateType::cases())
+ ->mapWithKeys(fn (TaxRateType $type) => [
+ $type->value => trans($type->label()),
+ ])
+ ->toArray()
+ )
+ ->required()
+ ->searchable()
+ ->preload()
+ ->native(false),
- TextInput::make('rate')
- ->label(trans('ip.percentage'))
- ->required()
- ->numeric()
- ->step(0.01),
- ]),
- ])
- ->columnSpan(1),
+ TextInput::make('rate')
+ ->label(trans('ip.percentage'))
+ ->prefixIcon(Heroicon::OutlinedReceiptPercent)
+ ->required()
+ ->numeric()
+ ->step(0.01),
+ ]),
]),
]);
}
diff --git a/Modules/Core/Filament/Admin/Resources/TaxRates/Tables/TaxRatesTable.php b/Modules/Core/Filament/Admin/Resources/TaxRates/Tables/TaxRatesTable.php
index 2c5ea5bb3..192ed9b4d 100644
--- a/Modules/Core/Filament/Admin/Resources/TaxRates/Tables/TaxRatesTable.php
+++ b/Modules/Core/Filament/Admin/Resources/TaxRates/Tables/TaxRatesTable.php
@@ -11,6 +11,7 @@
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Modules\Core\Enums\TaxRateType;
+use Modules\Core\Filament\Admin\Resources\TaxRates\TaxRateResource;
use Modules\Core\Helpers\EnumHelper;
use Modules\Core\Models\TaxRate;
use Modules\Core\Services\TaxRateService;
@@ -56,9 +57,10 @@ public static function configure(Table $table): Table
->filters([])
->recordActions([
ActionGroup::make([
- EditAction::make()->action(function (TaxRate $record, array $data) {
- app(TaxRateService::class)->updateTaxRate($record, $data);
- })->modalWidth('full'),
+ EditAction::make()
+ ->url(fn (TaxRate $record): string => TaxRateResource::getUrl('edit', [
+ 'record' => $record,
+ ])),
DeleteAction::make('delete')
->action(function (TaxRate $record, array $data) {
app(TaxRateService::class)->deleteTaxRate($record);
diff --git a/Modules/Core/Filament/Admin/Resources/TaxRates/TaxRateResource.php b/Modules/Core/Filament/Admin/Resources/TaxRates/TaxRateResource.php
index d3a2ae8ee..dd90b8d22 100644
--- a/Modules/Core/Filament/Admin/Resources/TaxRates/TaxRateResource.php
+++ b/Modules/Core/Filament/Admin/Resources/TaxRates/TaxRateResource.php
@@ -9,6 +9,8 @@
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Model;
use Modules\Core\Enums\Permission;
+use Modules\Core\Filament\Admin\Resources\TaxRates\Pages\CreateTaxRate;
+use Modules\Core\Filament\Admin\Resources\TaxRates\Pages\EditTaxRate;
use Modules\Core\Filament\Admin\Resources\TaxRates\Pages\ListTaxRates;
use Modules\Core\Filament\Admin\Resources\TaxRates\Schemas\TaxRateForm;
use Modules\Core\Filament\Admin\Resources\TaxRates\Tables\TaxRatesTable;
@@ -39,7 +41,9 @@ public static function getRelations(): array
public static function getPages(): array
{
return [
- 'index' => ListTaxRates::route('/'),
+ 'index' => ListTaxRates::route('/'),
+ 'create' => CreateTaxRate::route('/create'),
+ 'edit' => EditTaxRate::route('/{record}/edit'),
];
}
diff --git a/Modules/Core/Filament/Admin/Resources/Users/Pages/CreateUser.php b/Modules/Core/Filament/Admin/Resources/Users/Pages/CreateUser.php
index 26cfb2989..11485177a 100644
--- a/Modules/Core/Filament/Admin/Resources/Users/Pages/CreateUser.php
+++ b/Modules/Core/Filament/Admin/Resources/Users/Pages/CreateUser.php
@@ -3,9 +3,45 @@
namespace Modules\Core\Filament\Admin\Resources\Users\Pages;
use Filament\Resources\Pages\CreateRecord;
+use Illuminate\Database\Eloquent\Model;
use Modules\Core\Filament\Admin\Resources\Users\UserResource;
+use Modules\Core\Services\UserService;
class CreateUser extends CreateRecord
{
protected static string $resource = UserResource::class;
+
+ public function create(bool $another = false): void
+ {
+ $this->authorizeAccess();
+
+ $this->callHook('beforeValidate');
+ $data = $this->form->getState();
+ $this->callHook('afterValidate');
+
+ $data = $this->mutateFormDataBeforeCreate($data);
+ $this->callHook('beforeCreate');
+
+ $this->record = $this->handleRecordCreation($data);
+
+ $this->callHook('afterCreate');
+ $this->rememberData();
+
+ $this->getCreatedNotification()?->send();
+
+ if ($another) {
+ $this->form->model($this->getRecord()::class);
+ $this->record = null;
+ $this->fillForm();
+
+ return;
+ }
+
+ $this->redirect($this->getRedirectUrl());
+ }
+
+ protected function handleRecordCreation(array $data): Model
+ {
+ return app(UserService::class)->createUser($data);
+ }
}
diff --git a/Modules/Core/Filament/Admin/Resources/Users/Pages/ListUsers.php b/Modules/Core/Filament/Admin/Resources/Users/Pages/ListUsers.php
index 75ce22dfb..3d2b6a4b5 100644
--- a/Modules/Core/Filament/Admin/Resources/Users/Pages/ListUsers.php
+++ b/Modules/Core/Filament/Admin/Resources/Users/Pages/ListUsers.php
@@ -5,7 +5,6 @@
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
use Modules\Core\Filament\Admin\Resources\Users\UserResource;
-use Modules\Core\Services\UserService;
class ListUsers extends ListRecords
{
@@ -15,13 +14,7 @@ protected function getHeaderActions(): array
{
return [
CreateAction::make()
- ->mutateDataUsing(function (array $data) {
- return $data;
- })
- ->action(function (array $data) {
- app(UserService::class)->createUser($data);
- })
- ->modalWidth('full'),
+ ->url(fn (): string => UserResource::getUrl('create')),
];
}
}
diff --git a/Modules/Core/Filament/Admin/Resources/Users/Schemas/UserForm.php b/Modules/Core/Filament/Admin/Resources/Users/Schemas/UserForm.php
index bff28d6bc..74b18044a 100644
--- a/Modules/Core/Filament/Admin/Resources/Users/Schemas/UserForm.php
+++ b/Modules/Core/Filament/Admin/Resources/Users/Schemas/UserForm.php
@@ -7,6 +7,7 @@
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
+use Filament\Support\Icons\Heroicon;
class UserForm
{
@@ -14,44 +15,55 @@ public static function configure(Schema $schema): Schema
{
return $schema
->components([
- Grid::make(4)
+ Grid::make(3)
->columnSpanFull()
->schema([
Section::make(trans('ip.personal_information'))
- ->columnSpan(1) // 1/4 width
- ->columns(1)
+ ->icon(Heroicon::OutlinedUser)
+ ->columnSpan(2)
->schema([
- TextInput::make('name')
- ->label(trans('ip.name'))
- ->required()
- ->autofocus()
- ->maxLength(255),
+ Grid::make(2)
+ ->schema([
+ TextInput::make('name')
+ ->label(trans('ip.name'))
+ ->prefixIcon(Heroicon::OutlinedIdentification)
+ ->required()
+ ->autofocus()
+ ->maxLength(255)
+ ->columnSpan(2),
+ TextInput::make('email')
+ ->label(trans('ip.email'))
+ ->prefixIcon(Heroicon::OutlinedEnvelope)
+ ->email()
+ ->required()
+ ->maxLength(255)
+ // users.email has a unique DB constraint;
+ // without this, a duplicate email passes
+ // client validation and blows up as an
+ // unhandled SQL 500 instead of a form
+ // validation message (UserService::
+ // createUser/updateUser do no uniqueness
+ // check of their own).
+ ->unique(ignoreRecord: true)
+ ->columnSpan(1),
+ TextInput::make('password')
+ ->label(trans('ip.password'))
+ ->prefixIcon(Heroicon::OutlinedLockClosed)
+ ->password()
+ ->dehydrateStateUsing(fn ($state) => bcrypt($state))
+ ->dehydrated(fn ($state) => filled($state))
+ ->required(fn ($context) => $context === 'create')
+ ->columnSpan(1),
+ ]),
]),
- Section::make(trans('ip.contact_information'))
- ->columnSpan(3) // 3/4 width
- ->columns(2)
+ Section::make(trans('ip.status'))
+ ->icon(Heroicon::OutlinedShieldCheck)
+ ->columnSpan(1)
->schema([
- TextInput::make('email')
- ->label(trans('ip.email'))
- ->email()
- ->required()
- ->maxLength(255)
- // users.email has a unique DB constraint;
- // without this, a duplicate email passes
- // client validation and blows up as an
- // unhandled SQL 500 instead of a form
- // validation message (UserService::
- // createUser/updateUser do no uniqueness
- // check of their own).
- ->unique(ignoreRecord: true),
DatePicker::make('email_verified_at')
- ->label(trans('ip.email_verified_at')),
- TextInput::make('password')
- ->label(trans('ip.password'))
- ->password()
- ->dehydrateStateUsing(fn ($state) => filled($state) ? bcrypt($state) : null)
- ->required(fn ($context) => $context === 'create'),
+ ->label(trans('ip.email_verified_at'))
+ ->prefixIcon(Heroicon::OutlinedCalendar),
]),
]),
]);
diff --git a/Modules/Core/Filament/Admin/Resources/Users/Tables/UsersTable.php b/Modules/Core/Filament/Admin/Resources/Users/Tables/UsersTable.php
index 9e532d199..58b242112 100644
--- a/Modules/Core/Filament/Admin/Resources/Users/Tables/UsersTable.php
+++ b/Modules/Core/Filament/Admin/Resources/Users/Tables/UsersTable.php
@@ -10,6 +10,7 @@
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Collection;
+use Modules\Core\Filament\Admin\Resources\Users\UserResource;
use Modules\Core\Models\User;
use Modules\Core\Services\UserService;
@@ -43,9 +44,10 @@ public static function configure(Table $table): Table
])
->recordActions([
ActionGroup::make([
- EditAction::make()->action(function (User $record, array $data) {
- app(UserService::class)->updateUser($record, $data);
- })->modalWidth('full'),
+ EditAction::make()
+ ->url(fn (User $record): string => UserResource::getUrl('edit', [
+ 'record' => $record,
+ ])),
DeleteAction::make('delete')
->hidden(fn (User $record): bool => $record->isSuperAdmin())
->action(function (User $record, array $data) {
diff --git a/Modules/Core/Filament/Admin/Resources/Users/UserResource.php b/Modules/Core/Filament/Admin/Resources/Users/UserResource.php
index 5002ba2b3..3af347de7 100644
--- a/Modules/Core/Filament/Admin/Resources/Users/UserResource.php
+++ b/Modules/Core/Filament/Admin/Resources/Users/UserResource.php
@@ -9,6 +9,8 @@
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Model;
use Modules\Core\Enums\Permission;
+use Modules\Core\Filament\Admin\Resources\Users\Pages\CreateUser;
+use Modules\Core\Filament\Admin\Resources\Users\Pages\EditUser;
use Modules\Core\Filament\Admin\Resources\Users\Pages\ListUsers;
use Modules\Core\Filament\Admin\Resources\Users\Schemas\UserForm;
use Modules\Core\Filament\Admin\Resources\Users\Tables\UsersTable;
@@ -54,7 +56,9 @@ public static function getRelations(): array
public static function getPages(): array
{
return [
- 'index' => ListUsers::route('/'),
+ 'index' => ListUsers::route('/'),
+ 'create' => CreateUser::route('/create'),
+ 'edit' => EditUser::route('/{record}/edit'),
];
}
diff --git a/Modules/Core/Filament/Company/Resources/EmailTemplates/Pages/ListEmailTemplates.php b/Modules/Core/Filament/Company/Resources/EmailTemplates/Pages/ListEmailTemplates.php
index 2bf98b15b..9c3026ec6 100644
--- a/Modules/Core/Filament/Company/Resources/EmailTemplates/Pages/ListEmailTemplates.php
+++ b/Modules/Core/Filament/Company/Resources/EmailTemplates/Pages/ListEmailTemplates.php
@@ -13,7 +13,8 @@ class ListEmailTemplates extends ListRecords
protected function getHeaderActions(): array
{
return [
- CreateAction::make(),
+ CreateAction::make()
+ ->url(fn (): string => EmailTemplateResource::getUrl('create')),
];
}
}
diff --git a/Modules/Core/Filament/Company/Resources/NoteTemplates/NoteTemplateResource.php b/Modules/Core/Filament/Company/Resources/NoteTemplates/NoteTemplateResource.php
index cc207bf7c..bd3a9a567 100644
--- a/Modules/Core/Filament/Company/Resources/NoteTemplates/NoteTemplateResource.php
+++ b/Modules/Core/Filament/Company/Resources/NoteTemplates/NoteTemplateResource.php
@@ -9,6 +9,8 @@
use Illuminate\Database\Eloquent\Model;
use Modules\Core\Enums\Permission;
use Modules\Core\Filament\Company\Resources\BaseResource;
+use Modules\Core\Filament\Company\Resources\NoteTemplates\Pages\CreateNoteTemplate;
+use Modules\Core\Filament\Company\Resources\NoteTemplates\Pages\EditNoteTemplate;
use Modules\Core\Filament\Company\Resources\NoteTemplates\Pages\ListNoteTemplates;
use Modules\Core\Filament\Company\Resources\NoteTemplates\Schemas\NoteTemplateForm;
use Modules\Core\Filament\Company\Resources\NoteTemplates\Tables\NoteTemplatesTable;
@@ -42,7 +44,9 @@ public static function getRelations(): array
public static function getPages(): array
{
return [
- 'index' => ListNoteTemplates::route('/'),
+ 'index' => ListNoteTemplates::route('/'),
+ 'create' => CreateNoteTemplate::route('/create'),
+ 'edit' => EditNoteTemplate::route('/{record}/edit'),
];
}
diff --git a/Modules/Core/Filament/Company/Resources/NoteTemplates/Pages/CreateNoteTemplate.php b/Modules/Core/Filament/Company/Resources/NoteTemplates/Pages/CreateNoteTemplate.php
new file mode 100644
index 000000000..9d38c4312
--- /dev/null
+++ b/Modules/Core/Filament/Company/Resources/NoteTemplates/Pages/CreateNoteTemplate.php
@@ -0,0 +1,47 @@
+authorizeAccess();
+
+ $this->callHook('beforeValidate');
+ $data = $this->form->getState();
+ $this->callHook('afterValidate');
+
+ $data = $this->mutateFormDataBeforeCreate($data);
+ $this->callHook('beforeCreate');
+
+ $this->record = $this->handleRecordCreation($data);
+
+ $this->callHook('afterCreate');
+ $this->rememberData();
+
+ $this->getCreatedNotification()?->send();
+
+ if ($another) {
+ $this->form->model($this->getRecord()::class);
+ $this->record = null;
+ $this->fillForm();
+
+ return;
+ }
+
+ $this->redirect($this->getRedirectUrl());
+ }
+
+ protected function handleRecordCreation(array $data): Model
+ {
+ return app(NoteTemplateService::class)->createNoteTemplate($data);
+ }
+}
diff --git a/Modules/Core/Filament/Company/Resources/NoteTemplates/Pages/EditNoteTemplate.php b/Modules/Core/Filament/Company/Resources/NoteTemplates/Pages/EditNoteTemplate.php
new file mode 100644
index 000000000..c2c957209
--- /dev/null
+++ b/Modules/Core/Filament/Company/Resources/NoteTemplates/Pages/EditNoteTemplate.php
@@ -0,0 +1,50 @@
+authorizeAccess();
+
+ $this->callHook('beforeValidate');
+ $data = $this->form->getState();
+ $this->callHook('afterValidate');
+
+ $data = $this->mutateFormDataBeforeSave($data);
+ $this->callHook('beforeSave');
+
+ $this->record = $this->handleRecordUpdate($this->getRecord(), $data);
+
+ $this->callHook('afterSave');
+
+ if ($shouldSendSavedNotification) {
+ $this->getSavedNotification()?->send();
+ }
+
+ if ($shouldRedirect) {
+ $this->redirect($this->getRedirectUrl());
+ }
+ }
+
+ protected function getHeaderActions(): array
+ {
+ return [
+ DeleteAction::make(),
+ ];
+ }
+
+ protected function handleRecordUpdate(Model $record, array $data): Model
+ {
+ return app(NoteTemplateService::class)->updateNoteTemplate($record, $data);
+ }
+}
diff --git a/Modules/Core/Filament/Company/Resources/NoteTemplates/Pages/ListNoteTemplates.php b/Modules/Core/Filament/Company/Resources/NoteTemplates/Pages/ListNoteTemplates.php
index 421ea029a..8eed770df 100644
--- a/Modules/Core/Filament/Company/Resources/NoteTemplates/Pages/ListNoteTemplates.php
+++ b/Modules/Core/Filament/Company/Resources/NoteTemplates/Pages/ListNoteTemplates.php
@@ -5,7 +5,6 @@
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
use Modules\Core\Filament\Company\Resources\NoteTemplates\NoteTemplateResource;
-use Modules\Core\Services\NoteTemplateService;
class ListNoteTemplates extends ListRecords
{
@@ -15,10 +14,7 @@ protected function getHeaderActions(): array
{
return [
CreateAction::make()
- ->action(function (array $data) {
- app(NoteTemplateService::class)->createNoteTemplate($data);
- })
- ->modalWidth('full'),
+ ->url(fn (): string => NoteTemplateResource::getUrl('create')),
];
}
}
diff --git a/Modules/Core/Filament/Company/Resources/NoteTemplates/Schemas/NoteTemplateForm.php b/Modules/Core/Filament/Company/Resources/NoteTemplates/Schemas/NoteTemplateForm.php
index 8eff99acd..bf4f3d9c9 100644
--- a/Modules/Core/Filament/Company/Resources/NoteTemplates/Schemas/NoteTemplateForm.php
+++ b/Modules/Core/Filament/Company/Resources/NoteTemplates/Schemas/NoteTemplateForm.php
@@ -6,6 +6,7 @@
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
+use Filament\Support\Icons\Heroicon;
class NoteTemplateForm
{
@@ -13,10 +14,13 @@ public static function configure(Schema $schema): Schema
{
return $schema
->components([
- Section::make('Template')
+ Section::make(trans('ip.template'))
+ ->icon(Heroicon::OutlinedDocumentText)
+ ->columnSpanFull()
->schema([
TextInput::make('template_title')
->label(trans('ip.title'))
+ ->prefixIcon(Heroicon::OutlinedTag)
->required()
->maxLength(255),
diff --git a/Modules/Core/Filament/Company/Resources/NoteTemplates/Tables/NoteTemplatesTable.php b/Modules/Core/Filament/Company/Resources/NoteTemplates/Tables/NoteTemplatesTable.php
index 895fd899a..c444814f0 100644
--- a/Modules/Core/Filament/Company/Resources/NoteTemplates/Tables/NoteTemplatesTable.php
+++ b/Modules/Core/Filament/Company/Resources/NoteTemplates/Tables/NoteTemplatesTable.php
@@ -8,6 +8,7 @@
use Filament\Actions\EditAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
+use Modules\Core\Filament\Company\Resources\NoteTemplates\NoteTemplateResource;
use Modules\Core\Models\NoteTemplate;
use Modules\Core\Services\NoteTemplateService;
@@ -30,10 +31,9 @@ public static function configure(Table $table): Table
->defaultSort('template_title', 'asc')
->recordActions([
EditAction::make()
- ->action(function (NoteTemplate $record, array $data) {
- app(NoteTemplateService::class)->updateNoteTemplate($record, $data);
- })
- ->modalWidth('full'),
+ ->url(fn (NoteTemplate $record): string => NoteTemplateResource::getUrl('edit', [
+ 'record' => $record,
+ ])),
DeleteAction::make()
->action(function (NoteTemplate $record, array $data) {
app(NoteTemplateService::class)->deleteNoteTemplate($record, $data);
diff --git a/Modules/Core/Filament/Company/Resources/TaxRates/Pages/CreateTaxRate.php b/Modules/Core/Filament/Company/Resources/TaxRates/Pages/CreateTaxRate.php
new file mode 100644
index 000000000..a85bff8dd
--- /dev/null
+++ b/Modules/Core/Filament/Company/Resources/TaxRates/Pages/CreateTaxRate.php
@@ -0,0 +1,47 @@
+authorizeAccess();
+
+ $this->callHook('beforeValidate');
+ $data = $this->form->getState();
+ $this->callHook('afterValidate');
+
+ $data = $this->mutateFormDataBeforeCreate($data);
+ $this->callHook('beforeCreate');
+
+ $this->record = $this->handleRecordCreation($data);
+
+ $this->callHook('afterCreate');
+ $this->rememberData();
+
+ $this->getCreatedNotification()?->send();
+
+ if ($another) {
+ $this->form->model($this->getRecord()::class);
+ $this->record = null;
+ $this->fillForm();
+
+ return;
+ }
+
+ $this->redirect($this->getRedirectUrl());
+ }
+
+ protected function handleRecordCreation(array $data): Model
+ {
+ return app(TaxRateService::class)->createTaxRate($data);
+ }
+}
diff --git a/Modules/Core/Filament/Company/Resources/TaxRates/Pages/EditTaxRate.php b/Modules/Core/Filament/Company/Resources/TaxRates/Pages/EditTaxRate.php
new file mode 100644
index 000000000..a66e40f05
--- /dev/null
+++ b/Modules/Core/Filament/Company/Resources/TaxRates/Pages/EditTaxRate.php
@@ -0,0 +1,50 @@
+authorizeAccess();
+
+ $this->callHook('beforeValidate');
+ $data = $this->form->getState();
+ $this->callHook('afterValidate');
+
+ $data = $this->mutateFormDataBeforeSave($data);
+ $this->callHook('beforeSave');
+
+ $this->record = $this->handleRecordUpdate($this->getRecord(), $data);
+
+ $this->callHook('afterSave');
+
+ if ($shouldSendSavedNotification) {
+ $this->getSavedNotification()?->send();
+ }
+
+ if ($shouldRedirect) {
+ $this->redirect($this->getRedirectUrl());
+ }
+ }
+
+ protected function getHeaderActions(): array
+ {
+ return [
+ DeleteAction::make(),
+ ];
+ }
+
+ protected function handleRecordUpdate(Model $record, array $data): Model
+ {
+ return app(TaxRateService::class)->updateTaxRate($record, $data);
+ }
+}
diff --git a/Modules/Core/Filament/Company/Resources/TaxRates/Pages/ListTaxRates.php b/Modules/Core/Filament/Company/Resources/TaxRates/Pages/ListTaxRates.php
new file mode 100644
index 000000000..bb73d8feb
--- /dev/null
+++ b/Modules/Core/Filament/Company/Resources/TaxRates/Pages/ListTaxRates.php
@@ -0,0 +1,20 @@
+url(fn (): string => TaxRateResource::getUrl('create')),
+ ];
+ }
+}
diff --git a/Modules/Core/Filament/Company/Resources/TaxRates/Schemas/TaxRateForm.php b/Modules/Core/Filament/Company/Resources/TaxRates/Schemas/TaxRateForm.php
new file mode 100644
index 000000000..7fb05bffd
--- /dev/null
+++ b/Modules/Core/Filament/Company/Resources/TaxRates/Schemas/TaxRateForm.php
@@ -0,0 +1,84 @@
+components([
+ Grid::make(3)
+ ->columnSpanFull()
+ ->schema([
+ Section::make(trans('ip.basic_information'))
+ ->icon(Heroicon::OutlinedIdentification)
+ ->columnSpan(2)
+ ->schema([
+ Grid::make(4)
+ ->schema([
+ TextInput::make('name')
+ ->label(trans('ip.name'))
+ ->prefixIcon(Heroicon::OutlinedTag)
+ ->required()
+ ->autofocus()
+ ->columnSpan(4),
+
+ TextInput::make('code')
+ ->label(trans('ip.tax_rate_code'))
+ ->prefixIcon(Heroicon::OutlinedHashtag)
+ // tax_rates.code is NOT NULL with
+ // no DB default — leaving this
+ // blank passes client validation
+ // and blows up as an unhandled
+ // SQLSTATE 500 on submission.
+ ->required()
+ ->unique(ignoreRecord: true)
+ ->columnSpan(2),
+
+ Toggle::make('is_active')
+ ->label(trans('ip.is_active'))
+ ->default(true)
+ ->columnSpan(2),
+ ]),
+ ]),
+
+ Section::make(trans('ip.details'))
+ ->icon(Heroicon::OutlinedReceiptPercent)
+ ->columnSpan(1)
+ ->schema([
+ Select::make('tax_rate_type')
+ ->label(trans('ip.tax_rate_type'))
+ ->prefixIcon(Heroicon::OutlinedListBullet)
+ ->options(
+ collect(TaxRateType::cases())
+ ->mapWithKeys(fn (TaxRateType $type) => [
+ $type->value => trans($type->label()),
+ ])
+ ->toArray()
+ )
+ ->required()
+ ->searchable()
+ ->preload()
+ ->native(false),
+
+ TextInput::make('rate')
+ ->label(trans('ip.percentage'))
+ ->prefixIcon(Heroicon::OutlinedReceiptPercent)
+ ->required()
+ ->numeric()
+ ->step(0.01),
+ ]),
+ ]),
+ ]);
+ }
+}
diff --git a/Modules/Core/Filament/Company/Resources/TaxRates/Tables/TaxRatesTable.php b/Modules/Core/Filament/Company/Resources/TaxRates/Tables/TaxRatesTable.php
new file mode 100644
index 000000000..ab28f26c6
--- /dev/null
+++ b/Modules/Core/Filament/Company/Resources/TaxRates/Tables/TaxRatesTable.php
@@ -0,0 +1,77 @@
+columns([
+ TextColumn::make('tax_rate_type')
+ ->formatStateUsing(function ($state) {
+ $status = EnumHelper::safeEnum(TaxRateType::class, $state);
+
+ return $status?->label() ?? '-';
+ })
+ ->searchable()
+ ->sortable()
+ ->toggleable(),
+ IconColumn::make('is_active')
+ ->boolean()
+ ->searchable()
+ ->sortable()
+ ->toggleable(),
+ TextColumn::make('name')
+ ->label(trans('ip.name'))
+ ->limit(10)
+ ->searchable()->sortable()->toggleable(),
+ TextColumn::make('code')
+ ->label(trans('ip.code'))
+ ->searchable()->sortable()->toggleable(),
+ IconColumn::make('is_compound')
+ ->label(trans('ip.is_compound'))
+ ->boolean()->searchable()->sortable()->toggleable(),
+ IconColumn::make('calculate_vat')
+ ->label(trans('ip.calculate_vat'))
+ ->boolean()->searchable()->sortable()->toggleable(),
+ TextColumn::make('rate')
+ ->label(trans('ip.percentage'))
+ ->numeric()
+ ->searchable()->sortable()->toggleable(),
+ ])
+ ->filters([])
+ ->recordActions([
+ ActionGroup::make([
+ EditAction::make()
+ ->url(fn (TaxRate $record): string => TaxRateResource::getUrl('edit', [
+ 'record' => $record,
+ ])),
+ DeleteAction::make('delete')
+ ->action(function (TaxRate $record, array $data) {
+ app(TaxRateService::class)->deleteTaxRate($record);
+ }),
+ ]),
+ ])
+ ->toolbarActions([
+ BulkActionGroup::make([
+ DeleteBulkAction::make(),
+ ]),
+ ])
+ ->defaultSort('name', 'asc');
+ }
+}
diff --git a/Modules/Core/Filament/Company/Resources/TaxRates/TaxRateResource.php b/Modules/Core/Filament/Company/Resources/TaxRates/TaxRateResource.php
new file mode 100644
index 000000000..3f1e321ae
--- /dev/null
+++ b/Modules/Core/Filament/Company/Resources/TaxRates/TaxRateResource.php
@@ -0,0 +1,77 @@
+ ListTaxRates::route('/'),
+ 'create' => CreateTaxRate::route('/create'),
+ 'edit' => EditTaxRate::route('/{record}/edit'),
+ ];
+ }
+
+ public static function canViewAny(): bool
+ {
+ return auth()->user()?->can(Permission::VIEW_TAX_RATES->value) ?? false;
+ }
+
+ public static function canCreate(): bool
+ {
+ return auth()->user()?->can(Permission::CREATE_TAX_RATES->value) ?? false;
+ }
+
+ public static function canView(Model $record): bool
+ {
+ return auth()->user()?->can(Permission::VIEW_TAX_RATES->value) ?? false;
+ }
+
+ public static function canEdit(Model $record): bool
+ {
+ return auth()->user()?->can(Permission::EDIT_TAX_RATES->value) ?? false;
+ }
+
+ public static function canDelete(Model $record): bool
+ {
+ return auth()->user()?->can(Permission::DELETE_TAX_RATES->value) ?? false;
+ }
+}
diff --git a/Modules/Core/Providers/AdminPanelProvider.php b/Modules/Core/Providers/AdminPanelProvider.php
index f6c260109..7dcec5f52 100644
--- a/Modules/Core/Providers/AdminPanelProvider.php
+++ b/Modules/Core/Providers/AdminPanelProvider.php
@@ -12,7 +12,10 @@
use Filament\Navigation\NavigationGroup;
use Filament\Panel;
use Filament\PanelProvider;
+use Filament\Support\Colors\Color;
use Filament\Support\Enums\Width;
+use Filament\Support\Icons\Heroicon;
+use Filament\View\PanelsIconAlias;
use Filament\Widgets\AccountWidget;
use Filament\Widgets\FilamentInfoWidget;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
@@ -36,72 +39,64 @@ class AdminPanelProvider extends PanelProvider
{
public function panel(Panel $panel): Panel
{
+ $brand = [
+ 50 => '#f0f8fd',
+ 100 => '#deecf9',
+ 200 => '#bfdcf3',
+ 300 => '#8ec4ea',
+ 400 => '#57a4dd',
+ 500 => '#2589d0',
+ 600 => '#0078d7',
+ 700 => '#005a9e',
+ 800 => '#00477d',
+ 900 => '#093a63',
+ 950 => '#062544',
+ ];
+
+ $gray = [
+ 50 => '#f8f9f9',
+ 100 => '#f3f4f5',
+ 200 => '#e8eaec',
+ 300 => '#d7dade',
+ 400 => '#b6bbc2',
+ 500 => '#8b919b',
+ 600 => '#6b717a',
+ 700 => '#52575f',
+ 800 => '#383b41',
+ 900 => '#202226',
+ 950 => '#131417',
+ ];
+
return $panel
->id('admin')
->path('admin')
- ->viteTheme('resources/css/filament/company/nord.css')
->login(Login::class)
->profile(EditProfile::class, isSimple: false)
->passwordReset()
->emailVerification()
->maxContentWidth(Width::Full)
+ ->viteTheme('resources/css/filament/theme.css')
+ ->topbar(false)
+ ->icons([
+ PanelsIconAlias::SIDEBAR_EXPAND_BUTTON => Heroicon::OutlinedBars3,
+ PanelsIconAlias::SIDEBAR_EXPAND_BUTTON_RTL => Heroicon::OutlinedBars3,
+ PanelsIconAlias::SIDEBAR_COLLAPSE_BUTTON => Heroicon::OutlinedChevronLeft,
+ PanelsIconAlias::SIDEBAR_COLLAPSE_BUTTON_RTL => Heroicon::OutlinedChevronRight,
+ ])
->font(
'Poppins',
provider: GoogleFontProvider::class,
)
->colors([
- 'primary' => [
- 50 => '#F2F7FD',
- 100 => '#E3EFFB',
- 200 => '#C1DFF6',
- 300 => '#8FC0EE',
- 400 => '#429AE1',
- 500 => '#2684D1',
- 600 => '#1868B1',
- 700 => '#145390',
- 800 => '#154777',
- 900 => '#173C63',
- 950 => '#0F2742',
- ],
- 'curious' => [
- 50 => '#F2F7FD',
- 100 => '#E3EFFB',
- 200 => '#C1DFF6',
- 300 => '#8FC0EE',
- 400 => '#429AE1',
- 500 => '#2684D1',
- 600 => '#1868B1',
- 700 => '#145390',
- 800 => '#154777',
- 900 => '#113153',
- 950 => '#0F2742',
- ],
- 'darkious' => [
- 50 => '#CCE0FF',
- 100 => '#99B3EB',
- 200 => '#6696D6',
- 300 => '#2D6BB8',
- 400 => '#004DB8',
- 500 => '#003F99',
- 600 => '#002F7A',
- 700 => '#00265F',
- 800 => '#00204F',
- 900 => '#001B3E',
- 950 => '#00102B',
- ],
- 'emerald' => [
- 50 => '#ECFDF5',
- 100 => '#D1F8E4',
- 200 => '#A8ECCD',
- 300 => '#6FD9AE',
- 400 => '#3CBF8A',
- 500 => '#30A46B',
- 600 => '#258651',
- 700 => '#1D6840',
- 800 => '#165231',
- 900 => '#0F3E25',
- 950 => '#0A2917',
- ],
+ 'primary' => $brand,
+ 'info' => $brand,
+ 'gray' => $gray,
+ 'danger' => Color::hex('#D13438'), // Fluent red
+ 'warning' => Color::hex('#FFB900'), // Fluent gold
+ 'success' => Color::hex('#107C10'), // Fluent green
+ 'emerald' => Color::hex('#8FBCBB'), // Nord Frost teal (nord7) — used by status badges
+ 'maroon' => Color::hex('#8F3D42'), // Darkened Nord Aurora red — used by status badges
+ 'green' => Color::hex('#A3BE8C'), // Nord Aurora green (nord14) — used by status badges
])
->pages([
Dashboard::class,
diff --git a/Modules/Core/Providers/CompanyPanelProvider.php b/Modules/Core/Providers/CompanyPanelProvider.php
index 506e88438..0962fbbce 100644
--- a/Modules/Core/Providers/CompanyPanelProvider.php
+++ b/Modules/Core/Providers/CompanyPanelProvider.php
@@ -13,7 +13,10 @@
use Filament\Navigation\NavigationItem;
use Filament\Panel;
use Filament\PanelProvider;
+use Filament\Support\Colors\Color;
use Filament\Support\Enums\Width;
+use Filament\Support\Icons\Heroicon;
+use Filament\View\PanelsIconAlias;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
@@ -31,6 +34,7 @@
use Modules\Core\Filament\Company\Resources\CompanyUsers\CompanyUserResource;
use Modules\Core\Filament\Company\Resources\EmailTemplates\EmailTemplateResource;
use Modules\Core\Filament\Company\Resources\NoteTemplates\NoteTemplateResource;
+use Modules\Core\Filament\Company\Resources\TaxRates\TaxRateResource;
use Modules\Core\Filament\Pages\Auth\Login;
use Modules\Core\Http\Middleware\ConfigureTenant;
use Modules\Core\Http\Middleware\EnsureUserCanAccessCompany;
@@ -53,6 +57,34 @@ class CompanyPanelProvider extends PanelProvider
{
public function panel(Panel $panel): Panel
{
+ $brand = [
+ 50 => '#f0f8fd',
+ 100 => '#deecf9',
+ 200 => '#bfdcf3',
+ 300 => '#8ec4ea',
+ 400 => '#57a4dd',
+ 500 => '#2589d0',
+ 600 => '#0078d7',
+ 700 => '#005a9e',
+ 800 => '#00477d',
+ 900 => '#093a63',
+ 950 => '#062544',
+ ];
+
+ $gray = [
+ 50 => '#f8f9f9',
+ 100 => '#f3f4f5',
+ 200 => '#e8eaec',
+ 300 => '#d7dade',
+ 400 => '#b6bbc2',
+ 500 => '#8b919b',
+ 600 => '#6b717a',
+ 700 => '#52575f',
+ 800 => '#383b41',
+ 900 => '#202226',
+ 950 => '#131417',
+ ];
+
/** @var Panel $companyPanel */
$companyPanel = $panel
// #region Panel Configuration
@@ -60,13 +92,31 @@ public function panel(Panel $panel): Panel
->default()
->id('company')
->path('')
- ->viteTheme('resources/css/filament/company/nord.css')
->login(Login::class)
->passwordReset()
->emailVerification()
->emailChangeVerification()
->maxContentWidth(Width::Full)
+ ->viteTheme('resources/css/filament/theme.css')
+ ->topbar(false)
+ ->icons([
+ PanelsIconAlias::SIDEBAR_EXPAND_BUTTON => Heroicon::OutlinedBars3,
+ PanelsIconAlias::SIDEBAR_EXPAND_BUTTON_RTL => Heroicon::OutlinedBars3,
+ PanelsIconAlias::SIDEBAR_COLLAPSE_BUTTON => Heroicon::OutlinedChevronLeft,
+ PanelsIconAlias::SIDEBAR_COLLAPSE_BUTTON_RTL => Heroicon::OutlinedChevronRight,
+ ])
->font('Poppins', provider: GoogleFontProvider::class)
+ ->colors([
+ 'primary' => $brand,
+ 'info' => $brand,
+ 'gray' => $gray,
+ 'danger' => Color::hex('#D13438'), // Fluent red
+ 'warning' => Color::hex('#FFB900'), // Fluent gold
+ 'success' => Color::hex('#107C10'), // Fluent green
+ 'emerald' => Color::hex('#8FBCBB'), // Nord Frost teal (nord7) — used by status badges
+ 'maroon' => Color::hex('#8F3D42'), // Darkened Nord Aurora red — used by status badges
+ 'green' => Color::hex('#A3BE8C'), // Nord Aurora green (nord14) — used by status badges
+ ])
->unsavedChangesAlerts()
->sidebarCollapsibleOnDesktop()
->tenantMenu(false)
@@ -106,60 +156,6 @@ public function panel(Panel $panel): Panel
Authenticate::class,
])
- ->colors([
- 'primary' => [
- 50 => '#F2F7FD',
- 100 => '#E3EFFB',
- 200 => '#C1DFF6',
- 300 => '#8FC0EE',
- 400 => '#429AE1',
- 500 => '#2684D1',
- 600 => '#1868B1',
- 700 => '#145390',
- 800 => '#154777',
- 900 => '#173C63',
- 950 => '#0F2742',
- ],
- 'curious' => [
- 50 => '#F2F7FD',
- 100 => '#E3EFFB',
- 200 => '#C1DFF6',
- 300 => '#8FC0EE',
- 400 => '#429AE1',
- 500 => '#2684D1',
- 600 => '#1868B1',
- 700 => '#145390',
- 800 => '#154777',
- 900 => '#113153',
- 950 => '#0F2742',
- ],
- 'darkious' => [
- 50 => '#CCE0FF',
- 100 => '#99B3EB',
- 200 => '#6696D6',
- 300 => '#2D6BB8',
- 400 => '#004DB8',
- 500 => '#003F99',
- 600 => '#002F7A',
- 700 => '#00265F',
- 800 => '#00204F',
- 900 => '#001B3E',
- 950 => '#00102B',
- ],
- 'emerald' => [
- 50 => '#ECFDF5',
- 100 => '#D1F8E4',
- 200 => '#A8ECCD',
- 300 => '#6FD9AE',
- 400 => '#3CBF8A',
- 500 => '#30A46B',
- 600 => '#258651',
- 700 => '#1D6840',
- 800 => '#165231',
- 900 => '#0F3E25',
- 950 => '#0A2917',
- ],
- ])
->unsavedChangesAlerts()
->sidebarCollapsibleOnDesktop()
->resources([
@@ -178,6 +174,7 @@ public function panel(Panel $panel): Panel
NoteTemplateResource::class,
EmailTemplateResource::class,
CompanyUserResource::class,
+ TaxRateResource::class,
])
->discoverPages(in: app_path('Filament/Company/Pages'), for: 'App\Filament\Company\Pages')
->discoverWidgets(in: app_path('Filament/Company/Widgets'), for: 'App\Filament\Company\Widgets')
@@ -254,6 +251,7 @@ public function panel(Panel $panel): Panel
...NoteTemplateResource::getNavigationItems(),
...EmailTemplateResource::getNavigationItems(),
...CompanyUserResource::getNavigationItems(),
+ ...(TaxRateResource::shouldRegisterNavigation() ? TaxRateResource::getNavigationItems() : []),
]),
]);
})
diff --git a/Modules/Core/Providers/UserPanelProvider.php b/Modules/Core/Providers/UserPanelProvider.php
index e646492a3..46a767d48 100644
--- a/Modules/Core/Providers/UserPanelProvider.php
+++ b/Modules/Core/Providers/UserPanelProvider.php
@@ -10,6 +10,9 @@
use Filament\Navigation\MenuItem;
use Filament\Panel;
use Filament\PanelProvider;
+use Filament\Support\Colors\Color;
+use Filament\Support\Icons\Heroicon;
+use Filament\View\PanelsIconAlias;
use Filament\Widgets\AccountWidget;
use Filament\Widgets\FilamentInfoWidget;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
@@ -23,70 +26,62 @@ class UserPanelProvider extends PanelProvider
{
public function panel(Panel $panel): Panel
{
+ $brand = [
+ 50 => '#f0f8fd',
+ 100 => '#deecf9',
+ 200 => '#bfdcf3',
+ 300 => '#8ec4ea',
+ 400 => '#57a4dd',
+ 500 => '#2589d0',
+ 600 => '#0078d7',
+ 700 => '#005a9e',
+ 800 => '#00477d',
+ 900 => '#093a63',
+ 950 => '#062544',
+ ];
+
+ $gray = [
+ 50 => '#f8f9f9',
+ 100 => '#f3f4f5',
+ 200 => '#e8eaec',
+ 300 => '#d7dade',
+ 400 => '#b6bbc2',
+ 500 => '#8b919b',
+ 600 => '#6b717a',
+ 700 => '#52575f',
+ 800 => '#383b41',
+ 900 => '#202226',
+ 950 => '#131417',
+ ];
+
return $panel
->id('user')
->path('user')
- ->viteTheme('resources/css/filament/company/nord.css')
->login()
->passwordReset()
->emailVerification()
+ ->viteTheme('resources/css/filament/theme.css')
+ ->topbar(false)
+ ->icons([
+ PanelsIconAlias::SIDEBAR_EXPAND_BUTTON => Heroicon::OutlinedBars3,
+ PanelsIconAlias::SIDEBAR_EXPAND_BUTTON_RTL => Heroicon::OutlinedBars3,
+ PanelsIconAlias::SIDEBAR_COLLAPSE_BUTTON => Heroicon::OutlinedChevronLeft,
+ PanelsIconAlias::SIDEBAR_COLLAPSE_BUTTON_RTL => Heroicon::OutlinedChevronRight,
+ ])
->font(
'Poppins',
provider: GoogleFontProvider::class,
)
->colors([
- 'primary' => [
- 50 => '#F2F7FD',
- 100 => '#E3EFFB',
- 200 => '#C1DFF6',
- 300 => '#8FC0EE',
- 400 => '#429AE1',
- 500 => '#2684D1',
- 600 => '#1868B1',
- 700 => '#145390',
- 800 => '#154777',
- 900 => '#173C63',
- 950 => '#0F2742',
- ],
- 'curious' => [
- 50 => '#F2F7FD',
- 100 => '#E3EFFB',
- 200 => '#C1DFF6',
- 300 => '#8FC0EE',
- 400 => '#429AE1',
- 500 => '#2684D1',
- 600 => '#1868B1',
- 700 => '#145390',
- 800 => '#154777',
- 900 => '#113153',
- 950 => '#0F2742',
- ],
- 'darkious' => [
- 50 => '#CCE0FF',
- 100 => '#99B3EB',
- 200 => '#6696D6',
- 300 => '#2D6BB8',
- 400 => '#004DB8',
- 500 => '#003F99',
- 600 => '#002F7A',
- 700 => '#00265F',
- 800 => '#00204F',
- 900 => '#001B3E',
- 950 => '#00102B',
- ],
- 'emerald' => [
- 50 => '#ECFDF5',
- 100 => '#D1F8E4',
- 200 => '#A8ECCD',
- 300 => '#6FD9AE',
- 400 => '#3CBF8A',
- 500 => '#30A46B',
- 600 => '#258651',
- 700 => '#1D6840',
- 800 => '#165231',
- 900 => '#0F3E25',
- 950 => '#0A2917',
- ],
+ 'primary' => $brand,
+ 'info' => $brand,
+ 'gray' => $gray,
+ 'danger' => Color::hex('#D13438'), // Fluent red
+ 'warning' => Color::hex('#FFB900'), // Fluent gold
+ 'success' => Color::hex('#107C10'), // Fluent green
+ 'emerald' => Color::hex('#8FBCBB'), // Nord Frost teal (nord7) — used by status badges
+ 'maroon' => Color::hex('#8F3D42'), // Darkened Nord Aurora red — used by status badges
+ 'green' => Color::hex('#A3BE8C'), // Nord Aurora green (nord14) — used by status badges
])
->unsavedChangesAlerts()
->sidebarCollapsibleOnDesktop()
diff --git a/Modules/Core/Tests/Feature/CompaniesTest.php b/Modules/Core/Tests/Feature/CompaniesTest.php
index ca642ae74..e7d51bea4 100644
--- a/Modules/Core/Tests/Feature/CompaniesTest.php
+++ b/Modules/Core/Tests/Feature/CompaniesTest.php
@@ -2,7 +2,6 @@
namespace Modules\Core\Tests\Feature;
-use Filament\Actions\Testing\TestAction;
use Livewire\Livewire;
use Modules\Core\Filament\Admin\Resources\Companies\Pages\CreateCompany;
use Modules\Core\Filament\Admin\Resources\Companies\Pages\EditCompany;
@@ -39,84 +38,10 @@ public function it_lists_companies(): void
}
# endregion
- # region modals
- #[Test]
- #[Group('modals')]
- public function it_creates_a_company_through_a_modal(): void
- {
- /* Arrange */
- $payload = [
- 'search_code' => 'IVPLV2',
- 'name' => 'InvoicePlane LLC',
- 'slug' => 'invoiceplane_llc',
- ];
-
- /* Act */
- $component = Livewire::actingAs($this->superAdmin())
- ->test(ListCompanies::class)
- ->mountAction('create')
- ->fillForm($payload)
- ->callMountedAction();
-
- /* Assert */
- $component->assertSuccessful();
- $component->assertHasNoFormErrors();
- $this->assertDatabaseHas('companies', $payload);
- }
-
- #[Test]
- #[Group('crud')]
- /**
- * @payload {
- * "name": "InvoicePlane Corp"
- * }
- */
- public function it_fails_to_create_company_through_a_modal_without_required_search_code(): void
- {
- /* Arrange */
- $payload = ['name' => 'InvoicePlane Corp'];
-
- /* act & assert */
- Livewire::actingAs($this->superAdmin())
- ->test(ListCompanies::class)
- ->mountAction('create')
- ->fillForm($payload)
- ->callMountedAction()
- ->assertHasFormErrors(['search_code' => 'required']);
-
- $this->assertDatabaseMissing('companies', $payload);
- }
-
- #[Test]
- #[Group('crud')]
- /**
- * @payload {
- * "search_code": "IVPLV2",
- * "slug": "slug_should_be_generated"
- * }
- */
- public function it_fails_to_create_company_through_a_modal_without_required_name(): void
- {
- /* Arrange */
- $payload = [
- 'search_code' => 'IVPLV2',
- 'slug' => 'slug_should_be_generated',
- ];
-
- /* act & assert */
- Livewire::actingAs($this->superAdmin())
- ->test(ListCompanies::class)
- ->mountAction('create')
- ->fillForm($payload)
- ->callMountedAction()
- ->assertHasFormErrors(['name' => 'required']);
-
- $this->assertDatabaseMissing('companies', $payload);
- }
-
+ # region crud
#[Test]
#[Group('crud')]
- public function it_fails_to_create_company_through_a_modal_when_search_code_exceeds_max_length(): void
+ public function it_fails_to_create_company_when_search_code_exceeds_max_length(): void
{
/* Arrange — regression guard: companies.search_code is varchar(10);
* without ->maxLength(10) on the form field, a longer value passed
@@ -128,20 +53,19 @@ public function it_fails_to_create_company_through_a_modal_when_search_code_exce
];
/* Act */
- Livewire::actingAs($this->superAdmin())
- ->test(ListCompanies::class)
- ->mountAction('create')
+ $component = Livewire::actingAs($this->superAdmin())
+ ->test(CreateCompany::class)
->fillForm($payload)
- ->callMountedAction()
- ->assertHasFormErrors(['search_code']);
+ ->call('create');
/* Assert */
+ $component->assertHasFormErrors(['search_code']);
$this->assertDatabaseMissing('companies', ['name' => $payload['name']]);
}
#[Test]
#[Group('crud')]
- public function it_fails_to_create_company_through_a_modal_with_a_duplicate_name(): void
+ public function it_fails_to_create_company_with_a_duplicate_name(): void
{
/* Arrange — regression guard: companies.name also has a unique DB
* constraint (like search_code); without ->unique() on the form
@@ -155,20 +79,19 @@ public function it_fails_to_create_company_through_a_modal_with_a_duplicate_name
];
/* Act */
- Livewire::actingAs($this->superAdmin())
- ->test(ListCompanies::class)
- ->mountAction('create')
+ $component = Livewire::actingAs($this->superAdmin())
+ ->test(CreateCompany::class)
->fillForm($payload)
- ->callMountedAction()
- ->assertHasFormErrors(['name']);
+ ->call('create');
/* Assert */
+ $component->assertHasFormErrors(['name']);
$this->assertDatabaseMissing('companies', ['search_code' => $payload['search_code']]);
}
#[Test]
#[Group('crud')]
- public function it_fails_to_create_company_through_a_modal_with_a_duplicate_search_code(): void
+ public function it_fails_to_create_company_with_a_duplicate_search_code(): void
{
/* Arrange — regression guard: companies.search_code has a unique DB
* constraint; without ->unique() on the form field, a duplicate hit
@@ -182,65 +105,15 @@ public function it_fails_to_create_company_through_a_modal_with_a_duplicate_sear
];
/* Act */
- Livewire::actingAs($this->superAdmin())
- ->test(ListCompanies::class)
- ->mountAction('create')
+ $component = Livewire::actingAs($this->superAdmin())
+ ->test(CreateCompany::class)
->fillForm($payload)
- ->callMountedAction()
- ->assertHasFormErrors(['search_code']);
+ ->call('create');
/* Assert */
+ $component->assertHasFormErrors(['search_code']);
$this->assertDatabaseMissing('companies', ['name' => $payload['name']]);
}
-
- #[Test]
- #[Group('crud')]
- /**
- * @payload {
- * "name": "Updated Corp"
- * }
- */
- public function it_updates_a_company_through_a_modal(): void
- {
- /* Arrange */
- $company = Company::factory()->create([
- 'search_code' => 'OLDCODE',
- 'name' => 'Old Name',
- ]);
-
- $updatedData = [
- 'search_code' => 'NEWCODE',
- 'name' => 'Updated Corp',
- ];
-
- /* Act */
- $component = Livewire::actingAs($this->superAdmin())
- ->test(ListCompanies::class)
- ->mountAction(TestAction::make('edit')->table($company), $updatedData)
- ->fillForm($updatedData)
- ->callMountedAction()
- ->assertHasNoFormErrors();
-
- /* Assert */
- $component->assertSuccessful();
- $this->assertDatabaseHas('companies', array_merge(
- ['id' => $company->id],
- $updatedData
- ));
- }
- # endregion
-
- # region modals
- #[Test]
- #[Group('modals')]
- #[Group('failing')]
- public function it_creates_a_company_trough_a_modal(): void
- {
- $this->markTestSkipped('slug is not auto-generated when creating via modal — needs investigation');
- }
- # endregion
-
- # region crud
#[Test]
#[Group('crud')]
public function it_creates_a_company(): void
diff --git a/Modules/Core/Tests/Feature/Company/TaxRateResourceAuthorizationTest.php b/Modules/Core/Tests/Feature/Company/TaxRateResourceAuthorizationTest.php
new file mode 100644
index 000000000..035015a83
--- /dev/null
+++ b/Modules/Core/Tests/Feature/Company/TaxRateResourceAuthorizationTest.php
@@ -0,0 +1,109 @@
+actingAs($this->user);
+ }
+
+ #[Test]
+ public function it_allows_viewing_the_list_with_view_tax_rates_permission(): void
+ {
+ $this->grantPermission(Permission::VIEW_TAX_RATES);
+
+ $this->assertTrue(TaxRateResource::canViewAny());
+ }
+
+ #[Test]
+ public function it_blocks_viewing_the_list_without_view_tax_rates_permission(): void
+ {
+ $this->withoutTaxRatesPermissions();
+
+ $this->assertFalse(TaxRateResource::canViewAny());
+ }
+
+ #[Test]
+ public function it_allows_creating_with_create_tax_rates_permission(): void
+ {
+ $this->grantPermission(Permission::CREATE_TAX_RATES);
+
+ $this->assertTrue(TaxRateResource::canCreate());
+ }
+
+ #[Test]
+ public function it_blocks_creating_without_create_tax_rates_permission(): void
+ {
+ $this->withoutTaxRatesPermissions();
+
+ $this->assertFalse(TaxRateResource::canCreate());
+ }
+
+ #[Test]
+ public function it_allows_editing_with_edit_tax_rates_permission(): void
+ {
+ $taxRate = TaxRate::factory()->for($this->company)->create();
+ $this->grantPermission(Permission::EDIT_TAX_RATES);
+
+ $this->assertTrue(TaxRateResource::canEdit($taxRate));
+ }
+
+ #[Test]
+ public function it_blocks_editing_without_edit_tax_rates_permission(): void
+ {
+ $taxRate = TaxRate::factory()->for($this->company)->create();
+ $this->withoutTaxRatesPermissions();
+
+ $this->assertFalse(TaxRateResource::canEdit($taxRate));
+ }
+
+ #[Test]
+ public function it_allows_deleting_with_delete_tax_rates_permission(): void
+ {
+ $taxRate = TaxRate::factory()->for($this->company)->create();
+ $this->grantPermission(Permission::DELETE_TAX_RATES);
+
+ $this->assertTrue(TaxRateResource::canDelete($taxRate));
+ }
+
+ #[Test]
+ public function it_blocks_deleting_without_delete_tax_rates_permission(): void
+ {
+ $taxRate = TaxRate::factory()->for($this->company)->create();
+ $this->withoutTaxRatesPermissions();
+
+ $this->assertFalse(TaxRateResource::canDelete($taxRate));
+ }
+
+ /**
+ * AbstractCompanyPanelTestCase assigns the client_admin role by default,
+ * which now includes Tax Rates permissions -- strip it to test the
+ * genuinely-unauthorized case, mirroring the plain `client` role.
+ */
+ private function withoutTaxRatesPermissions(): void
+ {
+ $this->user->syncRoles([]);
+ $this->user->syncPermissions([]);
+ app(\Spatie\Permission\PermissionRegistrar::class)->forgetCachedPermissions();
+ }
+}
diff --git a/Modules/Core/Tests/Feature/Company/TaxRatesTest.php b/Modules/Core/Tests/Feature/Company/TaxRatesTest.php
new file mode 100644
index 000000000..65a142f65
--- /dev/null
+++ b/Modules/Core/Tests/Feature/Company/TaxRatesTest.php
@@ -0,0 +1,164 @@
+for($this->company)->create([
+ 'name' => 'Standard VAT',
+ 'code' => 'STDVAT',
+ ]);
+
+ /* Act */
+ $component = Livewire::actingAs($this->user)
+ ->test(ListTaxRates::class);
+
+ /* Assert */
+ $component->assertSuccessful();
+ // 'name' column is truncated with ->limit(10) in TaxRatesTable, so
+ // assert on the untruncated 'code' column instead.
+ $component->assertSee('STDVAT');
+
+ $this->assertDatabaseHas('tax_rates', ['id' => $taxRate->id]);
+ }
+ # endregion
+
+ # region multi-tenancy
+ #[Test]
+ #[Group('multi-tenancy')]
+ public function it_does_not_show_tax_rates_from_another_company(): void
+ {
+ /* Arrange */
+ $other = TaxRate::factory()->for(Company::factory()->create())->create([
+ 'name' => 'Other Company VAT',
+ ]);
+
+ /* Act */
+ $component = Livewire::actingAs($this->user)
+ ->test(ListTaxRates::class);
+
+ /* Assert */
+ $component->assertSuccessful();
+ $component->assertCanNotSeeTableRecords([$other]);
+ }
+
+ #[Test]
+ #[Group('multi-tenancy')]
+ public function it_creates_a_tax_rate_with_the_current_company_id(): void
+ {
+ /* Arrange */
+ $payload = [
+ 'code' => 'STD21',
+ 'name' => 'Standard Rate',
+ 'tax_rate_type' => 'exclusive',
+ 'rate' => 21.0,
+ 'is_active' => true,
+ ];
+
+ /* Act */
+ $component = Livewire::actingAs($this->user)
+ ->test(CreateTaxRate::class)
+ ->fillForm($payload)
+ ->call('create');
+
+ /* Assert */
+ $component->assertHasNoFormErrors();
+
+ $this->assertDatabaseHas('tax_rates', [
+ 'code' => 'STD21',
+ 'name' => 'Standard Rate',
+ 'company_id' => $this->company->id,
+ ]);
+ }
+ # endregion
+
+ # region crud
+ #[Test]
+ #[Group('crud')]
+ public function it_fails_to_create_a_tax_rate_without_required_code(): void
+ {
+ /* Arrange */
+ $payload = [
+ 'name' => 'Missing Code Tax',
+ 'tax_rate_type' => 'exclusive',
+ 'rate' => 10.0,
+ ];
+
+ /* Act */
+ $component = Livewire::actingAs($this->user)
+ ->test(CreateTaxRate::class)
+ ->fillForm($payload)
+ ->call('create');
+
+ /* Assert */
+ $component->assertHasFormErrors(['code']);
+
+ $this->assertDatabaseMissing('tax_rates', ['name' => 'Missing Code Tax']);
+ }
+
+ #[Test]
+ #[Group('crud')]
+ public function it_updates_a_tax_rate(): void
+ {
+ /* Arrange */
+ $taxRate = TaxRate::factory()->for($this->company)->create([
+ 'name' => 'Old Rate',
+ 'code' => 'OLD',
+ ]);
+
+ $payload = ['name' => 'Updated Rate'];
+
+ /* Act */
+ $component = Livewire::actingAs($this->user)
+ ->test(EditTaxRate::class, ['record' => $taxRate->id])
+ ->fillForm($payload)
+ ->call('save');
+
+ /* Assert */
+ $component->assertHasNoFormErrors();
+
+ $this->assertDatabaseHas('tax_rates', array_merge($payload, [
+ 'id' => $taxRate->id,
+ ]));
+ }
+
+ #[Test]
+ #[Group('crud')]
+ public function it_deletes_a_tax_rate(): void
+ {
+ /* Arrange */
+ $taxRate = TaxRate::factory()->for($this->company)->create([
+ 'name' => 'Rate to Delete',
+ ]);
+
+ /* Act */
+ Livewire::actingAs($this->user)
+ ->test(ListTaxRates::class)
+ ->mountAction(TestAction::make('delete')->table($taxRate))
+ ->callMountedAction();
+
+ /* Assert */
+ $this->assertDatabaseMissing('tax_rates', ['id' => $taxRate->id]);
+ }
+ # endregion
+}
diff --git a/Modules/Core/Tests/Feature/CompanyPanelQuickCreateWiringTest.php b/Modules/Core/Tests/Feature/CompanyPanelQuickCreateWiringTest.php
index eb72d3a28..e25e29526 100644
--- a/Modules/Core/Tests/Feature/CompanyPanelQuickCreateWiringTest.php
+++ b/Modules/Core/Tests/Feature/CompanyPanelQuickCreateWiringTest.php
@@ -19,51 +19,23 @@
* added for Expenses only, GH #617) to Relations, Products, Invoices,
* Quotes and Payments, via CompanyPanelProvider::withQuickCreate().
*
- * Resources without a dedicated `create` page (modal-only, creating records
- * through a CreateAction on their list page) get a quick-create URL pointing
- * at the index page with `?action=create`, which Filament's own
- * `#[Url(as: 'action')]`-bound `$defaultAction` property auto-mounts on load
- * (see vendor/filament/filament resources/views components/page/index.blade.php
- * `wire:init="mountAction(...)"`). Resources with a dedicated create page
- * (Invoices, Quotes, Expenses) link straight to it instead.
+ * All resources covered here now link straight to a dedicated `create` page
+ * instead of mounting a modal action on the index page.
*/
class CompanyPanelQuickCreateWiringTest extends AbstractCompanyPanelTestCase
{
- public static function modalOnlyResources(): array
+ public static function dedicatedCreatePageResources(): array
{
return [
+ 'Invoices' => [InvoiceResource::class],
+ 'Quotes' => [QuoteResource::class],
+ 'Expenses' => [ExpenseResource::class],
'Relations' => [RelationResource::class],
- 'Products' => [ProductResource::class],
'Payments' => [PaymentResource::class],
+ 'Products' => [ProductResource::class],
];
}
- public static function dedicatedCreatePageResources(): array
- {
- return [
- 'Invoices' => [InvoiceResource::class],
- 'Quotes' => [QuoteResource::class],
- 'Expenses' => [ExpenseResource::class],
- ];
- }
-
- #[Test]
- #[DataProvider('modalOnlyResources')]
- public function it_points_modal_only_resources_at_the_index_page_with_an_auto_mount_query_string(string $resourceClass): void
- {
- /* Arrange */
- $this->actingAs($this->user);
-
- /* Act */
- $items = $this->withQuickCreate($resourceClass);
-
- /* Assert */
- $this->assertNotEmpty($items);
- $url = $items[0]->getExtraAttributeBag()->get('data-quick-create-url');
- $this->assertSame($resourceClass::getUrl('index', ['action' => 'create']), $url);
- $this->assertStringContainsString('?action=create', $url);
- }
-
#[Test]
#[DataProvider('dedicatedCreatePageResources')]
public function it_points_dedicated_create_page_resources_at_their_create_page_without_a_query_string(string $resourceClass): void
diff --git a/Modules/Core/Tests/Feature/EmailTemplatesTest.php b/Modules/Core/Tests/Feature/EmailTemplatesTest.php
index 21b376bb5..77e78f38e 100644
--- a/Modules/Core/Tests/Feature/EmailTemplatesTest.php
+++ b/Modules/Core/Tests/Feature/EmailTemplatesTest.php
@@ -39,132 +39,6 @@ public function it_lists_email_templates(): void
}
# endregion
- # region modals
- #[Test]
- #[Group('crud')]
- public function it_creates_an_email_template_through_a_modal(): void
- {
- /* Arrange */
- $payload = [
- 'title' => 'Test Email',
- 'subject' => 'Welcome',
- 'body' => 'This is the email body content.',
- 'type' => EmailTemplateType::TEXT->value,
- 'from_name' => 'Acme Support',
- 'from_email' => 'support@acme.com',
- ];
-
- /* Act */
- $component = Livewire::actingAs($this->superAdmin())
- ->test(ListEmailTemplates::class)
- ->mountAction('create')
- ->fillForm($payload)
- ->callMountedAction();
-
- /*if (app()->runningUnitTests()) {
- dump($payload);
- }*/
-
- /* Assert */
- $component
- ->assertSuccessful()
- ->assertHasNoFormErrors();
-
- $this->assertDatabaseHas('email_templates', $payload);
- }
-
- #[Test]
- #[Group('crud')]
- public function it_fails_to_create_email_template_through_a_modal_without_required_title(): void
- {
- /* Arrange */
- $payload = [
- 'subject' => 'Welcome',
- 'body' => 'This is the email body content.',
- 'type' => EmailTemplateType::TEXT->value,
- 'from_name' => 'Acme Support',
- 'from_email' => 'support@acme.com',
- ];
-
- /* Act */
- $component = Livewire::actingAs($this->superAdmin())
- ->test(ListEmailTemplates::class)
- ->mountAction('create')
- ->fillForm($payload)
- ->callMountedAction();
-
- /*if (app()->runningUnitTests()) {
- dump($payload);
- }*/
-
- /* Assert */
- $component
- ->assertHasFormErrors(['title']);
-
- $this->assertDatabaseMissing('email_templates', $payload);
- }
-
- #[Test]
- #[Group('crud')]
- public function it_fails_to_create_an_email_template_through_a_modal_without_required_type(): void
- {
- /* Arrange */
- $payload = [
- 'title' => 'Welcome',
- 'subject' => 'Test Email',
- 'body' => 'This is the email body content.',
- 'from_name' => 'Acme Support',
- 'from_email' => 'support@acme.com',
- ];
-
- /* Act */
- $component = Livewire::actingAs($this->superAdmin())
- ->test(ListEmailTemplates::class)
- ->mountAction('create')
- ->fillForm($payload)
- ->callMountedAction();
-
- /*if (app()->runningUnitTests()) {
- dump($payload);
- }*/
-
- /* Assert */
- $component
- ->assertHasFormErrors(['type']);
-
- $this->assertDatabaseMissing('email_templates', $payload);
- }
-
- #[Test]
- #[Group('crud')]
- public function it_updates_an_email_template_through_a_modal(): void
- {
- /* Arrange */
- $template = EmailTemplate::factory()->for($this->company)->create([
- 'title' => 'Old Title',
- 'subject' => 'Old Subject',
- 'type' => EmailTemplateType::TEXT->value,
- ]);
-
- $payload = ['subject' => 'Updated Subject'];
-
- /* Act */
- $component = Livewire::actingAs($this->superAdmin)
- ->test(ListEmailTemplates::class)
- ->mountAction(TestAction::make('edit')->table($template), $payload)
- ->fillForm($payload)
- ->callMountedAction()
- ->assertHasNoFormErrors();
-
- /* Assert */
- $component
- ->assertSuccessful()
- ->assertHasNoErrors();
-
- $this->assertDatabaseHas('email_templates', $payload);
- }
- #endregion
-
# region crud
#[Test]
#[Group('crud')]
diff --git a/Modules/Core/Tests/Feature/NoteTemplatesTest.php b/Modules/Core/Tests/Feature/NoteTemplatesTest.php
index ee1029a84..3c26843e8 100644
--- a/Modules/Core/Tests/Feature/NoteTemplatesTest.php
+++ b/Modules/Core/Tests/Feature/NoteTemplatesTest.php
@@ -5,6 +5,8 @@
use Filament\Actions\Testing\TestAction;
use Livewire\Livewire;
use Modules\Core\Filament\Company\Resources\NoteTemplates\NoteTemplateResource;
+use Modules\Core\Filament\Company\Resources\NoteTemplates\Pages\CreateNoteTemplate;
+use Modules\Core\Filament\Company\Resources\NoteTemplates\Pages\EditNoteTemplate;
use Modules\Core\Filament\Company\Resources\NoteTemplates\Pages\ListNoteTemplates;
use Modules\Core\Models\Company;
use Modules\Core\Models\NoteTemplate;
@@ -63,7 +65,7 @@ public function it_does_not_show_note_templates_from_another_company(): void
# region crud
#[Test]
#[Group('crud')]
- public function it_creates_a_note_template_through_a_modal(): void
+ public function it_creates_a_note_template(): void
{
/* Arrange */
$payload = [
@@ -73,10 +75,9 @@ public function it_creates_a_note_template_through_a_modal(): void
/* Act */
$component = Livewire::actingAs($this->user)
- ->test(ListNoteTemplates::class)
- ->mountAction('create')
+ ->test(CreateNoteTemplate::class)
->fillForm($payload)
- ->callMountedAction();
+ ->call('create');
/* Assert */
$component->assertHasNoFormErrors();
@@ -97,10 +98,9 @@ public function it_fails_to_create_a_note_template_without_required_title(): voi
/* Act */
$component = Livewire::actingAs($this->user)
- ->test(ListNoteTemplates::class)
- ->mountAction('create')
+ ->test(CreateNoteTemplate::class)
->fillForm($payload)
- ->callMountedAction();
+ ->call('create');
/* Assert */
$component->assertHasFormErrors(['template_title']);
@@ -110,7 +110,7 @@ public function it_fails_to_create_a_note_template_without_required_title(): voi
#[Test]
#[Group('crud')]
- public function it_updates_a_note_template_through_a_modal(): void
+ public function it_updates_a_note_template(): void
{
/* Arrange */
$template = NoteTemplate::factory()->for($this->company)->create([
@@ -122,10 +122,9 @@ public function it_updates_a_note_template_through_a_modal(): void
/* Act */
$component = Livewire::actingAs($this->user)
- ->test(ListNoteTemplates::class)
- ->mountAction(TestAction::make('edit')->table($template), $payload)
+ ->test(EditNoteTemplate::class, ['record' => $template->id])
->fillForm($payload)
- ->callMountedAction();
+ ->call('save');
/* Assert */
$component->assertHasNoFormErrors();
diff --git a/Modules/Core/Tests/Feature/NumberingPrefixAutofillTest.php b/Modules/Core/Tests/Feature/NumberingPrefixAutofillTest.php
index fc273bb96..1c72c0b44 100644
--- a/Modules/Core/Tests/Feature/NumberingPrefixAutofillTest.php
+++ b/Modules/Core/Tests/Feature/NumberingPrefixAutofillTest.php
@@ -5,7 +5,7 @@
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Modules\Core\Enums\NumberingType;
-use Modules\Core\Filament\Admin\Resources\Numberings\Pages\ListNumberings;
+use Modules\Core\Filament\Admin\Resources\Numberings\Pages\CreateNumbering;
use Modules\Core\Filament\Admin\Resources\Numberings\Schemas\NumberingForm;
use Modules\Core\Tests\AbstractAdminPanelTestCase;
use PHPUnit\Framework\Attributes\CoversClass;
@@ -21,12 +21,11 @@ public function it_prefills_the_prefix_with_the_selected_types_default_prefix_wh
{
/* Act */
$component = Livewire::actingAs($this->superAdmin())
- ->test(ListNumberings::class)
- ->mountAction('create')
- ->set('mountedActions.0.data.type', NumberingType::QUOTE->value);
+ ->test(CreateNumbering::class)
+ ->set('data.type', NumberingType::QUOTE->value);
/* Assert */
- $component->assertSet('mountedActions.0.data.prefix', NumberingType::QUOTE->prefix());
+ $component->assertSet('data.prefix', NumberingType::QUOTE->prefix());
}
#[Test]
@@ -34,13 +33,12 @@ public function it_does_not_overwrite_an_already_chosen_prefix_when_the_type_cha
{
/* Act */
$component = Livewire::actingAs($this->superAdmin())
- ->test(ListNumberings::class)
- ->mountAction('create')
- ->set('mountedActions.0.data.prefix', 'CUSTOM')
- ->set('mountedActions.0.data.type', NumberingType::QUOTE->value);
+ ->test(CreateNumbering::class)
+ ->set('data.prefix', 'CUSTOM')
+ ->set('data.type', NumberingType::QUOTE->value);
/* Assert */
- $component->assertSet('mountedActions.0.data.prefix', 'CUSTOM');
+ $component->assertSet('data.prefix', 'CUSTOM');
}
#[Test]
@@ -48,12 +46,11 @@ public function it_prefills_a_different_prefix_when_the_type_selection_changes_a
{
/* Act */
$component = Livewire::actingAs($this->superAdmin())
- ->test(ListNumberings::class)
- ->mountAction('create')
- ->set('mountedActions.0.data.type', NumberingType::EXPENSE->value);
+ ->test(CreateNumbering::class)
+ ->set('data.type', NumberingType::EXPENSE->value);
/* Assert */
- $component->assertSet('mountedActions.0.data.prefix', NumberingType::EXPENSE->prefix());
+ $component->assertSet('data.prefix', NumberingType::EXPENSE->prefix());
}
#[Test]
@@ -61,8 +58,7 @@ public function it_no_longer_shows_the_misleading_job_placeholder(): void
{
/* Act */
$component = Livewire::actingAs($this->superAdmin())
- ->test(ListNumberings::class)
- ->mountAction('create');
+ ->test(CreateNumbering::class);
/* Assert */
$component->assertDontSee('JOB');
diff --git a/Modules/Core/Tests/Feature/NumberingTest.php b/Modules/Core/Tests/Feature/NumberingTest.php
index db0de5f7b..3697ac46c 100644
--- a/Modules/Core/Tests/Feature/NumberingTest.php
+++ b/Modules/Core/Tests/Feature/NumberingTest.php
@@ -5,6 +5,8 @@
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Modules\Core\Enums\NumberingType;
+use Modules\Core\Filament\Admin\Resources\Numberings\Pages\CreateNumbering;
+use Modules\Core\Filament\Admin\Resources\Numberings\Pages\EditNumbering;
use Modules\Core\Filament\Admin\Resources\Numberings\Pages\ListNumberings;
use Modules\Core\Models\Company;
use Modules\Core\Models\Numbering;
@@ -95,8 +97,9 @@ public function it_creates_a_numbering_scheme(): void
/* Act */
$component = Livewire::actingAs($this->superAdmin())
- ->test(ListNumberings::class)
- ->callAction('create', data: $payload);
+ ->test(CreateNumbering::class)
+ ->fillForm($payload)
+ ->call('create');
/* Assert */
$component->assertHasNoFormErrors();
@@ -110,7 +113,6 @@ public function it_creates_a_numbering_scheme(): void
#[Test]
#[Group('crud')]
- #[Group('failing')]
public function it_updates_a_numbering_scheme(): void
{
/* Arrange */
@@ -127,11 +129,12 @@ public function it_updates_a_numbering_scheme(): void
/* Act */
$component = Livewire::actingAs($this->superAdmin())
- ->test(ListNumberings::class)
- ->callTableAction('edit', $numbering, data: $payload);
+ ->test(EditNumbering::class, ['record' => $numbering->id])
+ ->fillForm($payload)
+ ->call('save');
/* Assert */
- $component->assertHasNoTableActionErrors();
+ $component->assertHasNoFormErrors();
$this->assertDatabaseHas('numbering', [
'id' => $numbering->id,
'name' => 'Updated Quote Numbering',
@@ -141,7 +144,6 @@ public function it_updates_a_numbering_scheme(): void
#[Test]
#[Group('crud')]
- #[Group('failing')]
public function it_deletes_a_numbering_scheme(): void
{
/* Arrange */
@@ -172,11 +174,12 @@ public function it_requires_name_when_creating_numbering(): void
/* Act */
$component = Livewire::actingAs($this->superAdmin())
- ->test(ListNumberings::class)
- ->callAction('create', data: $payload);
+ ->test(CreateNumbering::class)
+ ->fillForm($payload)
+ ->call('create');
/* Assert */
- $component->assertHasTableActionErrors(['name']);
+ $component->assertHasFormErrors(['name']);
}
#[Test]
@@ -191,10 +194,11 @@ public function it_requires_type_when_creating_numbering(): void
/* Act */
$component = Livewire::actingAs($this->superAdmin())
- ->test(ListNumberings::class)
- ->callAction('create', data: $payload);
+ ->test(CreateNumbering::class)
+ ->fillForm($payload)
+ ->call('create');
/* Assert */
- $component->assertHasTableActionErrors(['type']);
+ $component->assertHasFormErrors(['type']);
}
}
diff --git a/Modules/Core/Tests/Feature/ScratchSettingsTest.php b/Modules/Core/Tests/Feature/ScratchSettingsTest.php
new file mode 100644
index 000000000..db5870524
--- /dev/null
+++ b/Modules/Core/Tests/Feature/ScratchSettingsTest.php
@@ -0,0 +1,36 @@
+superAdmin())->test(Settings::class);
+ $c1->set('settings.currency_symbol', '€')->call('submit');
+
+ try {
+ $c2 = Livewire::actingAs($this->superAdmin())->test(Settings::class);
+ } catch (\Throwable $e) {
+ fwrite(STDERR, get_class($e) . ': ' . $e->getMessage() . PHP_EOL);
+ fwrite(STDERR, $e->getFile() . ':' . $e->getLine() . PHP_EOL);
+ $prev = $e->getPrevious();
+ while ($prev) {
+ fwrite(STDERR, 'PREV: ' . get_class($prev) . ': ' . $prev->getMessage() . PHP_EOL);
+ fwrite(STDERR, $prev->getFile() . ':' . $prev->getLine() . PHP_EOL);
+ foreach (array_slice($prev->getTrace(), 0, 8) as $i => $frame) {
+ fwrite(STDERR, $i . ': ' . ($frame['file'] ?? '?') . ':' . ($frame['line'] ?? '?') . ' ' . ($frame['function'] ?? '') . PHP_EOL);
+ }
+ $prev = $prev->getPrevious();
+ }
+ }
+
+ $this->assertTrue(true);
+ }
+}
diff --git a/Modules/Core/Tests/Feature/Seeders/RolesSeederTest.php b/Modules/Core/Tests/Feature/Seeders/RolesSeederTest.php
index 19f671177..c2f554008 100644
--- a/Modules/Core/Tests/Feature/Seeders/RolesSeederTest.php
+++ b/Modules/Core/Tests/Feature/Seeders/RolesSeederTest.php
@@ -130,6 +130,44 @@ public function client_admin_has_no_import_permissions(): void
}
}
+ #[Test]
+ public function client_admin_has_tax_rate_permissions(): void
+ {
+ /* Arrange */
+ (new RolesSeeder())->run();
+
+ /* Act */
+ $permissionNames = Role::query()->where('name', UserRole::CUSTOMER_ADMIN->value)
+ ->firstOrFail()
+ ->permissions
+ ->pluck('name');
+
+ /* Assert */
+ $this->assertContains(PermissionEnum::VIEW_TAX_RATES->value, $permissionNames);
+ $this->assertContains(PermissionEnum::CREATE_TAX_RATES->value, $permissionNames);
+ $this->assertContains(PermissionEnum::EDIT_TAX_RATES->value, $permissionNames);
+ $this->assertContains(PermissionEnum::DELETE_TAX_RATES->value, $permissionNames);
+ }
+
+ #[Test]
+ public function client_has_no_tax_rate_permissions(): void
+ {
+ /* Arrange */
+ (new RolesSeeder())->run();
+
+ /* Act */
+ $permissionNames = Role::query()->where('name', UserRole::CUSTOMER->value)
+ ->firstOrFail()
+ ->permissions
+ ->pluck('name');
+
+ /* Assert */
+ $this->assertNotContains(PermissionEnum::VIEW_TAX_RATES->value, $permissionNames);
+ $this->assertNotContains(PermissionEnum::CREATE_TAX_RATES->value, $permissionNames);
+ $this->assertNotContains(PermissionEnum::EDIT_TAX_RATES->value, $permissionNames);
+ $this->assertNotContains(PermissionEnum::DELETE_TAX_RATES->value, $permissionNames);
+ }
+
#[Test]
public function client_has_only_a_minimal_view_and_document_action_allowlist(): void
{
diff --git a/Modules/Core/Tests/Feature/TaxRatesTest.php b/Modules/Core/Tests/Feature/TaxRatesTest.php
index c0f094f90..234e51136 100644
--- a/Modules/Core/Tests/Feature/TaxRatesTest.php
+++ b/Modules/Core/Tests/Feature/TaxRatesTest.php
@@ -63,10 +63,12 @@ public function it_lists_tax_rates(): void
}
# endregion
- # region modals
+ # region crud
#[Test]
#[Group('crud')]
/**
+ * TaxRateResource.
+ *
* @payload
* {
* "company_id": "Value",
@@ -77,7 +79,8 @@ public function it_lists_tax_rates(): void
* "rate": "Example"
* }
*/
- public function it_creates_a_taxrate_through_a_modal(): void
+ #[Group('crud')]
+ public function it_creates_a_taxrate(): void
{
/* Arrange */
$payload = [
@@ -90,10 +93,9 @@ public function it_creates_a_taxrate_through_a_modal(): void
/* Act */
$component = Livewire::actingAs($this->superAdmin())
- ->test(ListTaxRates::class)
- ->mountAction('create')
+ ->test(CreateTaxRate::class)
->fillForm($payload)
- ->callMountedAction();
+ ->call('create');
/* Assert */
$component
@@ -105,7 +107,7 @@ public function it_creates_a_taxrate_through_a_modal(): void
#[Test]
#[Group('crud')]
- public function it_fails_to_create_a_taxrate_through_a_modal_with_a_duplicate_code(): void
+ public function it_fails_to_create_a_taxrate_with_a_duplicate_code(): void
{
/* Arrange — regression guard: tax_rates has a unique DB constraint
* on (company_id, code); without ->unique() on the form field, a
@@ -123,10 +125,9 @@ public function it_fails_to_create_a_taxrate_through_a_modal_with_a_duplicate_co
/* Act */
$component = Livewire::actingAs($this->superAdmin())
- ->test(ListTaxRates::class)
- ->mountAction('create')
+ ->test(CreateTaxRate::class)
->fillForm($payload)
- ->callMountedAction();
+ ->call('create');
/* Assert */
$component->assertHasFormErrors(['code']);
@@ -135,7 +136,7 @@ public function it_fails_to_create_a_taxrate_through_a_modal_with_a_duplicate_co
#[Test]
#[Group('crud')]
- public function it_fails_to_create_a_taxrate_through_a_modal_without_required_code(): void
+ public function it_fails_to_create_a_taxrate_without_required_code(): void
{
/* Arrange — regression guard: tax_rates.code is NOT NULL with no DB
* default; without ->required() on the form field (it had no
@@ -148,92 +149,6 @@ public function it_fails_to_create_a_taxrate_through_a_modal_without_required_co
'rate' => 5.0,
];
- /* Act */
- $component = Livewire::actingAs($this->superAdmin())
- ->test(ListTaxRates::class)
- ->mountAction('create')
- ->fillForm($payload)
- ->callMountedAction();
-
- /* Assert */
- $component->assertHasFormErrors(['code' => 'required']);
- $this->assertDatabaseMissing('tax_rates', ['name' => $payload['name']]);
- }
-
- #[Test]
- #[Group('crud')]
- /**
- * @payload
- * {
- * "company_id": "Value",
- * "tax_rate_type": "Value",
- * "is_active": "true",
- * "name": "Example",
- * "code": "Example",
- * "rate": "Example"
- * }
- */
- public function it_updates_a_taxrate_through_a_modal(): void
- {
- $record = TaxRate::factory()->create([
- 'tax_rate_type' => TaxRateType::EXCLUSIVE,
- 'is_active' => true,
- 'code' => 'EXCL21',
- 'name' => '::taxrate_name::',
- 'rate' => 21.0000,
- ]);
-
- $updatedData = [
- 'name' => 'Updated VAT Rate',
- 'rate' => 22.0,
- ];
-
- /* Act */
- $component = Livewire::actingAs($this->superAdmin())
- ->test(ListTaxRates::class)
- ->mountAction(TestAction::make('edit')->table($record), $updatedData)
- ->fillForm($updatedData)
- ->callMountedAction()
- ->assertHasNoFormErrors();
-
- /* Assert */
- $component->assertSuccessful();
-
- $this->assertDatabaseHas('tax_rates', array_merge(
- ['id' => $record->id],
- $updatedData
- ));
- }
- # endregion
-
- # region crud
- #[Test]
- #[Group('crud')]
- /**
- * TaxRateResource.
- *
- * @payload
- * {
- * "company_id": "Value",
- * "tax_rate_type": "Value",
- * "is_active": "true",
- * "name": "Example",
- * "code": "Example",
- * "rate": "Example"
- * }
- */
- #[Group('crud')]
- public function it_creates_a_taxrate(): void
- {
- /* Arrange */
- $payload = [
- 'tax_rate_type' => TaxRateType::EXCLUSIVE,
- 'is_active' => true,
- 'code' => 'EXCL21',
- 'name' => '::taxrate_name::',
- 'rate' => 21.0000,
- ];
-
/* Act */
$component = Livewire::actingAs($this->superAdmin())
->test(CreateTaxRate::class)
@@ -241,11 +156,8 @@ public function it_creates_a_taxrate(): void
->call('create');
/* Assert */
- $component
- ->assertSuccessful()
- ->assertHasNoErrors();
-
- $this->assertDatabaseHas('tax_rates', $payload);
+ $component->assertHasFormErrors(['code' => 'required']);
+ $this->assertDatabaseMissing('tax_rates', ['name' => $payload['name']]);
}
#[Test]
diff --git a/Modules/Core/Tests/Feature/UsersTest.php b/Modules/Core/Tests/Feature/UsersTest.php
index d591e54d8..d9ad7d9b0 100644
--- a/Modules/Core/Tests/Feature/UsersTest.php
+++ b/Modules/Core/Tests/Feature/UsersTest.php
@@ -6,6 +6,8 @@
use Illuminate\Support\Carbon;
use Livewire\Livewire;
use Modules\Core\Enums\UserRole;
+use Modules\Core\Filament\Admin\Resources\Users\Pages\CreateUser;
+use Modules\Core\Filament\Admin\Resources\Users\Pages\EditUser;
use Modules\Core\Filament\Admin\Resources\Users\Pages\ListUsers;
use Modules\Core\Filament\Pages\Auth\Login;
use Modules\Core\Models\User;
@@ -53,7 +55,7 @@ public function it_lists_users(): void
# region crud
#[Test]
#[Group('crud')]
- public function it_creates_a_user_through_a_modal(): void
+ public function it_creates_a_user(): void
{
/* Arrange */
$payload = [
@@ -63,10 +65,9 @@ public function it_creates_a_user_through_a_modal(): void
/* Act */
$component = Livewire::actingAs($this->superAdmin())
- ->test(ListUsers::class)
- ->mountAction('create')
+ ->test(CreateUser::class)
->fillForm(array_merge($payload, ['password' => 'password']))
- ->callMountedAction();
+ ->call('create');
/* Assert */
$component->assertSuccessful()
@@ -76,7 +77,7 @@ public function it_creates_a_user_through_a_modal(): void
#[Test]
#[Group('crud')]
- public function it_fails_to_create_a_user_through_a_modal_with_a_duplicate_email(): void
+ public function it_fails_to_create_a_user_with_a_duplicate_email(): void
{
/* Arrange — regression guard: users.email has a unique DB
* constraint; without ->unique() on the form field, a duplicate hit
@@ -93,16 +94,36 @@ public function it_fails_to_create_a_user_through_a_modal_with_a_duplicate_email
/* Act */
Livewire::actingAs($this->superAdmin())
- ->test(ListUsers::class)
- ->mountAction('create')
+ ->test(CreateUser::class)
->fillForm($payload)
- ->callMountedAction()
+ ->call('create')
->assertHasFormErrors(['email']);
/* Assert */
$this->assertDatabaseMissing('users', ['name' => $payload['name']]);
}
+ #[Test]
+ #[Group('crud')]
+ public function it_updates_a_user(): void
+ {
+ /* Arrange */
+ $user = User::factory()->create(['name' => 'Old Name']);
+
+ $payload = ['name' => 'Updated Name'];
+
+ /* Act */
+ $component = Livewire::actingAs($this->superAdmin())
+ ->test(EditUser::class, ['record' => $user->id])
+ ->fillForm($payload)
+ ->call('save');
+
+ /* Assert */
+ $component->assertSuccessful()
+ ->assertHasNoErrors();
+ $this->assertDatabaseHas('users', ['id' => $user->id, 'name' => $payload['name']]);
+ }
+
#[Test]
#[Group('crud')]
public function it_deletes_a_user(): void
diff --git a/Modules/Expenses/Filament/Company/Resources/Expenses/ExpenseResource.php b/Modules/Expenses/Filament/Company/Resources/Expenses/ExpenseResource.php
index 74d7b011b..8ac82f357 100644
--- a/Modules/Expenses/Filament/Company/Resources/Expenses/ExpenseResource.php
+++ b/Modules/Expenses/Filament/Company/Resources/Expenses/ExpenseResource.php
@@ -10,6 +10,7 @@
use Modules\Core\Enums\Permission;
use Modules\Core\Filament\Company\Resources\BaseResource;
use Modules\Expenses\Filament\Company\Resources\Expenses\Pages\CreateExpense;
+use Modules\Expenses\Filament\Company\Resources\Expenses\Pages\EditExpense;
use Modules\Expenses\Filament\Company\Resources\Expenses\Pages\ListExpenses;
use Modules\Expenses\Filament\Company\Resources\Expenses\Schemas\ExpenseForm;
use Modules\Expenses\Filament\Company\Resources\Expenses\Tables\ExpensesTable;
@@ -67,6 +68,7 @@ public static function getPages(): array
return [
'index' => ListExpenses::route('/'),
'create' => CreateExpense::route('/create'),
+ 'edit' => EditExpense::route('/{record}/edit'),
];
}
diff --git a/Modules/Expenses/Filament/Company/Resources/Expenses/Pages/ListExpenses.php b/Modules/Expenses/Filament/Company/Resources/Expenses/Pages/ListExpenses.php
index 176fe7ce0..5da1089ff 100644
--- a/Modules/Expenses/Filament/Company/Resources/Expenses/Pages/ListExpenses.php
+++ b/Modules/Expenses/Filament/Company/Resources/Expenses/Pages/ListExpenses.php
@@ -4,9 +4,7 @@
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
-use Modules\Core\Enums\Permission;
use Modules\Expenses\Filament\Company\Resources\Expenses\ExpenseResource;
-use Modules\Expenses\Services\ExpenseService;
class ListExpenses extends ListRecords
{
@@ -16,14 +14,7 @@ protected function getHeaderActions(): array
{
return [
CreateAction::make()
- ->visible(fn () => auth()->user()?->can(Permission::CREATE_EXPENSES->value))
- ->mutateDataUsing(function (array $data) {
- return $data;
- })
- ->action(function (array $data) {
- app(ExpenseService::class)->createExpense($data);
- })
- ->modalWidth('full'),
+ ->url(fn (): string => ExpenseResource::getUrl('create')),
];
}
}
diff --git a/Modules/Expenses/Filament/Company/Resources/Expenses/Schemas/ExpenseForm.php b/Modules/Expenses/Filament/Company/Resources/Expenses/Schemas/ExpenseForm.php
index 6abedfe01..79fffe2f1 100644
--- a/Modules/Expenses/Filament/Company/Resources/Expenses/Schemas/ExpenseForm.php
+++ b/Modules/Expenses/Filament/Company/Resources/Expenses/Schemas/ExpenseForm.php
@@ -4,15 +4,14 @@
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\MarkdownEditor;
-use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Components\Grid;
-use Filament\Schemas\Components\Group;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Schema;
+use Filament\Support\Icons\Heroicon;
use Illuminate\Support\Facades\Log;
use Modules\Clients\Enums\RelationType;
use Modules\Expenses\Enums\ExpenseStatus;
@@ -26,123 +25,123 @@ class ExpenseForm
public static function configure(Schema $schema): Schema
{
return $schema
- ->columns(1)
- ->schema([
+ ->components([
Grid::make(3)
+ ->columnSpanFull()
->schema([
- Section::make()
+ Section::make(trans('ip.details'))
+ ->icon(Heroicon::OutlinedDocumentText)
+ ->columnSpan(2)
+ ->schema([
+ Grid::make(4)
+ ->schema([
+ TextInput::make('expense_number')
+ ->label(trans('ip.expense_number'))
+ ->prefixIcon(Heroicon::OutlinedHashtag)
+ ->required()
+ ->columnSpan(2)
+ ->default(function (Get $get, string $operation) {
+ if ($operation !== 'create') {
+ return;
+ }
+
+ $user = auth()->user();
+ $companyId = $user?->getCurrentCompanyId();
+
+ if (config('app.extreme_logging')) {
+ Log::debug('ExpenseForm: Initializing ExpenseNumberGenerator', [
+ 'company_id' => $companyId,
+ 'expense_status' => $get('expense_status'),
+ 'user_id' => $user?->id,
+ 'session_company_id' => session('current_company_id'),
+ 'trace' => debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 5),
+ ]);
+ }
+
+ $generator = new ExpenseNumberGenerator($companyId);
+
+ return $generator->generate();
+ })
+ ->dehydrated(),
+
+ Select::make('expense_status')
+ ->label(trans('ip.expense_status'))
+ ->prefixIcon(Heroicon::OutlinedCheckBadge)
+ ->options(ExpenseStatus::options())
+ ->searchable()
+ ->preload()
+ ->native(false)
+ ->required()
+ ->columnSpan(2),
+
+ Select::make('category_id')
+ ->label(trans('ip.category'))
+ ->prefixIcon(Heroicon::OutlinedTag)
+ ->relationship('expenseCategory', 'category_name')
+ ->required()
+ ->searchable()
+ ->preload()
+ ->columnSpan(2),
+
+ Select::make('expense_type')
+ ->label(trans('ip.expense_type'))
+ ->prefixIcon(Heroicon::OutlinedBriefcase)
+ ->options(ExpenseType::options())
+ ->searchable()
+ ->preload()
+ ->native(false)
+ ->required()
+ ->columnSpan(2),
+
+ TextInput::make('expense_amount')
+ ->label(trans('ip.expense_amount'))
+ ->prefixIcon(Heroicon::OutlinedCurrencyDollar)
+ ->numeric()
+ ->required()
+ ->columnSpan(2),
+
+ DatePicker::make('expensed_at')
+ ->label(trans('ip.expensed_at'))
+ ->prefixIcon(Heroicon::OutlinedCalendar)
+ ->required()
+ ->columnSpan(2),
+ ]),
+ ]),
+
+ Section::make(trans('ip.contact'))
+ ->icon(Heroicon::OutlinedUserGroup)
+ ->columnSpan(1)
->schema([
Select::make('customer_id')
+ ->label(trans('ip.client'))
+ ->prefixIcon(Heroicon::OutlinedUserGroup)
->relationship(
name: 'customer',
titleAttribute: 'company_name',
modifyQueryUsing: fn ($query) => $query->where('relation_type', RelationType::CUSTOMER->value)
)
- ->label(trans('ip.client'))
->required()
->searchable()
->preload()
->native(false),
- Placeholder::make('customer_info')
- ->label(trans('ip.client'))
- ->content(fn (Get $get) => optional($get('customer'))->company_name ?? '-')
- ->visible(fn (Get $get) => filled($get('customer_id'))),
- ])
- ->columnSpan(1),
-
- Section::make()
- ->schema([
Select::make('vendor_id')
+ ->label(trans('ip.vendor'))
+ ->prefixIcon(Heroicon::OutlinedBuildingOffice2)
->relationship(
name: 'vendor',
titleAttribute: 'company_name',
modifyQueryUsing: fn ($query) => $query->where('relation_type', RelationType::VENDOR->value)
)
- ->label(trans('ip.vendor'))
->searchable()
->preload()
->native(false),
-
- Placeholder::make('vendor_info')
- ->label(trans('ip.vendor'))
- ->content(fn (Get $get) => optional($get('vendor'))->company_name ?? '-')
- ->visible(fn (Get $get) => filled($get('vendor_id'))),
- ])
- ->columnSpan(1),
-
- Section::make(trans('ip.details'))
- ->schema([
- TextInput::make('expense_number')
- ->required()
- ->default(function (Get $get, string $operation) {
- if ($operation !== 'create') {
- return; // Don't generate number for edit operations
- }
-
- $user = auth()->user();
- $companyId = $user?->getCurrentCompanyId();
-
- if (config('app.extreme_logging')) {
- Log::debug('ExpenseForm: Initializing ExpenseNumberGenerator', [
- 'company_id' => $companyId,
- 'expense_status' => $get('expense_status'),
- 'user_id' => $user?->id,
- 'session_company_id' => session('current_company_id'),
- 'trace' => debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 5),
- ]);
- }
-
- $generator = new ExpenseNumberGenerator($companyId);
-
- if (config('app.extreme_logging')) {
- Log::debug('ExpenseForm: Generating number', [
- 'status' => $get('expense_status'),
- 'is_draft' => ($get('expense_status') ?? '') !== ExpenseStatus::DRAFT->value,
- 'company_id' => auth()->user()?->company_id,
- 'trace' => debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 5),
- ]);
- }
-
- $number = $generator->generate();
-
- if (config('app.extreme_logging')) {
- Log::debug('ExpenseForm: Generated number', [
- 'number' => $number,
- 'company_id' => auth()->user()?->company_id,
- ]);
- }
-
- return $number;
- })
- ->dehydrated()
- ->required(),
- Select::make('expense_status')
- ->options(ExpenseStatus::options())
- ->searchable()
- ->preload()
- ->required(),
- Select::make('category_id')
- ->relationship('expenseCategory', 'category_name')
- ->label(trans('ip.category'))
- ->required()
- ->searchable()
- ->preload(),
- Select::make('expense_type')
- ->options(ExpenseType::options())
- ->searchable()
- ->preload()
- ->required(),
- TextInput::make('expense_amount')
- ->numeric()
- ->required(),
- DatePicker::make('expensed_at')
- ->required(),
- ])
- ->columnSpan(1),
+ ]),
]),
Section::make(trans('ip.expense_items'))
+ ->icon(Heroicon::OutlinedListBullet)
+ ->columnSpanFull()
->schema([
Repeater::make('expenseItems')
->defaultItems(0)
@@ -150,71 +149,66 @@ public static function configure(Schema $schema): Schema
->label(trans('ip.expense_items'))
->reorderable()
->addActionLabel(trans('ip.add_new_row'))
- ->columns(6) // Adjust columns to control field widths
->schema([
- Select::make('item_id')
- ->label(trans('ip.item'))
- ->options(Product::pluck('product_name', 'id')->toArray())
- ->searchable()
- ->preload()
- ->required(),
- TextInput::make('quantity')->numeric()->required(),
- TextInput::make('price')->numeric()->required(),
- TextInput::make('discount')->numeric()->default(0),
- TextInput::make('subtotal')->numeric()->default(0)->disabled(),
- ])
- ->collapsed(false)
- ->afterStateUpdated(fn ($set, $get) => (new ExpenseCalculator())->updateGrandTotal($set, $get, 'expenseItems', 'subtotal', 'expense_item_subtotal')),
- ])
- ->columnSpanFull(),
+ Grid::make(12)
+ ->schema([
+ Select::make('item_id')
+ ->label(trans('ip.item'))
+ ->prefixIcon(Heroicon::OutlinedCube)
+ ->options(Product::query()->pluck('product_name', 'id')->toArray())
+ ->searchable()
+ ->preload()
+ ->required()
+ ->dehydrated()
+ ->columnSpan(4),
- Section::make(trans('ip.expense_totals'))
- ->schema([
- Grid::make(2)
- ->schema([
- Group::make()
- ->schema([]), // Left side: Empty (future use)
+ TextInput::make('quantity')
+ ->label(trans('ip.quantity'))
+ ->prefixIcon(Heroicon::OutlinedHashtag)
+ ->numeric()
+ ->required()
+ ->dehydrated()
+ ->columnSpan(2),
- Group::make()
- ->schema([
- TextInput::make('expense_item_subtotal')
- ->label(trans('ip.subtotal'))
- ->disabled()
+ TextInput::make('price')
+ ->label(trans('ip.price'))
+ ->prefixIcon(Heroicon::OutlinedCurrencyDollar)
+ ->numeric()
+ ->required()
->dehydrated()
- ->reactive()
- ->afterStateUpdated(fn ($set, $get) => (new ExpenseCalculator())->updateGrandTotal($set, $get, 'expenseItems', 'subtotal', 'expense_item_subtotal'))
- ->extraAttributes(['class' => 'text-right']),
-
- TextInput::make('expense_discount_amount')
- ->label(trans('ip.discount_amount'))
- ->nullable()
- ->extraAttributes(['class' => 'text-right']),
-
- TextInput::make('expense_discount_percent')
- ->label(trans('ip.discount_percent'))
- ->nullable()
- ->extraAttributes(['class' => 'text-right']),
-
- TextInput::make('expense_tax_total')
- ->label(trans('ip.tax_total'))
- ->disabled()
- ->extraAttributes(['class' => 'text-right']),
+ ->columnSpan(2),
- TextInput::make('expense_total')
- ->label(trans('ip.total'))
+ TextInput::make('discount')
+ ->label(trans('ip.discount'))
+ ->prefixIcon(Heroicon::OutlinedMinusCircle)
+ ->numeric()
+ ->default(0)
+ ->dehydrated()
+ ->columnSpan(2),
+
+ TextInput::make('subtotal')
+ ->label(trans('ip.subtotal'))
+ ->prefixIcon(Heroicon::OutlinedBanknotes)
+ ->numeric()
+ ->default(0)
+ ->dehydrated()
->disabled()
- ->extraAttributes(['class' => 'text-right']),
+ ->columnSpan(2),
]),
- ]),
- ])
- ->collapsed(true)
- ->columnSpanFull(),
+ ])
+ ->columns(1)
+ ->reactive()
+ ->afterStateUpdated(fn (callable $set, callable $get) => (new ExpenseCalculator())->updateGrandTotal($set, $get, 'expenseItems', 'subtotal', 'expense_item_subtotal')),
+ ]),
Section::make(trans('ip.expense_notes'))
+ ->icon(Heroicon::OutlinedPencilSquare)
->schema([
MarkdownEditor::make('description')
+ ->label(trans('ip.expense_notes'))
->toolbarButtons(['bold', 'italic']),
])
+ ->collapsed()
->columnSpanFull(),
]);
}
diff --git a/Modules/Expenses/Filament/Company/Resources/Expenses/Tables/ExpensesTable.php b/Modules/Expenses/Filament/Company/Resources/Expenses/Tables/ExpensesTable.php
index 03f2f204a..3798a68c0 100644
--- a/Modules/Expenses/Filament/Company/Resources/Expenses/Tables/ExpensesTable.php
+++ b/Modules/Expenses/Filament/Company/Resources/Expenses/Tables/ExpensesTable.php
@@ -14,6 +14,7 @@
use Modules\Core\Helpers\EnumHelper;
use Modules\Expenses\Enums\ExpenseStatus;
use Modules\Expenses\Enums\ExpenseType;
+use Modules\Expenses\Filament\Company\Resources\Expenses\ExpenseResource;
use Modules\Expenses\Models\Expense;
use Modules\Expenses\Services\ExpenseService;
@@ -79,10 +80,9 @@ public static function configure(Table $table): Table
ActionGroup::make([
EditAction::make('edit')
->visible(fn () => auth()->user()?->can(Permission::EDIT_EXPENSES->value))
- ->action(function (Expense $record, array $data) {
- app(ExpenseService::class)->updateExpense($record, $data);
- })
- ->modalWidth('full'),
+ ->url(fn (Expense $record): string => ExpenseResource::getUrl('edit', [
+ 'record' => $record,
+ ])),
DeleteAction::make('delete')
->visible(fn () => auth()->user()?->can(Permission::DELETE_EXPENSES->value))
->action(function (Expense $record, array $data) {
diff --git a/Modules/Expenses/Tests/Feature/ExpensesTest.php b/Modules/Expenses/Tests/Feature/ExpensesTest.php
index ea47ac2ff..87d3ab34c 100644
--- a/Modules/Expenses/Tests/Feature/ExpensesTest.php
+++ b/Modules/Expenses/Tests/Feature/ExpensesTest.php
@@ -62,438 +62,6 @@ public function it_lists_expenses(): void
}
# endregion
- # region modals
- #[Test]
- #[Group('crud')]
- public function it_creates_an_expense_through_a_modal(): void
- {
- /* Arrange */
- $customer = Relation::factory()->for($this->company)->customer()->create();
- $category = ExpenseCategory::factory()->for($this->company)->create();
- $taxRate = TaxRate::factory()->for($this->company)->create();
- $productCategory = ProductCategory::factory()->for($this->company)->create();
- $productUnit = ProductUnit::factory()->for($this->company)->create();
- $product = Product::factory()->for($this->company)->create([
- 'category_id' => $productCategory->id,
- 'unit_id' => $productUnit->id,
- 'tax_rate_id' => $taxRate->id,
- 'tax_rate_2_id' => null,
- ]);
-
- $payload = [
- 'customer_id' => $customer->id,
- 'category_id' => $category->id,
- 'expense_type' => ExpenseType::FIXED->value,
- 'expense_status' => ExpenseStatus::DRAFT->value,
- 'expense_number' => 'EXP-001',
- 'expense_amount' => 120.0000,
- 'expensed_at' => '2026-01-11 00:00:00',
- 'description' => 'Office chairs',
- 'expenseItems' => [
- [
- 'item_id' => $product->id,
- 'quantity' => 2,
- 'price' => 60,
- 'discount' => 0,
- 'subtotal' => 120,
- ],
- ],
- ];
-
- /* Act */
- $component = Livewire::actingAs($this->user)
- ->test(ListExpenses::class)
- ->mountAction('create')
- ->fillForm($payload)
- ->callMountedAction();
-
- /* Assert */
- $component->assertHasNoFormErrors();
- $expectedPayload = Arr::except($payload, ['expenseItems']);
- $this->assertDatabaseHas('expenses', $expectedPayload);
- }
-
- #[Test]
- #[Group('crud')]
- public function it_fails_to_create_expense_through_a_modal_without_required_expense_number(): void
- {
- /* Arrange */
- $category = ExpenseCategory::factory()->for($this->company)->create();
- $customer = Relation::factory()->for($this->company)->customer()->create();
- $taxRate = TaxRate::factory()->for($this->company)->create();
- $productCategory = ProductCategory::factory()->for($this->company)->create();
- $productUnit = ProductUnit::factory()->for($this->company)->create();
- $product = Product::factory()->for($this->company)->create([
- 'category_id' => $productCategory->id,
- 'unit_id' => $productUnit->id,
- 'tax_rate_id' => $taxRate->id,
- 'tax_rate_2_id' => null,
- ]);
-
- $payload = [
- 'expense_amount' => 120.00,
- 'expensed_at' => now()->format('Y-m-d'),
- 'category_id' => $category->id,
- 'customer_id' => $customer->id,
- 'expense_type' => ExpenseType::ONE_TIME,
- 'expense_status' => ExpenseStatus::APPROVED,
- 'expenseItems' => [
- [
- 'item_id' => $product->id,
- 'quantity' => 2,
- 'price' => 10,
- 'discount' => 0,
- 'subtotal' => 20,
- 'is_recurring' => false,
- 'tax_1' => 2,
- 'tax_2' => 1,
- ],
- ],
- ];
-
- /* Act */
- $component = Livewire::actingAs($this->user)
- ->test(ListExpenses::class)
- ->mountAction('create')
- ->fillForm($payload)
- ->callMountedAction();
-
- /* Assert */
- $component->assertHasFormErrors(['expense_number' => 'required']);
- }
-
- #[Test]
- #[Group('crud')]
- public function it_fails_to_create_expense_through_a_modal_without_required_expensed_at(): void
- {
- /* Arrange */
- $category = ExpenseCategory::factory()->for($this->company)->create();
- $customer = Relation::factory()->for($this->company)->customer()->create();
- $taxRate = TaxRate::factory()->for($this->company)->create();
- $productCategory = ProductCategory::factory()->for($this->company)->create();
- $productUnit = ProductUnit::factory()->for($this->company)->create();
- $product = Product::factory()->for($this->company)->create([
- 'category_id' => $productCategory->id,
- 'unit_id' => $productUnit->id,
- 'tax_rate_id' => $taxRate->id,
- 'tax_rate_2_id' => null,
- ]);
-
- $payload = [
- 'expense_number' => 'EXP-4585487',
- 'expense_amount' => 120.00,
- 'category_id' => $category->id,
- 'customer_id' => $customer->id,
- 'expense_type' => ExpenseType::ONE_TIME,
- 'expense_status' => ExpenseStatus::APPROVED,
- 'expenseItems' => [
- [
- 'item_id' => $product->id,
- 'quantity' => 2,
- 'price' => 10,
- 'discount' => 0,
- 'subtotal' => 20,
- 'is_recurring' => false,
- 'tax_1' => 2,
- 'tax_2' => 1,
- ],
- ],
- ];
-
- /* Act */
- $component = Livewire::actingAs($this->user)
- ->test(ListExpenses::class)
- ->mountAction('create')
- ->fillForm($payload)
- ->callMountedAction();
-
- /* Assert */
- $component->assertHasFormErrors(['expensed_at' => 'required']);
- }
-
- #[Test]
- #[Group('crud')]
- public function it_fails_to_create_expense_through_a_modal_without_required_amount(): void
- {
- /* Arrange */
- $category = ExpenseCategory::factory()->for($this->company)->create();
- $customer = Relation::factory()->for($this->company)->customer()->create();
- $taxRate = TaxRate::factory()->for($this->company)->create();
- $productCategory = ProductCategory::factory()->for($this->company)->create();
- $productUnit = ProductUnit::factory()->for($this->company)->create();
- $product = Product::factory()->for($this->company)->create([
- 'category_id' => $productCategory->id,
- 'unit_id' => $productUnit->id,
- 'tax_rate_id' => $taxRate->id,
- 'tax_rate_2_id' => null,
- ]);
-
- $payload = [
- 'expense_number' => 'EXP-4585487',
- 'expensed_at' => now()->format('Y-m-d'),
- 'category_id' => $category->id,
- 'customer_id' => $customer->id,
- 'expense_type' => ExpenseType::ONE_TIME,
- 'expense_status' => ExpenseStatus::APPROVED,
- 'expenseItems' => [
- [
- 'item_id' => $product->id,
- 'quantity' => 2,
- 'price' => 10,
- 'discount' => 0,
- 'subtotal' => 20,
- 'is_recurring' => false,
- 'tax_1' => 2,
- 'tax_2' => 1,
- ],
- ],
- ];
-
- /* Act */
- $component = Livewire::actingAs($this->user)
- ->test(ListExpenses::class)
- ->mountAction('create')
- ->fillForm($payload)
- ->callMountedAction();
-
- /* Assert */
- $component->assertHasFormErrors(['expense_amount' => 'required']);
- }
-
- #[Test]
- #[Group('crud')]
- public function it_fails_to_create_expense_through_a_modal_without_required_category_id(): void
- {
- /* Arrange */
- $category = ExpenseCategory::factory()->for($this->company)->create();
- $customer = Relation::factory()->for($this->company)->customer()->create();
- $taxRate = TaxRate::factory()->for($this->company)->create();
- $productCategory = ProductCategory::factory()->for($this->company)->create();
- $productUnit = ProductUnit::factory()->for($this->company)->create();
- $product = Product::factory()->for($this->company)->create([
- 'category_id' => $productCategory->id,
- 'unit_id' => $productUnit->id,
- 'tax_rate_id' => $taxRate->id,
- 'tax_rate_2_id' => null,
- ]);
-
- $payload = [
- 'expense_number' => 'EXP-4585487',
- 'expense_amount' => 120.00,
- 'expensed_at' => now()->format('Y-m-d'),
- 'customer_id' => $customer->id,
- 'expense_type' => ExpenseType::ONE_TIME,
- 'expense_status' => ExpenseStatus::APPROVED,
- 'expenseItems' => [
- [
- 'item_id' => $product->id,
- 'quantity' => 2,
- 'price' => 10,
- 'discount' => 0,
- 'subtotal' => 20,
- 'is_recurring' => false,
- 'tax_1' => 2,
- 'tax_2' => 1,
- ],
- ],
- ];
-
- /* Act */
- $component = Livewire::actingAs($this->user)
- ->test(ListExpenses::class)
- ->mountAction('create')
- ->fillForm($payload)
- ->callMountedAction();
-
- /* Assert */
- $component->assertHasFormErrors(['category_id' => 'required']);
- }
-
- #[Test]
- #[Group('crud')]
- public function it_fails_to_create_expense_through_a_modal_without_required_customer(): void
- {
- /* Arrange */
- $category = ExpenseCategory::factory()->for($this->company)->create();
- $customer = Relation::factory()->for($this->company)->customer()->create();
- $taxRate = TaxRate::factory()->for($this->company)->create();
- $productCategory = ProductCategory::factory()->for($this->company)->create();
- $productUnit = ProductUnit::factory()->for($this->company)->create();
- $product = Product::factory()->for($this->company)->create([
- 'category_id' => $productCategory->id,
- 'unit_id' => $productUnit->id,
- 'tax_rate_id' => $taxRate->id,
- 'tax_rate_2_id' => null,
- ]);
-
- $payload = [
- 'expense_number' => 'EXP-4585487',
- 'expense_amount' => 120.00,
- 'expensed_at' => now()->format('Y-m-d'),
- 'category_id' => $category->id,
- 'expense_type' => ExpenseType::ONE_TIME->value,
- 'expense_status' => ExpenseStatus::APPROVED->value,
- 'expenseItems' => [
- [
- 'item_id' => $product->id,
- 'quantity' => 2,
- 'price' => 10,
- 'discount' => 0,
- 'subtotal' => 20,
- 'is_recurring' => false,
- 'tax_1' => 2,
- 'tax_2' => 1,
- ],
- ],
- ];
-
- /* Act */
- $component = Livewire::actingAs($this->user)
- ->test(ListExpenses::class)
- ->mountAction('create')
- ->fillForm($payload)
- ->callMountedAction();
-
- /* Assert */
- $component->assertHasFormErrors(['customer_id' => 'required']);
- }
-
- #[Test]
- #[Group('crud')]
- public function it_fails_to_create_expense_through_a_modal_without_required_type(): void
- {
- /* Arrange */
- $customer = Relation::factory()->for($this->company)->customer()->create();
- $category = ExpenseCategory::factory()->for($this->company)->create();
- $taxRate = TaxRate::factory()->for($this->company)->create();
- $productCategory = ProductCategory::factory()->for($this->company)->create();
- $productUnit = ProductUnit::factory()->for($this->company)->create();
- $product = Product::factory()->for($this->company)->create([
- 'category_id' => $productCategory->id,
- 'unit_id' => $productUnit->id,
- 'tax_rate_id' => $taxRate->id,
- 'tax_rate_2_id' => null,
- ]);
-
- $payload = [
- 'customer_id' => $customer->id,
- 'category_id' => $category->id,
- 'expense_status' => ExpenseStatus::DRAFT->value,
- 'expense_number' => 'EXP-002',
- 'expense_amount' => 50.00,
- 'expensed_at' => now()->format('Y-m-d'),
- 'description' => 'Pens',
- 'expenseItems' => [
- [
- 'item_id' => $product->id,
- 'quantity' => 1,
- 'price' => 50,
- 'discount' => 0,
- 'subtotal' => 50,
- ],
- ],
- ];
-
- /* Act */
- $component = Livewire::actingAs($this->user)
- ->test(ListExpenses::class)
- ->mountAction('create')
- ->fillForm($payload)
- ->callMountedAction();
-
- /* Assert */
- $component
- ->assertHasFormErrors(['expense_type' => 'required']);
-
- $this->assertDatabaseMissing('expenses', Arr::except($payload, ['expenseItems']));
- }
-
- #[Test]
- #[Group('crud')]
- public function it_fails_to_create_expense_through_a_modal_without_required_status(): void
- {
- /* Arrange */
- $category = ExpenseCategory::factory()->for($this->company)->create();
- $customer = Relation::factory()->for($this->company)->customer()->create();
- $taxRate = TaxRate::factory()->for($this->company)->create();
- $productCategory = ProductCategory::factory()->for($this->company)->create();
- $productUnit = ProductUnit::factory()->for($this->company)->create();
- $product = Product::factory()->for($this->company)->create([
- 'category_id' => $productCategory->id,
- 'unit_id' => $productUnit->id,
- 'tax_rate_id' => $taxRate->id,
- 'tax_rate_2_id' => null,
- ]);
-
- $payload = [
- 'expense_number' => 'EXP-4585487',
- 'expense_amount' => 120.00,
- 'expensed_at' => now()->format('Y-m-d'),
- 'category_id' => $category->id,
- 'customer_id' => $customer->id,
- 'expense_type' => ExpenseType::ONE_TIME,
- 'expenseItems' => [
- [
- 'item_id' => $product->id,
- 'quantity' => 2,
- 'price' => 10,
- 'discount' => 0,
- 'subtotal' => 20,
- 'is_recurring' => false,
- 'tax_1' => 2,
- 'tax_2' => 1,
- ],
- ],
- ];
-
- /* Act */
- $component = Livewire::actingAs($this->user)
- ->test(ListExpenses::class)
- ->mountAction('create')
- ->fillForm($payload)
- ->callMountedAction();
-
- /* Assert */
- $component->assertHasFormErrors(['expense_status' => 'required']);
- }
-
- #[Test]
- #[Group('crud')]
- public function it_updates_an_expense_through_a_modal(): void
- {
- /* Arrange */
- $customer = Relation::factory()->for($this->company)->customer()->create();
- $category = ExpenseCategory::factory()->for($this->company)->create();
-
- $expense = Expense::factory()->for($this->company)->create([
- 'customer_id' => $customer->id,
- 'category_id' => $category->id,
- 'expense_type' => ExpenseType::FIXED->value,
- 'expense_status' => ExpenseStatus::DRAFT->value,
- ]);
-
- $payload = ['expense_type' => ExpenseType::RECURRING];
-
- /* Act */
- $component = Livewire::actingAs($this->user)
- ->test(ListExpenses::class, ['record' => $expense->id])
- ->mountAction(TestAction::make('edit')->table($expense), $payload)
- ->fillForm($payload)
- ->callMountedAction();
-
- /* Assert */
- $component
- ->assertSuccessful()
- ->assertHasNoErrors();
-
- /* Assert */
- $this->assertDatabaseHas('expenses', [
- 'id' => $expense->id,
- 'expense_type' => ExpenseType::RECURRING,
- ]);
- }
- # endregion
-
# region crud
#[Test]
#[Group('crud')]
@@ -582,9 +150,9 @@ public function it_fails_to_create_without_required_expense_number(): void
];
$component = Livewire::actingAs($this->user)
- ->test(ListExpenses::class)
- //->fillForm($payload)
- ->callAction('create', data: $payload);
+ ->test(CreateExpense::class)
+ ->fillForm($payload)
+ ->call('create');
/* Assert */
$component->assertHasFormErrors(['expense_number' => 'required']);
diff --git a/Modules/Invoices/Filament/Company/Resources/Invoices/Pages/ListInvoices.php b/Modules/Invoices/Filament/Company/Resources/Invoices/Pages/ListInvoices.php
index 2a22fb055..4e83adbe0 100644
--- a/Modules/Invoices/Filament/Company/Resources/Invoices/Pages/ListInvoices.php
+++ b/Modules/Invoices/Filament/Company/Resources/Invoices/Pages/ListInvoices.php
@@ -2,11 +2,9 @@
namespace Modules\Invoices\Filament\Company\Resources\Invoices\Pages;
-use Filament\Actions\Action;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
use Modules\Invoices\Filament\Company\Resources\Invoices\InvoiceResource;
-use Modules\Invoices\Services\InvoiceService;
class ListInvoices extends ListRecords
{
@@ -16,19 +14,7 @@ protected function getHeaderActions(): array
{
return [
CreateAction::make()
- ->modalWidth('full')
- ->mutateDataUsing(function (array $data) {
- return $data;
- })
- ->action(function (array $data, Action $action) {
- $invoice = app(InvoiceService::class)->createInvoice($data);
-
- if (filled($invoice->invoice_number)) {
- $action->successNotificationTitle(
- trans('ip.invoice_created_with_number', ['number' => $invoice->invoice_number])
- );
- }
- }),
+ ->url(fn (): string => InvoiceResource::getUrl('create')),
];
}
}
diff --git a/Modules/Invoices/Filament/Company/Resources/Invoices/Schemas/InvoiceForm.php b/Modules/Invoices/Filament/Company/Resources/Invoices/Schemas/InvoiceForm.php
index fdefbdcf9..396891dce 100644
--- a/Modules/Invoices/Filament/Company/Resources/Invoices/Schemas/InvoiceForm.php
+++ b/Modules/Invoices/Filament/Company/Resources/Invoices/Schemas/InvoiceForm.php
@@ -6,16 +6,15 @@
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\MarkdownEditor;
-use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Infolists\Components\TextEntry;
-use Filament\Schemas;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Schema;
+use Filament\Support\Icons\Heroicon;
use Modules\Clients\Enums\RelationType;
use Modules\Clients\Services\RelationService;
use Modules\Core\Enums\NumberingType;
@@ -35,37 +34,30 @@ public static function configure(Schema $schema): Schema
{
return $schema
->components([
- //
- // Top two-column: Client on left, Details on right
- //
- Grid::make(5)
+ Grid::make(3)
->columnSpanFull()
->schema([
- Schemas\Components\Group::make()
- ->columnSpan(3)
+ Section::make(trans('ip.details'))
+ ->icon(Heroicon::OutlinedDocumentText)
+ ->columnSpan(2)
->schema([
- Section::make(trans('ip.client'))
+ Grid::make(4)
->schema([
Select::make('customer_id')
->label(trans('ip.client'))
+ ->prefixIcon(Heroicon::OutlinedUserGroup)
->relationship('customer', 'company_name')
->searchable()
->preload()
->required()
+ ->columnSpan(2)
->createOptionForm([
TextInput::make('company_name')
->label(trans('ip.customer_name'))
->required()
- // relations.company_name is varchar(150) —
- // match RelationForm / ContactForm.
->maxLength(150),
])
->createOptionUsing(function (array $data): int {
- // Filament's default createOptionUsing() does a
- // raw Relation::create($data), which omits
- // relation_type/relation_number/registered_at —
- // all NOT NULL with no DB default — and 500s.
- // RelationService::createRelation() fills those.
return app(RelationService::class)->createRelation([
'relation_type' => RelationType::CUSTOMER->value,
'company_name' => $data['company_name'],
@@ -73,20 +65,26 @@ public static function configure(Schema $schema): Schema
})
->reactive(),
- Placeholder::make('customer_info')
- ->label(trans('ip.client_information'))
- ->content(fn ($get) => optional($get('customer'))->company_name ?? '-'),
- ]),
- ]),
+ Select::make('numbering_id')
+ ->label(trans('ip.numbering'))
+ ->prefixIcon(Heroicon::OutlinedQueueList)
+ ->relationship('numbering', 'name', fn ($query) => $query->where('type', NumberingType::INVOICE->value))
+ ->required()
+ ->searchable()
+ ->preload()
+ ->native(false)
+ ->columnSpan(2)
+ ->exists(
+ table: 'numbering',
+ column: 'id',
+ modifyRuleUsing: fn ($rule) => $rule
+ ->where('type', NumberingType::INVOICE->value)
+ ->where('company_id', Filament::getTenant()?->id),
+ ),
- Schemas\Components\Group::make()
- ->columnSpan(2)
- ->schema([
- Section::make(trans('ip.details'))
- ->columns(2)
- ->schema([
TextInput::make('invoice_number')
->label(trans('ip.invoice_number'))
+ ->prefixIcon(Heroicon::OutlinedHashtag)
->required()
->default(function (Get $get, string $operation) {
if ($operation !== 'create') {
@@ -95,10 +93,12 @@ public static function configure(Schema $schema): Schema
return self::generateInvoiceNumber($get);
})
+ ->columnSpan(2)
->dehydrated(),
Select::make('invoice_status')
->label(trans('ip.invoice_status'))
+ ->prefixIcon(Heroicon::OutlinedCheckBadge)
->options(
collect(InvoiceStatus::cases())
->mapWithKeys(fn ($s) => [$s->value => trans($s->label())])
@@ -114,6 +114,7 @@ public static function configure(Schema $schema): Schema
->native(false)
->required()
->reactive()
+ ->columnSpan(2)
->afterStateUpdated(function (callable $set, Get $get, string $operation): void {
// Only (re)generate on create, and only when the field is still
// empty -- never clobber a number the user already typed or one
@@ -127,59 +128,86 @@ public static function configure(Schema $schema): Schema
DatePicker::make('invoiced_at')
->label(trans('ip.invoice_date'))
+ ->prefixIcon(Heroicon::OutlinedCalendar)
->default(now())
+ ->columnSpan(2)
->required(),
DatePicker::make('invoice_due_at')
->label(trans('ip.invoice_due_at'))
+ ->columnSpan(2)
+ ->prefixIcon(Heroicon::OutlinedCalendarDays)
->required(),
- Placeholder::make('last_reminder_sent')
- ->label(trans('ip.last_reminder_sent'))
- ->visible(fn (string $operation): bool => $operation === 'edit')
- ->content(function (?Invoice $record) {
- $lastSentAt = $record ? app(InvoiceService::class)->lastReminderSentAt($record) : null;
-
- return $lastSentAt
- ? DateHelpers::formatDate($lastSentAt)
- : trans('ip.reminder_never_sent');
- }),
-
- Select::make('numbering_id')
- ->label(trans('ip.numbering'))
- ->relationship('numbering', 'name', fn ($query) => $query->where('type', NumberingType::INVOICE->value))
- ->required()
- ->searchable()
- ->preload()
- ->native(false)
- ->exists(
- table: 'numbering',
- column: 'id',
- modifyRuleUsing: fn ($rule) => $rule
- ->where('type', NumberingType::INVOICE->value)
- ->where('company_id', Filament::getTenant()?->id),
- ),
-
TextInput::make('client_reference')
->label(trans('ip.client_reference'))
+ ->prefixIcon(Heroicon::OutlinedBookmark)
->maxLength(255),
TextInput::make('work_order')
->label(trans('ip.work_order'))
+ ->prefixIcon(Heroicon::OutlinedClipboardDocumentList)
->maxLength(255),
TextInput::make('invoice_password')
- ->label(trans('ip.invoice_password')),
+ ->label(trans('ip.invoice_password'))
+ ->prefixIcon(Heroicon::OutlinedLockClosed)
+ ->columnSpan(2),
+
+ TextEntry::make('last_reminder_sent')
+ ->label(trans('ip.last_reminder_sent'))
+ ->visible(fn (string $operation): bool => $operation === 'edit')
+ ->columnSpan(2)
+ ->state(function (?Invoice $record) {
+ $lastSentAt = $record ? app(InvoiceService::class)->lastReminderSentAt($record) : null;
+
+ return $lastSentAt
+ ? DateHelpers::formatDate($lastSentAt)
+ : trans('ip.reminder_never_sent');
+ }),
]),
]),
+
+ Section::make(trans('ip.invoice_amounts'))
+ ->icon(Heroicon::OutlinedCalculator)
+ ->columnSpan(1)
+ ->columns(2)
+ ->schema([
+ TextInput::make('invoice_item_subtotal')
+ ->label(trans('ip.subtotal'))
+ ->prefixIcon(Heroicon::OutlinedBanknotes)
+ ->disabled()
+ ->dehydrated()
+ ->reactive()
+ ->afterStateUpdated(function (callable $set, callable $get): void {
+ (new InvoiceCalculator())->updateGrandTotal($set, $get);
+ }),
+
+ TextInput::make('invoice_discount_amount')
+ ->label(trans('ip.discount_amount'))
+ ->prefixIcon(Heroicon::OutlinedMinusCircle)
+ ->nullable(),
+
+ TextInput::make('invoice_discount_percent')
+ ->label(trans('ip.discount_percent'))
+ ->prefixIcon(Heroicon::OutlinedReceiptPercent)
+ ->nullable(),
+
+ TextInput::make('invoice_tax_total')
+ ->label(trans('ip.tax_total'))
+ ->prefixIcon(Heroicon::OutlinedBuildingLibrary)
+ ->disabled(),
+
+ TextInput::make('invoice_total')
+ ->label(trans('ip.invoice_total'))
+ ->prefixIcon(Heroicon::OutlinedCurrencyDollar)
+ ->disabled(),
+ ]),
]),
- //
- // Items repeater (always visible, not collapsed)
- //
- // Invoice Items
Section::make(trans('ip.invoice_items'))
- ->collapsed()
+ ->icon(Heroicon::OutlinedListBullet)
+ ->columnSpanFull()
->schema([
Repeater::make('invoiceItems')
->defaultItems(0)
@@ -187,137 +215,99 @@ public static function configure(Schema $schema): Schema
->label(trans('ip.invoice_items'))
->reorderable()
->addActionLabel(trans('ip.add_new_row'))
- //->dehydrated()
->schema([
- Grid::make(6) // Adjust the number of columns as needed
+ Grid::make(12)
->schema([
Select::make('product_id')
->label(trans('ip.product'))
+ ->prefixIcon(Heroicon::OutlinedCube)
->options(Product::query()->pluck('product_name', 'id')->toArray())
->searchable()
->preload()
->required()
- ->dehydrated(),
-
- TextEntry::make('product_name')
- ->state(fn ($get) => Product::query()->find($get('product_id'))?->product_name)
- ->disabled(),
+ ->dehydrated()
+ ->columnSpan(4),
TextInput::make('quantity')
+ ->label(trans('ip.quantity'))
+ ->prefixIcon(Heroicon::OutlinedHashtag)
->numeric()
->required()
- ->dehydrated(),
+ ->dehydrated()
+ ->columnSpan(2),
TextInput::make('price')
+ ->label(trans('ip.price'))
+ ->prefixIcon(Heroicon::OutlinedCurrencyDollar)
->numeric()
->required()
- ->dehydrated(),
+ ->dehydrated()
+ ->columnSpan(2),
TextInput::make('discount')
+ ->label(trans('ip.discount'))
+ ->prefixIcon(Heroicon::OutlinedMinusCircle)
->numeric()
->default(0)
- ->dehydrated(),
+ ->dehydrated()
+ ->columnSpan(2),
TextInput::make('subtotal')
+ ->label(trans('ip.subtotal'))
+ ->prefixIcon(Heroicon::OutlinedBanknotes)
->numeric()
->default(0)
->dehydrated()
- ->disabled(),
+ ->disabled()
+ ->columnSpan(2),
]),
])
->columns(1)
->reactive()
- /*->afterStateHydrated(function ($component, $state) {
- // overwrite any stray default state with what the request provided
- if (is_array($state) && $state !== []) {
- // Normalize to numeric keys so Livewire/Filament don’t try to merge by UUID
- $component->rawState(array_values($state));
- }
- })*/
->afterStateUpdated(fn (callable $set, callable $get) => (new InvoiceCalculator())->updateGrandTotal($set, $get, 'invoiceItems', 'subtotal', 'invoice_item_subtotal')),
- ])
- ->columnSpanFull(),
+ ]),
- // Totals
- Section::make(trans('ip.invoice_amounts'))
- ->columns(2)
+ Section::make(trans('ip.notes'))
+ ->icon(Heroicon::OutlinedPencilSquare)
->schema([
- Grid::make(2)
- ->schema([
- // Left side reserved (e.g. “Add Item” button later)
- Schemas\Components\Group::make()->schema([]),
-
- // Right side: the actual totals
- Schemas\Components\Group::make()
- ->schema([
- TextInput::make('invoice_item_subtotal')
- ->label(trans('ip.subtotal'))
- ->inlineLabel()
- ->disabled()
- ->dehydrated()
- ->reactive()
- ->afterStateUpdated(function (callable $set, callable $get): void {
- (new InvoiceCalculator())->updateGrandTotal($set, $get);
- }),
-
- TextInput::make('invoice_discount_amount')
- ->label(trans('ip.discount_amount'))
- ->inlineLabel()
- ->nullable(),
-
- TextInput::make('invoice_discount_percent')
- ->label(trans('ip.discount_percent'))
- ->inlineLabel()
- ->nullable(),
-
- TextInput::make('invoice_tax_total')
- ->label(trans('ip.tax_total'))
- ->inlineLabel()
- ->disabled(),
-
- TextInput::make('invoice_total')
- ->label(trans('ip.invoice_total'))
- ->inlineLabel()
- ->disabled(),
- ]),
- ]),
+ MarkdownEditor::make('notes')
+ ->label(trans('ip.notes'))
+ ->toolbarButtons(['bold', 'italic'])
+ ->hintAction(InsertNoteTemplateAction::make('notes')),
])
->collapsed()
->columnSpanFull(),
- // Notes & Attachments
- Grid::make(2)
- ->columnSpanFull()
+ Section::make(trans('ip.attachments'))
+ ->icon(Heroicon::OutlinedPaperClip)
->schema([
- Section::make(trans('ip.notes'))
- ->collapsed()
- ->schema([
- MarkdownEditor::make('notes')
- ->label(trans('ip.notes'))
- ->toolbarButtons(['bold', 'italic'])
- ->hintAction(InsertNoteTemplateAction::make('notes')),
- ])
- ->columnSpan(1),
-
- Section::make(trans('ip.attachments'))
- ->collapsed()
- ->schema([
- FileUpload::make('attachments')
- ->label(trans('ip.attachments'))
- ->multiple(),
- ])
- ->columnSpan(1),
- ]),
+ FileUpload::make('attachments')
+ ->label(trans('ip.attachments'))
+ ->multiple(),
+ ])
+ ->collapsed()
+ ->columnSpanFull(),
- // Invoice Terms
Section::make(trans('ip.invoice_terms'))
- ->collapsed()
+ ->icon(Heroicon::OutlinedDocumentText)
->schema([
MarkdownEditor::make('invoice_terms')
->toolbarButtons(['bold', 'italic'])
->label(trans('ip.invoice_terms'))
+ ->default(function (string $operation) {
+ if ($operation !== 'create') {
+ return;
+ }
+
+ $companyId = Filament::getTenant()?->id;
+
+ return $companyId
+ ? Setting::getForCompany($companyId, Setting::KEY_INVOICE_DEFAULT_TERMS)
+ : null;
+ })
->hintAction(InsertNoteTemplateAction::make('invoice_terms')),
])
+ ->collapsed()
->columnSpanFull(),
]);
}
diff --git a/Modules/Invoices/Filament/Company/Resources/Invoices/Tables/InvoicesTable.php b/Modules/Invoices/Filament/Company/Resources/Invoices/Tables/InvoicesTable.php
index 2d065714f..bae7c1ff8 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;
@@ -100,33 +101,9 @@ public static function configure(Table $table): Table
ActionGroup::make([
EditAction::make()
->visible(fn () => auth()->user()?->can(Permission::EDIT_INVOICES->value))
- ->mutateDataUsing(function (array $data, Invoice $record) {
- $data['invoiceItems'] = $record->invoiceItems()->get()->map(function ($item) {
- $product = $item->product;
-
- return [
- 'id' => $item->id,
- 'product_id' => $item->product_id,
- 'product_name' => $product?->product_name ?? '',
- 'item_name' => $item->item_name,
- 'quantity' => $item->quantity,
- 'price' => $item->price,
- 'discount' => $item->discount,
- 'subtotal' => $item->subtotal,
- 'tax_1' => $item->tax_1,
- 'tax_2' => $item->tax_2,
- 'tax_rate_id' => $item->tax_rate_id,
- 'tax_rate_2_id' => $item->tax_rate_2_id,
- 'description' => $item->description,
- ];
- })->toArray();
-
- return $data;
- })
- ->action(function (Invoice $record, array $data) {
- app(\Modules\Invoices\Services\InvoiceService::class)->updateInvoice($record, $data);
- })
- ->modalWidth('full'),
+ ->url(fn (Invoice $record): string => InvoiceResource::getUrl('edit', [
+ 'record' => $record,
+ ])),
Action::make('copy')
->visible(fn () => auth()->user()?->can(Permission::DUPLICATE_INVOICES->value))
->label(trans('ip.copy_invoice'))
diff --git a/Modules/Invoices/Tests/Feature/CompanyRenameInvoicePreviewTest.php b/Modules/Invoices/Tests/Feature/CompanyRenameInvoicePreviewTest.php
index bb1026ec0..558c7356c 100644
--- a/Modules/Invoices/Tests/Feature/CompanyRenameInvoicePreviewTest.php
+++ b/Modules/Invoices/Tests/Feature/CompanyRenameInvoicePreviewTest.php
@@ -12,8 +12,8 @@
use Modules\Core\Models\TaxRate;
use Modules\Core\Models\User;
use Modules\Core\Tests\AbstractCompanyPanelTestCase;
+use Modules\Invoices\Filament\Company\Resources\Invoices\Pages\CreateInvoice;
use Modules\Invoices\Filament\Company\Resources\Invoices\Pages\EditInvoice;
-use Modules\Invoices\Filament\Company\Resources\Invoices\Pages\ListInvoices;
use Modules\Invoices\Models\Invoice;
use Modules\Products\Models\Product;
use Modules\Products\Models\ProductCategory;
@@ -65,12 +65,10 @@ public function editing_the_company_through_the_admin_panel_does_not_change_an_a
],
];
- /* Act — create the invoice through the real Filament "create" modal */
- Livewire::actingAs($this->user)->test(ListInvoices::class)
- ->mountAction('create')
+ /* Act — create the invoice through the real Filament "create" page */
+ Livewire::actingAs($this->user)->test(CreateInvoice::class)
->fillForm($payload)
- ->assertHasNoFormErrors()
- ->callMountedAction()
+ ->call('create')
->assertHasNoFormErrors();
$invoice = Invoice::query()->where('invoice_number', 'INV-CRP-001')->firstOrFail();
diff --git a/Modules/Invoices/Tests/Feature/InvoiceDefaultTermsPrefillTest.php b/Modules/Invoices/Tests/Feature/InvoiceDefaultTermsPrefillTest.php
new file mode 100644
index 000000000..4884f0ec4
--- /dev/null
+++ b/Modules/Invoices/Tests/Feature/InvoiceDefaultTermsPrefillTest.php
@@ -0,0 +1,78 @@
+company->id,
+ Setting::KEY_INVOICE_DEFAULT_TERMS,
+ 'Payment due within 30 days.'
+ );
+
+ /* Act */
+ $component = Livewire::actingAs($this->user)
+ ->test(CreateInvoice::class);
+
+ /* Assert */
+ $component->assertFormSet(['invoice_terms' => 'Payment due within 30 days.']);
+ }
+
+ #[Test]
+ public function it_leaves_invoice_terms_empty_when_no_company_setting_exists(): void
+ {
+ /* Act */
+ $component = Livewire::actingAs($this->user)
+ ->test(CreateInvoice::class);
+
+ /* Assert */
+ $component->assertFormSet(['invoice_terms' => null]);
+ }
+
+ #[Test]
+ public function it_does_not_leak_another_companys_default_terms(): void
+ {
+ /* Arrange */
+ $other = Company::factory()->create();
+ Setting::saveForCompany($other->id, Setting::KEY_INVOICE_DEFAULT_TERMS, 'Other company terms.');
+
+ /* Act */
+ $component = Livewire::actingAs($this->user)
+ ->test(CreateInvoice::class);
+
+ /* Assert */
+ $component->assertFormSet(['invoice_terms' => null]);
+ }
+
+ #[Test]
+ public function the_prefilled_terms_can_be_overridden_before_saving(): void
+ {
+ /* Arrange */
+ Setting::saveForCompany($this->company->id, Setting::KEY_INVOICE_DEFAULT_TERMS, 'Default terms.');
+
+ /* Act */
+ $component = Livewire::actingAs($this->user)
+ ->test(CreateInvoice::class)
+ ->fillForm(['invoice_terms' => 'Custom terms for this invoice.']);
+
+ /* Assert */
+ $component->assertFormSet(['invoice_terms' => 'Custom terms for this invoice.']);
+ }
+}
diff --git a/Modules/Invoices/Tests/Feature/InvoiceNumberGenerationOnCreateTest.php b/Modules/Invoices/Tests/Feature/InvoiceNumberGenerationOnCreateTest.php
index 8ec18b2da..536dbdc48 100644
--- a/Modules/Invoices/Tests/Feature/InvoiceNumberGenerationOnCreateTest.php
+++ b/Modules/Invoices/Tests/Feature/InvoiceNumberGenerationOnCreateTest.php
@@ -2,7 +2,6 @@
namespace Modules\Invoices\Tests\Feature;
-use Illuminate\Support\Str;
use Livewire\Livewire;
use Modules\Clients\Models\Relation;
use Modules\Core\Enums\NumberingType;
@@ -10,12 +9,12 @@
use Modules\Core\Models\Setting;
use Modules\Core\Tests\AbstractCompanyPanelTestCase;
use Modules\Invoices\Enums\InvoiceStatus;
-use Modules\Invoices\Filament\Company\Resources\Invoices\Pages\ListInvoices;
+use Modules\Invoices\Filament\Company\Resources\Invoices\Pages\CreateInvoice;
use Modules\Invoices\Models\Invoice;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
-#[CoversClass(ListInvoices::class)]
+#[CoversClass(CreateInvoice::class)]
class InvoiceNumberGenerationOnCreateTest extends AbstractCompanyPanelTestCase
{
#[Test]
@@ -35,10 +34,9 @@ public function it_auto_generates_an_invoice_number_on_create_when_none_is_suppl
/* Act */
$component = Livewire::actingAs($this->user)
- ->test(ListInvoices::class, ['tenant' => Str::lower($this->company->search_code)])
- ->mountAction('create')
+ ->test(CreateInvoice::class)
->fillForm($payload)
- ->callMountedAction();
+ ->call('create');
/* Assert */
$component->assertHasNoFormErrors();
@@ -73,10 +71,9 @@ public function it_does_not_auto_populate_invoice_number_for_drafts_when_the_set
/* Act */
$component = Livewire::actingAs($this->user)
- ->test(ListInvoices::class, ['tenant' => Str::lower($this->company->search_code)])
- ->mountAction('create')
+ ->test(CreateInvoice::class)
->fillForm($payload)
- ->callMountedAction();
+ ->call('create');
/* Assert */
$component->assertHasFormErrors(['invoice_number' => 'required']);
@@ -101,17 +98,16 @@ public function it_still_generates_an_invoice_number_for_non_draft_status_when_t
// invoice_status is set via a real Livewire property update (not
// fillForm, which bypasses afterStateUpdated hooks) so the form's
// reactive regeneration of invoice_number on status change actually
- // fires -- mirroring a user picking "Sent" interactively in the modal.
+ // fires -- mirroring a user picking "Sent" interactively on the create page.
$payload = $this->basePayload($customer->id, $numbering->id, InvoiceStatus::SENT->value);
unset($payload['invoice_status']);
/* Act */
$component = Livewire::actingAs($this->user)
- ->test(ListInvoices::class, ['tenant' => Str::lower($this->company->search_code)])
- ->mountAction('create')
- ->set('mountedActions.0.data.invoice_status', InvoiceStatus::SENT->value)
+ ->test(CreateInvoice::class)
+ ->set('data.invoice_status', InvoiceStatus::SENT->value)
->fillForm($payload)
- ->callMountedAction();
+ ->call('create');
/* Assert */
$component->assertHasNoFormErrors();
diff --git a/Modules/Invoices/Tests/Feature/InvoicesTest.php b/Modules/Invoices/Tests/Feature/InvoicesTest.php
index d1ce931f3..2bb4d9898 100644
--- a/Modules/Invoices/Tests/Feature/InvoicesTest.php
+++ b/Modules/Invoices/Tests/Feature/InvoicesTest.php
@@ -91,251 +91,6 @@ public function it_lists_invoices(): void
}
# endregion
- # region modals
- #[Test]
- #[Group('crud')]
- #[Group('failing')]
- public function it_creates_an_invoice_through_a_modal(): void
- {
- /* Arrange */
- $customer = Relation::factory()->for($this->company)->customer()->create();
- $documentGroup = Numbering::factory()->for($this->company)->state(['type' => NumberingType::INVOICE->value])->create();
- $taxRate = TaxRate::factory()->for($this->company)->create();
- $productCategory = ProductCategory::factory()->for($this->company)->create();
- $productUnit = ProductUnit::factory()->for($this->company)->create();
- $product = Product::factory()->for($this->company)->create([
- 'category_id' => $productCategory->id,
- 'unit_id' => $productUnit->id,
- 'tax_rate_id' => $taxRate->id,
- 'tax_rate_2_id' => null,
- ]);
-
- $payload = [
- 'invoice_number' => 'INV-987654',
- 'customer_id' => $customer->getKey(),
- 'numbering_id' => $documentGroup->getKey(),
- 'user_id' => $this->user->id,
- 'invoice_status' => 'draft',
- 'invoiced_at' => '2025-05-10',
- 'invoice_due_at' => '2025-06-09',
- 'invoiceItems' => [
- [
- 'product_id' => $product->getKey(),
- 'quantity' => 3,
- 'price' => 150,
- 'discount' => 0,
- ],
- ],
- ];
-
- /* Act */
- Livewire::actingAs($this->user)->test(ListInvoices::class)
- ->mountAction('create')
- ->fillForm($payload)
- ->assertHasNoFormErrors()
- ->callMountedAction()
- ->assertHasNoFormErrors();
-
- /* Assert */
- $expected = Arr::except($payload, ['invoiceItems', 'numbering_id']);
- if (isset($expected['invoiced_at'])) {
- $expected['invoiced_at'] = Carbon::parse($expected['invoiced_at'])->format('Y-m-d H:i:s');
- }
- if (isset($expected['invoice_due_at'])) {
- $expected['invoice_due_at'] = Carbon::parse($expected['invoice_due_at'])->format('Y-m-d H:i:s');
- }
- $this->assertDatabaseHas('invoices', $expected);
- }
-
- #[Test]
- #[Group('crud')]
- public function it_fails_to_create_invoice_through_a_modal_without_required_invoice_number(): void
- {
- /* Arrange */
- // Draft-number auto-generation is disabled so the still-required
- // invoice_number field isn't silently auto-filled, keeping this test
- // a genuine check of the required rule (see InvoiceForm's generator wiring).
- Setting::saveByKey('generate_invoice_number_for_draft', '0');
-
- $customer = Relation::factory()->for($this->company)->customer()->create();
- $documentGroup = Numbering::factory()->for($this->company)->state(['type' => NumberingType::INVOICE->value])->create();
- $taxRate = TaxRate::factory()->for($this->company)->create();
- $productCategory = ProductCategory::factory()->for($this->company)->create();
- $productUnit = ProductUnit::factory()->for($this->company)->create();
- $product = Product::factory()->for($this->company)->create([
- 'category_id' => $productCategory->id,
- 'unit_id' => $productUnit->id,
- 'tax_rate_id' => $taxRate->id,
- 'tax_rate_2_id' => null,
- ]);
-
- $payload = [
- 'customer_id' => $customer->getKey(),
- 'numbering_id' => $documentGroup->getKey(),
- 'user_id' => $this->user->id,
- 'invoice_status' => 'draft',
- 'invoiced_at' => '2025-05-10',
- 'invoice_due_at' => '2025-06-09',
- 'invoiceItems' => [
- [
- 'product_id' => $product->getKey(),
- 'quantity' => 3,
- 'price' => 150,
- 'discount' => 0,
- ],
- ],
- ];
-
- /* Act */
- $component = Livewire::actingAs($this->user)
- ->test(ListInvoices::class)
- ->mountAction('create')
- ->fillForm($payload)
- ->callMountedAction();
-
- /* Assert */
- $component->assertHasFormErrors(['invoice_number' => 'required']);
- }
-
- #[Test]
- #[Group('crud')]
- public function it_fails_to_create_invoice_through_a_modal_without_required_invoice_status(): void
- {
- /* Arrange */
- $customer = Relation::factory()->for($this->company)->customer()->create();
- $documentGroup = Numbering::factory()->for($this->company)->state(['type' => NumberingType::INVOICE->value])->create();
- $taxRate = TaxRate::factory()->for($this->company)->create();
- $productCategory = ProductCategory::factory()->for($this->company)->create();
- $productUnit = ProductUnit::factory()->for($this->company)->create();
- $product = Product::factory()->for($this->company)->create([
- 'category_id' => $productCategory->id,
- 'unit_id' => $productUnit->id,
- 'tax_rate_id' => $taxRate->id,
- 'tax_rate_2_id' => null,
- ]);
-
- $payload = [
- 'invoice_number' => 'INV-987654',
- 'customer_id' => $customer->getKey(),
- 'numbering_id' => $documentGroup->getKey(),
- 'user_id' => $this->user->id,
- 'invoiced_at' => '2025-05-10',
- 'invoice_due_at' => '2025-06-09',
- 'invoiceItems' => [
- [
- 'product_id' => $product->getKey(),
- 'quantity' => 3,
- 'price' => 150,
- 'discount' => 0,
- ],
- ],
- ];
-
- $component = Livewire::actingAs($this->user)
- ->test(ListInvoices::class)
- ->mountAction('create')
- ->fillForm($payload)
- ->callMountedAction();
-
- $component->assertHasFormErrors(['invoice_status' => 'required']);
- }
-
- #[Test]
- #[Group('crud')]
- public function it_fails_to_create_invoice_through_a_modal_without_required_customer(): void
- {
- /* Arrange */
- $customer = Relation::factory()->for($this->company)->customer()->create();
- $documentGroup = Numbering::factory()->for($this->company)->state(['type' => NumberingType::INVOICE->value])->create();
- $taxRate = TaxRate::factory()->for($this->company)->create();
- $productCategory = ProductCategory::factory()->for($this->company)->create();
- $productUnit = ProductUnit::factory()->for($this->company)->create();
- $product = Product::factory()->for($this->company)->create([
- 'category_id' => $productCategory->id,
- 'unit_id' => $productUnit->id,
- 'tax_rate_id' => $taxRate->id,
- 'tax_rate_2_id' => null,
- ]);
-
- $payload = [
- 'invoice_number' => 'INV-987654',
- 'numbering_id' => $documentGroup->getKey(),
- 'user_id' => $this->user->id,
- 'invoice_status' => 'draft',
- 'invoiced_at' => '2025-05-10',
- 'invoice_due_at' => '2025-06-09',
- 'invoiceItems' => [
- [
- 'product_id' => $product->getKey(),
- 'quantity' => 3,
- 'price' => 150,
- 'discount' => 0,
- ],
- ],
- ];
-
- /* Act */
- $component = Livewire::actingAs($this->user)
- ->test(ListInvoices::class)
- ->mountAction('create')
- ->fillForm($payload)
- ->callMountedAction();
-
- /* Assert */
- $component->assertHasFormErrors(['customer_id']);
- }
-
- #[Test]
- #[Group('crud')]
- #[Group('slow')]
- public function it_updates_an_invoice_through_a_modal(): void
- {
- /* Arrange */
- $customer = Relation::factory()->for($this->company)->customer()->create();
- $documentGroup = Numbering::factory()->for($this->company)->state(['type' => NumberingType::INVOICE->value])->create();
- $taxRate = TaxRate::factory()->for($this->company)->create();
- $productCategory = ProductCategory::factory()->for($this->company)->create();
- $productUnit = ProductUnit::factory()->for($this->company)->create();
- $product = Product::factory()->for($this->company)->create([
- 'category_id' => $productCategory->id,
- 'unit_id' => $productUnit->id,
- 'tax_rate_id' => $taxRate->id,
- 'tax_rate_2_id' => null,
- ]);
-
- $invoice = Invoice::factory()->for($this->company)->create([
- 'invoice_number' => 'INV-987654',
- 'customer_id' => $customer->getKey(),
- 'numbering_id' => $documentGroup->getKey(),
- 'user_id' => $this->user->id,
- 'invoice_status' => InvoiceStatus::DRAFT->value,
- 'invoiced_at' => '2025-05-10',
- 'invoice_due_at' => '2025-06-09',
- ]);
-
- $payload = ['invoice_status' => InvoiceStatus::SENT];
-
- /* Act */
- $component = Livewire::actingAs($this->user)
- ->test(ListInvoices::class)
- ->mountAction(TestAction::make('edit')->table($invoice), $payload)
- ->fillForm($payload)
- ->mountAction('save')
- ->callMountedAction();
-
- /* Assert */
- $component
- ->assertSuccessful()
- ->assertHasNoErrors();
-
- /* Assert */
- $this->assertDatabaseHas('invoices', [
- 'id' => $invoice->id,
- 'invoice_status' => InvoiceStatus::SENT,
- ]);
- }
- # endregion
-
# region crud
#[Test]
#[Group('crud')]
@@ -932,11 +687,9 @@ public function it_moves_an_invoice_to_a_different_numbering_group(): void
/* Act */
$component = Livewire::actingAs($this->user)
- ->test(ListInvoices::class)
- ->mountAction(TestAction::make('edit')->table($invoice), $payload)
+ ->test(EditInvoice::class, ['record' => $invoice->id])
->fillForm($payload)
- ->mountAction('save')
- ->callMountedAction();
+ ->call('save');
/* Assert */
$component
@@ -973,11 +726,9 @@ public function it_rejects_a_numbering_group_that_does_not_belong_to_the_current
/* Act */
$component = Livewire::actingAs($this->user)
- ->test(ListInvoices::class)
- ->mountAction(TestAction::make('edit')->table($invoice), $payload)
+ ->test(EditInvoice::class, ['record' => $invoice->id])
->fillForm($payload)
- ->mountAction('save')
- ->callMountedAction();
+ ->call('save');
/* Assert — a numbering group belonging to another company is not a valid option */
$component->assertHasFormErrors(['numbering_id']);
@@ -1010,11 +761,9 @@ public function it_rejects_a_numbering_group_of_a_different_type(): void
/* Act */
$component = Livewire::actingAs($this->user)
- ->test(ListInvoices::class)
- ->mountAction(TestAction::make('edit')->table($invoice), $payload)
+ ->test(EditInvoice::class, ['record' => $invoice->id])
->fillForm($payload)
- ->mountAction('save')
- ->callMountedAction();
+ ->call('save');
/* Assert — a Quote numbering group must never be selectable for an Invoice */
$component->assertHasFormErrors(['numbering_id']);
diff --git a/Modules/Payments/Filament/Company/Resources/Payments/Pages/ListPayments.php b/Modules/Payments/Filament/Company/Resources/Payments/Pages/ListPayments.php
index 833dde53b..ef1cb602d 100644
--- a/Modules/Payments/Filament/Company/Resources/Payments/Pages/ListPayments.php
+++ b/Modules/Payments/Filament/Company/Resources/Payments/Pages/ListPayments.php
@@ -5,7 +5,6 @@
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
use Modules\Payments\Filament\Company\Resources\Payments\PaymentResource;
-use Modules\Payments\Services\PaymentService;
class ListPayments extends ListRecords
{
@@ -15,13 +14,7 @@ protected function getHeaderActions(): array
{
return [
CreateAction::make()
- ->mutateDataUsing(function (array $data) {
- return $data;
- })
- ->action(function (array $data) {
- app(PaymentService::class)->createPayment($data);
- })
- ->modalWidth('full'),
+ ->url(fn (): string => PaymentResource::getUrl('create')),
];
}
}
diff --git a/Modules/Payments/Filament/Company/Resources/Payments/PaymentResource.php b/Modules/Payments/Filament/Company/Resources/Payments/PaymentResource.php
index 9cb1f132c..ab0bb1d80 100644
--- a/Modules/Payments/Filament/Company/Resources/Payments/PaymentResource.php
+++ b/Modules/Payments/Filament/Company/Resources/Payments/PaymentResource.php
@@ -11,6 +11,8 @@
use Modules\Core\Enums\Permission;
use Modules\Core\Enums\UserRole;
use Modules\Core\Filament\Company\Resources\BaseResource;
+use Modules\Payments\Filament\Company\Resources\Payments\Pages\CreatePayment;
+use Modules\Payments\Filament\Company\Resources\Payments\Pages\EditPayment;
use Modules\Payments\Filament\Company\Resources\Payments\Pages\ListPayments;
use Modules\Payments\Filament\Company\Resources\Payments\Schemas\PaymentForm;
use Modules\Payments\Filament\Company\Resources\Payments\Tables\PaymentsTable;
@@ -66,7 +68,9 @@ public static function getRelations(): array
public static function getPages(): array
{
return [
- 'index' => ListPayments::route('/'),
+ 'index' => ListPayments::route('/'),
+ 'create' => CreatePayment::route('/create'),
+ 'edit' => EditPayment::route('/{record}/edit'),
];
}
diff --git a/Modules/Payments/Filament/Company/Resources/Payments/Schemas/PaymentForm.php b/Modules/Payments/Filament/Company/Resources/Payments/Schemas/PaymentForm.php
index 225b57fac..9c7c2583a 100644
--- a/Modules/Payments/Filament/Company/Resources/Payments/Schemas/PaymentForm.php
+++ b/Modules/Payments/Filament/Company/Resources/Payments/Schemas/PaymentForm.php
@@ -4,13 +4,12 @@
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\MarkdownEditor;
-use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
-use Filament\Schemas;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
+use Filament\Support\Icons\Heroicon;
use Modules\Invoices\Models\Invoice;
use Modules\Payments\Enums\PaymentMethod;
use Modules\Payments\Enums\PaymentStatus;
@@ -22,113 +21,107 @@ public static function configure(Schema $schema): Schema
{
return $schema
->components([
- Grid::make(4)
+ Grid::make(3)
->columnSpanFull()
->schema([
- // LEFT COLUMN: invoice + customer
- Schemas\Components\Group::make()
+ Section::make(trans('ip.payment_details'))
+ ->icon(Heroicon::OutlinedCreditCard)
->columnSpan(2)
->schema([
- Section::make(trans('ip.payment'))
- ->columns(1)
+ Grid::make(4)
->schema([
- Grid::make()
- ->columns(1)
- ->schema([
- TextInput::make('payment_number')
- ->label(trans('ip.payment_number'))
- ->maxLength(255),
+ TextInput::make('payment_number')
+ ->label(trans('ip.payment_number'))
+ ->prefixIcon(Heroicon::OutlinedHashtag)
+ ->maxLength(255)
+ ->columnSpan(4),
- Select::make('invoice_id')
- ->label(trans('ip.invoice'))
- ->getSearchResultsUsing(function (string $search): array {
- return Invoice::with('customer')
- ->where('invoice_number', 'like', "%{$search}%")
- ->orWhereHas('customer', fn ($q) => $q->where('company_name', 'like', "%{$search}%"))
- ->limit(50)
- ->get()
- ->pluck('invoice_number', 'id')
- ->mapWithKeys(fn ($number, $id) => [
- $id => "{$number} – " . Invoice::query()->find($id)->customer?->company_name,
- ])
- ->toArray();
- })
- ->getOptionLabelUsing(
- fn (?int $value) => match (true) {
- $value === null => '',
- default => ($invoice = Invoice::with('customer')->find($value))
- ? "{$invoice->invoice_number} – {$invoice->customer?->company_name}"
- : '',
- }
- )
- ->required()
- ->searchable()
- ->preload()
- ->default(fn (?Payment $record) => $record?->invoice_id),
+ Select::make('payment_status')
+ ->label(trans('ip.payment_status'))
+ ->prefixIcon(Heroicon::OutlinedCheckBadge)
+ ->options(
+ collect(PaymentStatus::cases())
+ ->mapWithKeys(fn (PaymentStatus $s) => [
+ $s->value => trans($s->label()),
+ ])
+ ->toArray()
+ )
+ ->searchable()
+ ->preload()
+ ->native(false)
+ ->required()
+ ->columnSpan(2),
- Placeholder::make('customer')
- ->label(trans('ip.client'))
- ->content(fn (?Payment $record) => $record?->customer?->company_name ?? '-'),
- ]),
+ Select::make('payment_method')
+ ->label(trans('ip.payment_method'))
+ ->prefixIcon(Heroicon::OutlinedCreditCard)
+ ->options(
+ collect(PaymentMethod::cases())
+ ->mapWithKeys(fn (PaymentMethod $method) => [
+ $method->value => $method->label(),
+ ])
+ ->toArray()
+ )
+ ->searchable()
+ ->preload()
+ ->native(false)
+ ->required()
+ ->columnSpan(2),
+
+ TextInput::make('payment_amount')
+ ->label(trans('ip.payment_amount'))
+ ->prefixIcon(Heroicon::OutlinedCurrencyDollar)
+ ->numeric()
+ ->required()
+ ->dehydrated(true)
+ ->default(fn (?Payment $record) => $record?->payment_amount)
+ ->columnSpan(2),
+
+ DatePicker::make('paid_at')
+ ->label(trans('ip.paid_at'))
+ ->prefixIcon(Heroicon::OutlinedCalendar)
+ ->default(now())
+ ->required()
+ ->columnSpan(2),
]),
]),
- // RIGHT COLUMN: payment details
- Schemas\Components\Group::make()
- ->columnSpan(2)
+ Section::make(trans('ip.invoice'))
+ ->icon(Heroicon::OutlinedDocumentText)
+ ->columnSpan(1)
->schema([
- Section::make(trans('ip.payment_details'))
- ->columnSpanFull()
- ->schema([
- Grid::make(2)
- ->schema([
- DatePicker::make('paid_at')
- ->label(trans('ip.paid_at'))
- ->default(now())
- ->required(),
-
- Select::make('payment_method')
- ->label(trans('ip.payment_method'))
- ->options(
- collect(PaymentMethod::cases())
- ->mapWithKeys(fn (PaymentMethod $method) => [
- $method->value => $method->label(),
- ])
- ->toArray()
- )
- ->searchable()
- ->preload()
- ->native(false)
- ->required(),
-
- Select::make('payment_status')
- ->label(trans('ip.payment_status'))
- ->options(
- collect(PaymentStatus::cases())
- ->mapWithKeys(fn (PaymentStatus $s) => [
- $s->value => trans($s->label()),
- ])
- ->toArray()
- )
- ->searchable()
- ->preload()
- ->native(false)
- ->required(),
-
- TextInput::make('payment_amount')
- ->label(trans('ip.payment_amount'))
- ->numeric()
- ->required()
- ->dehydrated(true)
- ->afterStateHydrated(fn ($component, $state) => $component->state($state))
- ->default(fn (?Payment $record) => $record?->payment_amount),
- ]),
- ]),
+ Select::make('invoice_id')
+ ->label(trans('ip.invoice'))
+ ->prefixIcon(Heroicon::OutlinedDocumentText)
+ ->getSearchResultsUsing(function (string $search): array {
+ return Invoice::with('customer')
+ ->where('invoice_number', 'like', "%{$search}%")
+ ->orWhereHas('customer', fn ($q) => $q->where('company_name', 'like', "%{$search}%"))
+ ->limit(50)
+ ->get()
+ ->pluck('invoice_number', 'id')
+ ->mapWithKeys(fn ($number, $id) => [
+ $id => "{$number} – " . Invoice::query()->find($id)->customer?->company_name,
+ ])
+ ->toArray();
+ })
+ ->getOptionLabelUsing(
+ fn (?int $value) => match (true) {
+ $value === null => '',
+ default => ($invoice = Invoice::with('customer')->find($value))
+ ? "{$invoice->invoice_number} – {$invoice->customer?->company_name}"
+ : '',
+ }
+ )
+ ->required()
+ ->searchable()
+ ->preload()
+ ->default(fn (?Payment $record) => $record?->invoice_id),
]),
]),
- // NOTES (collapsed)
Section::make(trans('ip.notes'))
+ ->icon(Heroicon::OutlinedPencilSquare)
->columnSpanFull()
->schema([
MarkdownEditor::make('note')
diff --git a/Modules/Payments/Filament/Company/Resources/Payments/Tables/PaymentsTable.php b/Modules/Payments/Filament/Company/Resources/Payments/Tables/PaymentsTable.php
index f1d22b8e8..98b4a3280 100644
--- a/Modules/Payments/Filament/Company/Resources/Payments/Tables/PaymentsTable.php
+++ b/Modules/Payments/Filament/Company/Resources/Payments/Tables/PaymentsTable.php
@@ -11,6 +11,7 @@
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Modules\Core\Enums\Permission;
+use Modules\Payments\Filament\Company\Resources\Payments\PaymentResource;
use Modules\Payments\Models\Payment;
use Modules\Payments\Services\PaymentService;
@@ -78,10 +79,9 @@ public static function configure(Table $table): Table
ActionGroup::make([
EditAction::make('edit')
->visible(fn () => auth()->user()?->can(Permission::EDIT_PAYMENTS->value))
- ->action(function (Payment $record, array $data) {
- app(PaymentService::class)->updatePayment($record, $data);
- })
- ->modalWidth('full'),
+ ->url(fn (Payment $record): string => PaymentResource::getUrl('edit', [
+ 'record' => $record,
+ ])),
Action::make('email_receipt')
->visible(fn () => auth()->user()?->can(Permission::EMAIL_PAYMENTS->value))
->label(trans('ip.send_email'))
diff --git a/Modules/Payments/Tests/Feature/PaymentsTest.php b/Modules/Payments/Tests/Feature/PaymentsTest.php
index 385ec792b..adff9f743 100644
--- a/Modules/Payments/Tests/Feature/PaymentsTest.php
+++ b/Modules/Payments/Tests/Feature/PaymentsTest.php
@@ -64,325 +64,6 @@ public function it_lists_payments(): void
}
# endregion
- # region modals
- #[Test]
- #[Group('modals')]
- /**
- * @payload
- * {
- * "customer_id": 1,
- * "invoice_id": 1,
- * "payment_method": "bank_transfer",
- * "payment_status": "pending",
- * "payment_amount": 250.00,
- * "paid_at": "2024-11-01"
- * }
- */
- #[Group('failing')]
- public function it_creates_a_payment_through_a_modal(): void
- {
- /* Arrange */
- $customer = Relation::factory()->customer()->for($this->company)->create();
- $invoice = Invoice::factory()->for($this->company)->create([
- 'customer_id' => $customer->id,
- 'user_id' => $this->user->id,
- ]);
-
- $payload = [
- 'invoice_id' => $invoice->id,
- 'customer_id' => $customer->id,
- 'payment_method' => PaymentMethod::BANK_TRANSFER->value,
- 'payment_status' => PaymentStatus::PENDING->value,
- 'payment_amount' => 250.00,
- 'paid_at' => '2024-11-01',
- ];
-
- /* Act */
- $component = Livewire::actingAs($this->user)
- ->test(ListPayments::class)
- ->mountAction('create')
- ->fillForm($payload)
- ->callMountedAction();
-
- /*if (app()->runningUnitTests()) {
- dd($component->errors());
- dd($payload);
- }*/
-
- /* Assert */
- $component->assertHasNoErrors();
-
- $this->assertDatabaseHas('payments', array_merge(
- $payload,
- [
- 'payment_amount' => TestDecimal::exact(250),
- 'paid_at' => '2024-11-01 00:00:00',
- ]
- ));
- }
-
- #[Test]
- #[Group('modals')]
- /**
- * @payload missing: invoice_id
- * {
- * "customer_id": 1,
- * "payment_method": "bank_transfer",
- * "payment_status": "pending",
- * "payment_amount": 250.00,
- * "paid_at": "2024-11-01"
- * }
- */
- public function it_fails_to_create_payment_through_a_modal_without_required_invoice_id(): void
- {
- /* Arrange */
- $customer = Relation::factory()->for($this->company)->create();
-
- $payload = [
- 'customer_id' => $customer->id,
- 'payment_method' => PaymentMethod::BANK_TRANSFER,
- 'payment_amount' => 250.00,
- 'paid_at' => '2024-11-01',
- ];
-
- /*if (app()->runningUnitTests()) {
- dump($payload);
- }*/
-
- /* Act */
- $component = Livewire::actingAs($this->user)
- ->test(ListPayments::class)
- ->mountAction('create')
- ->fillForm($payload)
- ->callMountedAction();
-
- /* Assert */
- $component->assertHasFormErrors(['invoice_id']);
-
- $this->assertDatabaseMissing('payments', $payload);
- }
-
- #[Test]
- #[Group('modals')]
- /**
- * @payload missing: payment_method
- * {
- * "customer_id": 1,
- * "invoice_id": 1,
- * "payment_status": "pending",
- * "payment_amount": 250.00,
- * "paid_at": "2024-11-01"
- * }
- */
- public function it_fails_to_create_payment_through_a_modal_without_required_payment_method(): void
- {
- /* Arrange */
- $customer = Relation::factory()->for($this->company)->create();
- $invoice = Invoice::factory()->for($this->company)->create([
- 'customer_id' => $customer->id,
- 'user_id' => $this->user->id,
- ]);
-
- $payload = [
- 'invoice_id' => $invoice->id,
- 'customer_id' => $customer->id,
- 'payment_amount' => 250.00,
- 'paid_at' => '2024-11-01',
- ];
-
- /*if (app()->runningUnitTests()) {
- dump($payload);
- }*/
-
- /* Act */
- $component = Livewire::actingAs($this->user)
- ->test(ListPayments::class)
- ->mountAction('create')
- ->fillForm($payload)
- ->callMountedAction();
-
- /* Assert */
- $component->assertHasFormErrors(['payment_method']);
- $this->assertDatabaseMissing('payments', $payload);
- }
-
- #[Test]
- #[Group('modals')]
- /**
- * @payload missing: payment_status
- * {
- * "customer_id": 1,
- * "invoice_id": 1,
- * "payment_method": "bank_transfer",
- * "payment_amount": 250.00,
- * "paid_at": "2024-11-01"
- * }
- */
- public function it_fails_to_create_payment_through_a_modal_without_required_payment_status(): void
- {
- /* Arrange */
- $customer = Relation::factory()->for($this->company)->create();
- $invoice = Invoice::factory()->for($this->company)->create([
- 'customer_id' => $customer->id,
- 'user_id' => $this->user->id,
- ]);
-
- $payload = [
- 'invoice_id' => $invoice->id,
- 'customer_id' => $customer->id,
- 'payment_method' => PaymentMethod::BANK_TRANSFER->value,
- 'payment_amount' => 250.00,
- 'paid_at' => '2024-11-01',
- 'notes' => 'Test payment',
- ];
-
- /* Act */
- $component = Livewire::actingAs($this->user)
- ->test(ListPayments::class)
- ->mountAction('create')
- ->fillForm($payload)
- ->callMountedAction();
-
- /*if (app()->runningUnitTests()) {
- dump($payload);
- }*/
-
- /* Assert */
- $component->assertHasFormErrors(['payment_status']);
-
- $this->assertDatabaseMissing('payments', $payload);
- }
-
- #[Test]
- #[Group('modals')]
- /**
- * @payload missing: paid_at
- * {
- * "customer_id": 1,
- * "invoice_id": 1,
- * "payment_method": "bank_transfer",
- * "payment_status": "pending",
- * "payment_amount": 250.00,
- * "paid_at": "2024-11-01"
- * }
- */
- public function it_fails_to_create_payment_through_a_modal_without_required_paid_at(): void
- {
- /* Arrange */
- $customer = Relation::factory()->for($this->company)->create();
- $invoice = Invoice::factory()->for($this->company)->create([
- 'customer_id' => $customer->id,
- 'user_id' => $this->user->id,
- ]);
-
- $payload = [
- 'invoice_id' => $invoice->id,
- 'customer_id' => $customer->id,
- 'payment_method' => PaymentMethod::BANK_TRANSFER,
- 'payment_amount' => 250.00,
- 'paid_at' => null,
- ];
-
- /* Act */
- $component = Livewire::actingAs($this->user)
- ->test(ListPayments::class)
- ->mountAction('create')
- ->fillForm($payload)
- ->callMountedAction();
-
- /*if (app()->runningUnitTests()) {
- dump($payload);
- }*/
-
- /* Assert */
- $component->assertHasFormErrors(['paid_at']);
- $this->assertDatabaseMissing('payments', $payload);
- }
-
- #[Test]
- #[Group('modals')]
- /**
- * @payload missing: payment_amount
- * {
- * "customer_id": 1,
- * "invoice_id": 1,
- * "payment_method": "bank_transfer",
- * "payment_status": "pending",
- * "paid_at": "2024-11-01"
- * }
- */
- public function it_fails_to_create_payment_through_a_modal_without_required_amount(): void
- {
- /* Arrange */
- $customer = Relation::factory()->for($this->company)->create();
- $invoice = Invoice::factory()->for($this->company)->create([
- 'customer_id' => $customer->id,
- 'user_id' => $this->user->id,
- ]);
-
- $payload = [
- 'invoice_id' => $invoice->id,
- 'customer_id' => $customer->id,
- 'payment_method' => PaymentMethod::BANK_TRANSFER,
- 'paid_at' => '2024-11-01',
- ];
-
- /* Act */
- $component = Livewire::actingAs($this->user)
- ->test(ListPayments::class)
- ->mountAction('create')
- ->fillForm($payload)
- ->callMountedAction();
-
- /* Assert */
- $component->assertHasFormErrors(['payment_amount']);
-
- $this->assertDatabaseMissing('payments', $payload);
- }
-
- #[Test]
- #[Group('modals')]
- public function it_updates_a_payment_through_a_modal(): void
- {
- /* Arrange */
- $customer = Relation::factory()->customer()->for($this->company)->create();
- $invoice = Invoice::factory()->for($this->company)->create([
- 'customer_id' => $customer->id,
- 'user_id' => $this->user->id,
- ]);
-
- $payment = Payment::factory()
- ->for($this->company)
- ->create([
- 'invoice_id' => $invoice->id,
- 'customer_id' => $customer->id,
- 'payment_method' => PaymentMethod::BANK_TRANSFER->value,
- 'payment_status' => PaymentStatus::PENDING->value,
- 'payment_amount' => 123.00,
- 'paid_at' => '2024-11-01',
- ]);
-
- $payload = [
- 'payment_status' => PaymentStatus::COMPLETED->value,
- ];
-
- /* Act */
- $component = Livewire::actingAs($this->user)
- ->test(ListPayments::class, ['record' => $payment->id])
- ->mountAction(TestAction::make('edit')->table($payment), $payload)
- ->fillForm($payload)
- ->callMountedAction();
-
- /* Assert */
- $component
- ->assertSuccessful()
- ->assertHasNoErrors();
-
- /* Assert */
- $this->assertDatabaseHas('payments', ['id' => $payment->id, 'payment_status' => PaymentStatus::COMPLETED->value]);
- }
- # endregion
-
# region crud
#[Test]
#[Group('crud')]
diff --git a/Modules/Products/Filament/Company/Resources/Products/Pages/ListProducts.php b/Modules/Products/Filament/Company/Resources/Products/Pages/ListProducts.php
index 756488e8d..8b478ddbd 100644
--- a/Modules/Products/Filament/Company/Resources/Products/Pages/ListProducts.php
+++ b/Modules/Products/Filament/Company/Resources/Products/Pages/ListProducts.php
@@ -5,7 +5,6 @@
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
use Modules\Products\Filament\Company\Resources\Products\ProductResource;
-use Modules\Products\Services\ProductService;
class ListProducts extends ListRecords
{
@@ -15,12 +14,7 @@ protected function getHeaderActions(): array
{
return [
CreateAction::make()
- ->mutateDataUsing(function (array $data) {
- return $data;
- })
- ->action(function (array $data) {
- app(ProductService::class)->createProduct($data);
- })->modalWidth('full'),
+ ->url(fn (): string => ProductResource::getUrl('create')),
];
}
}
diff --git a/Modules/Products/Filament/Company/Resources/Products/ProductResource.php b/Modules/Products/Filament/Company/Resources/Products/ProductResource.php
index e7c646106..b2d26a3a4 100644
--- a/Modules/Products/Filament/Company/Resources/Products/ProductResource.php
+++ b/Modules/Products/Filament/Company/Resources/Products/ProductResource.php
@@ -9,6 +9,8 @@
use Illuminate\Database\Eloquent\Model;
use Modules\Core\Enums\Permission;
use Modules\Core\Filament\Company\Resources\BaseResource;
+use Modules\Products\Filament\Company\Resources\Products\Pages\CreateProduct;
+use Modules\Products\Filament\Company\Resources\Products\Pages\EditProduct;
use Modules\Products\Filament\Company\Resources\Products\Pages\ListProducts;
use Modules\Products\Filament\Company\Resources\Products\Schemas\ProductForm;
use Modules\Products\Filament\Company\Resources\Products\Tables\ProductsTable;
@@ -58,7 +60,9 @@ public static function getRelations(): array
public static function getPages(): array
{
return [
- 'index' => ListProducts::route('/'),
+ 'index' => ListProducts::route('/'),
+ 'create' => CreateProduct::route('/create'),
+ 'edit' => EditProduct::route('/{record}/edit'),
];
}
diff --git a/Modules/Products/Filament/Company/Resources/Products/Schemas/ProductForm.php b/Modules/Products/Filament/Company/Resources/Products/Schemas/ProductForm.php
index 1a5e00be9..8e70d33d0 100644
--- a/Modules/Products/Filament/Company/Resources/Products/Schemas/ProductForm.php
+++ b/Modules/Products/Filament/Company/Resources/Products/Schemas/ProductForm.php
@@ -5,10 +5,10 @@
use Filament\Forms\Components\MarkdownEditor;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
-use Filament\Schemas;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
+use Filament\Support\Icons\Heroicon;
use Modules\Products\Enums\ProductType;
class ProductForm
@@ -17,80 +17,80 @@ public static function configure(Schema $schema): Schema
{
return $schema
->components([
- Grid::make(2)
+ Grid::make(3)
->columnSpanFull()
->schema([
- //
- // LEFT COLUMN: basic details
- //
- Schemas\Components\Group::make()
- ->columnSpan(1)
+ Section::make(trans('ip.details'))
+ ->icon(Heroicon::OutlinedArchiveBox)
+ ->columnSpan(2)
->schema([
- Section::make(trans('ip.details'))
+ Grid::make(4)
->schema([
TextInput::make('code')
->label(trans('ip.product_sku'))
+ ->prefixIcon(Heroicon::OutlinedHashtag)
->required()
- ->maxLength(255),
+ ->maxLength(255)
+ ->columnSpan(2),
TextInput::make('product_name')
->label(trans('ip.product_name'))
+ ->prefixIcon(Heroicon::OutlinedTag)
->required()
- ->maxLength(255),
+ ->maxLength(255)
+ ->columnSpan(2),
Select::make('type')
->label(trans('ip.product_type'))
+ ->prefixIcon(Heroicon::OutlinedCube)
->options(
collect(ProductType::cases())
->mapWithKeys(fn (ProductType $type) => [$type->value => $type->label()])
->toArray()
)
->native(false)
- ->required(),
+ ->required()
+ ->columnSpan(2),
+
+ TextInput::make('price')
+ ->label(trans('ip.price'))
+ ->prefixIcon(Heroicon::OutlinedCurrencyDollar)
+ ->numeric()
+ ->required()
+ ->columnSpan(2),
]),
]),
- //
- // RIGHT COLUMN: classification
- //
- Schemas\Components\Group::make()
+ Section::make(trans('ip.classification'))
+ ->icon(Heroicon::OutlinedRectangleStack)
->columnSpan(1)
->schema([
- Section::make(trans('ip.classification'))
- ->schema([
- Grid::make(2)->schema([
- Select::make('category_id')
- ->label(trans('ip.family'))
- ->relationship('productCategory', 'category_name')
- ->searchable()
- ->preload()
- ->required(),
-
- Select::make('unit_id')
- ->label(trans('ip.product_unit'))
- ->relationship('productUnit', 'unit_name')
- ->searchable()
- ->preload(),
+ Select::make('category_id')
+ ->label(trans('ip.family'))
+ ->prefixIcon(Heroicon::OutlinedRectangleStack)
+ ->relationship('productCategory', 'category_name')
+ ->searchable()
+ ->preload()
+ ->required(),
- TextInput::make('price')
- ->label(trans('ip.price'))
- ->numeric()
- ->required(),
+ Select::make('unit_id')
+ ->label(trans('ip.product_unit'))
+ ->prefixIcon(Heroicon::OutlinedScale)
+ ->relationship('productUnit', 'unit_name')
+ ->searchable()
+ ->preload(),
- Select::make('tax_rate_id')
- ->label(trans('ip.tax_rate'))
- ->relationship('taxRate', 'name')
- ->searchable()
- ->preload(),
- ]),
- ]),
+ Select::make('tax_rate_id')
+ ->label(trans('ip.tax_rate'))
+ ->prefixIcon(Heroicon::OutlinedReceiptPercent)
+ ->relationship('taxRate', 'name')
+ ->searchable()
+ ->preload(),
]),
]),
- //
- // DESCRIPTION / NOTES (collapsed)
- //
Section::make(trans('ip.description'))
+ ->icon(Heroicon::OutlinedPencilSquare)
->collapsed()
->schema([
MarkdownEditor::make('description')
diff --git a/Modules/Products/Filament/Company/Resources/Products/Tables/ProductsTable.php b/Modules/Products/Filament/Company/Resources/Products/Tables/ProductsTable.php
index 2d150b747..3ab9b0bfd 100644
--- a/Modules/Products/Filament/Company/Resources/Products/Tables/ProductsTable.php
+++ b/Modules/Products/Filament/Company/Resources/Products/Tables/ProductsTable.php
@@ -13,6 +13,7 @@
use Filament\Tables\Table;
use Modules\Core\Enums\Permission;
use Modules\Products\Enums\ProductType;
+use Modules\Products\Filament\Company\Resources\Products\ProductResource;
use Modules\Products\Models\Product;
use Modules\Products\Models\ProductCategory;
use Modules\Products\Models\ProductUnit;
@@ -80,10 +81,9 @@ public static function configure(Table $table): Table
ActionGroup::make([
EditAction::make('edit')
->visible(fn () => auth()->user()?->can(Permission::EDIT_PRODUCTS->value))
- ->action(function (Product $record, array $data) {
- app(ProductService::class)->updateProduct($record, $data);
- })
- ->modalWidth('full'),
+ ->url(fn (Product $record): string => ProductResource::getUrl('edit', [
+ 'record' => $record,
+ ])),
DeleteAction::make('delete')
->visible(fn () => auth()->user()?->can(Permission::DELETE_PRODUCTS->value))
diff --git a/Modules/Products/Tests/Feature/ProductsTest.php b/Modules/Products/Tests/Feature/ProductsTest.php
index 3a5c7cf5f..4c989646e 100644
--- a/Modules/Products/Tests/Feature/ProductsTest.php
+++ b/Modules/Products/Tests/Feature/ProductsTest.php
@@ -66,300 +66,6 @@ public function it_lists_products(): void
}
# endregion
- # region modals
- #[Test]
- #[Group('crud')]
- /**
- * @payload
- * {
- * "company_id": 1,
- * "category_id": 2,
- * "unit_id": 3,
- * "tax_rate_id": 4,
- * "type": "PRODUCT",
- * "code": "P001",
- * "product_name": "Test Product",
- * "price": "9.99",
- * "cost_price": "5.00",
- * "tariff": "TX123",
- * "description": "Example description"
- * }
- */
- public function it_creates_a_product_through_a_modal(): void
- {
- /* Arrange */
- $productCategory = ProductCategory::factory()->create([
- 'category_name' => '::category_name::',
- ]);
- $productUnit = ProductUnit::factory()->create([
- 'unit_name' => '::unit_name::',
- ]);
- $taxRate = TaxRate::factory()->create([
- 'name' => '::taxrate_name::',
- ]);
-
- $payload = [
- 'category_id' => $productCategory->id,
- 'unit_id' => $productUnit->id,
- 'type' => ProductType::PRODUCT->value,
- 'code' => 'SKU-001',
- 'product_name' => 'Test Product',
- 'price' => 9.99,
- 'tax_rate_id' => $taxRate->id,
- 'description' => 'Example',
- ];
-
- /* Act */
- $component = Livewire::actingAs($this->user)
- ->test(ListProducts::class)
- ->mountAction('create')
- ->fillForm($payload)
- ->callMountedAction();
-
- /*if (app()->runningUnitTests()) {
- dd($payload);
- }*/
-
- /* Assert */
- $component
- ->assertHasNoFormErrors();
-
- $this->assertDatabaseHas('products', array_merge(
- $payload,
- ['price' => TestDecimal::exact(9.99)]
- ));
- }
-
- #[Test]
- #[Group('crud')]
- /**
- * @payload missing: code
- * {
- * "company_id": 1,
- * "category_id": 2,
- * "unit_id": 3,
- * "tax_rate_id": 4,
- * "type": "PRODUCT",
- * "product_name": "Test Product",
- * "price": "9.99",
- * "cost_price": "5.00",
- * "tariff": "TX123",
- * "description": "Example description"
- * }
- */
- public function it_fails_to_create_product_through_a_modal_without_required_code(): void
- {
- /* Arrange */
- $productCategory = ProductCategory::factory()->create([
- 'category_name' => '::category_name::',
- ]);
- $taxRate = TaxRate::factory()->create([
- 'name' => '::taxrate_name::',
- ]);
-
- $productUnit = ProductUnit::factory()->create([
- 'unit_name' => '::unit_name::',
- ]);
-
- $payload = [
- 'category_id' => $productCategory->id,
- 'unit_id' => $productUnit->id,
- 'type' => ProductType::PRODUCT->value,
- 'product_name' => 'Test Product',
- 'price' => 9.99,
- 'cost_price' => 5.00,
- 'product_tariff' => 123,
- 'tax_rate_id' => $taxRate->id,
- 'description' => 'Example',
- ];
-
- /* Act */
- $component = Livewire::actingAs($this->user)
- ->test(ListProducts::class)
- ->mountAction('create')
- ->fillForm($payload)
- ->callMountedAction();
-
- /*if (app()->runningUnitTests()) {
- dump($payload);
- }*/
-
- /* Assert */
- $component
- ->assertHasFormErrors(['code']);
-
- $this->assertDatabaseMissing('products', $payload);
- }
-
- #[Test]
- #[Group('crud')]
- /**
- * @payload missing: name
- * {
- * "company_id": 1,
- * "category_id": 2,
- * "unit_id": 3,
- * "tax_rate_id": 4,
- * "type": "PRODUCT",
- * "code": "P001",
- * "price": "9.99",
- * "cost_price": "5.00",
- * "tariff": "TX123",
- * "description": "Example description"
- * }
- */
- public function it_fails_to_create_product_through_a_modal_without_required_product_name(): void
- {
- /* Arrange */
- $productCategory = ProductCategory::factory()->create([
- 'category_name' => '::category_name::',
- ]);
- $taxRate = TaxRate::factory()->create([
- 'name' => '::taxrate_name::',
- ]);
-
- $productUnit = ProductUnit::factory()->create([
- 'unit_name' => '::unit_name::',
- ]);
-
- /* Arrange */
- $payload = [
- 'category_id' => $productCategory->id,
- 'unit_id' => $productUnit->id,
- 'type' => ProductType::PRODUCT->value,
- 'code' => 'SKU-001',
- 'price' => 9.99,
- 'cost_price' => 5.00,
- 'product_tariff' => 123,
- 'tax_rate_id' => $taxRate->id,
- 'description' => 'Example',
- ];
-
- /* Act */
- $component = Livewire::actingAs($this->user)
- ->test(ListProducts::class)
- ->mountAction('create')
- ->fillForm($payload)
- ->callMountedAction();
-
- /*if (app()->runningUnitTests()) {
- dump($payload);
- }*/
-
- /* Assert */
- $component
- ->assertHasFormErrors(['product_name']);
- }
-
- #[Test]
- #[Group('crud')]
- /**
- * @payload missing: price
- * {
- * "company_id": 1,
- * "category_id": 2,
- * "unit_id": 3,
- * "tax_rate_id": 4,
- * "type": "PRODUCT",
- * "code": "P001",
- * "product_name": "Test Product",
- * "cost_price": "5.00",
- * "tariff": "TX123",
- * "description": "Example description"
- * }
- */
- public function it_fails_to_create_product_through_a_modal_without_required_price(): void
- {
- $productCategory = ProductCategory::factory()->create([
- 'category_name' => '::category_name::',
- ]);
- $taxRate = TaxRate::factory()->create([
- 'name' => '::taxrate_name::',
- ]);
-
- $productUnit = ProductUnit::factory()->create([
- 'unit_name' => '::unit_name::',
- ]);
-
- /* Arrange */
- $payload = [
- 'category_id' => $productCategory->id,
- 'unit_id' => $productUnit->id,
- 'type' => ProductType::PRODUCT->value,
- 'code' => 'SKU-001',
- 'product_name' => 'Test Product',
- 'cost_price' => 5.00,
- 'product_tariff' => 123,
- 'tax_rate_id' => $taxRate->id,
- 'description' => 'Example',
- ];
-
- /* Act */
- $component = Livewire::actingAs($this->user)
- ->test(ListProducts::class)
- ->mountAction('create')
- ->fillForm($payload)
- ->callMountedAction();
-
- /*if (app()->runningUnitTests()) {
- dump($payload);
- }*/
-
- /* Assert */
- $component
- ->assertHasFormErrors(['price']);
-
- $this->assertDatabaseMissing('products', $payload);
- }
-
- #[Test]
- #[Group('crud')]
- public function it_updates_a_product_through_a_modal(): void
- {
- /* Arrange */
- $productCategory = ProductCategory::factory()->create([
- 'category_name' => '::category_name::',
- ]);
- $productUnit = ProductUnit::factory()->create([
- 'unit_name' => '::unit_name::',
- ]);
- $taxRate = TaxRate::factory()->create([
- 'name' => '::taxrate_name::',
- ]);
-
- $product = Product::factory()->for($this->company)->create([
- 'category_id' => $productCategory->id,
- 'unit_id' => $productUnit->id,
- 'type' => ProductType::PRODUCT->value,
- 'code' => 'SKU-001',
- 'product_name' => 'Test Product',
- 'price' => 9.99,
- 'tax_rate_id' => $taxRate->id,
- 'tax_rate_2_id' => null,
- 'description' => 'Example',
- ]);
-
- $payload = [
- 'product_name' => 'Updated Product',
- 'price' => 70.00,
- ];
-
- /* Act */
- $component = Livewire::actingAs($this->user)
- ->test(ListProducts::class)
- ->mountAction(TestAction::make('edit')->table($product), $payload)
- ->fillForm($payload)
- ->callMountedAction();
-
- $component
- ->assertHasNoFormErrors();
-
- $this->assertDatabaseHas('products', array_merge($payload, [
- 'id' => $product->id,
- ]));
- }
- # endregion
-
# region crud
#[Test]
#[Group('crud')]
diff --git a/Modules/Projects/Filament/Company/Resources/Projects/Pages/ListProjects.php b/Modules/Projects/Filament/Company/Resources/Projects/Pages/ListProjects.php
index 2244480d9..80b9c505d 100644
--- a/Modules/Projects/Filament/Company/Resources/Projects/Pages/ListProjects.php
+++ b/Modules/Projects/Filament/Company/Resources/Projects/Pages/ListProjects.php
@@ -5,7 +5,6 @@
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
use Modules\Projects\Filament\Company\Resources\Projects\ProjectResource;
-use Modules\Projects\Services\ProjectService;
class ListProjects extends ListRecords
{
@@ -15,13 +14,7 @@ protected function getHeaderActions(): array
{
return [
CreateAction::make()
- ->mutateDataUsing(function (array $data) {
- return $data;
- })
- ->action(function (array $data) {
- app(ProjectService::class)->createProject($data);
- })
- ->modalWidth('full'),
+ ->url(fn (): string => ProjectResource::getUrl('create')),
];
}
}
diff --git a/Modules/Projects/Filament/Company/Resources/Projects/ProjectResource.php b/Modules/Projects/Filament/Company/Resources/Projects/ProjectResource.php
index 5fc4c62f8..11422259d 100644
--- a/Modules/Projects/Filament/Company/Resources/Projects/ProjectResource.php
+++ b/Modules/Projects/Filament/Company/Resources/Projects/ProjectResource.php
@@ -9,6 +9,8 @@
use Illuminate\Database\Eloquent\Model;
use Modules\Core\Enums\Permission;
use Modules\Core\Filament\Company\Resources\BaseResource;
+use Modules\Projects\Filament\Company\Resources\Projects\Pages\CreateProject;
+use Modules\Projects\Filament\Company\Resources\Projects\Pages\EditProject;
use Modules\Projects\Filament\Company\Resources\Projects\Pages\ListProjects;
use Modules\Projects\Filament\Company\Resources\Projects\Schemas\ProjectForm;
use Modules\Projects\Filament\Company\Resources\Projects\Tables\ProjectsTable;
@@ -60,7 +62,9 @@ public static function getRelations(): array
public static function getPages(): array
{
return [
- 'index' => ListProjects::route('/'),
+ 'index' => ListProjects::route('/'),
+ 'create' => CreateProject::route('/create'),
+ 'edit' => EditProject::route('/{record}/edit'),
];
}
diff --git a/Modules/Projects/Filament/Company/Resources/Projects/Schemas/ProjectForm.php b/Modules/Projects/Filament/Company/Resources/Projects/Schemas/ProjectForm.php
index 8ced74e43..9aa470409 100644
--- a/Modules/Projects/Filament/Company/Resources/Projects/Schemas/ProjectForm.php
+++ b/Modules/Projects/Filament/Company/Resources/Projects/Schemas/ProjectForm.php
@@ -3,14 +3,12 @@
namespace Modules\Projects\Filament\Company\Resources\Projects\Schemas;
use Filament\Forms\Components\DatePicker;
-use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
-use Filament\Schemas;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Section;
-use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Schema;
+use Filament\Support\Icons\Heroicon;
use Illuminate\Validation\Rules\Enum;
use Modules\Projects\Enums\ProjectStatus;
@@ -20,77 +18,73 @@ public static function configure(Schema $schema): Schema
{
return $schema
->components([
- Grid::make(2)
+ Grid::make(3)
->columnSpanFull()
->schema([
- //
- // LEFT COLUMN: Client selector + info
- //
- Schemas\Components\Group::make()
- ->columnSpan(1)
- ->schema([
- Section::make(trans('ip.client'))
- ->schema([
- Select::make('customer_id')
- ->label(trans('ip.client'))
- ->relationship('customer', 'company_name')
- ->searchable()
- ->preload()
- ->required()
- ->createOptionForm([
- TextInput::make('company_name')
- ->label(trans('ip.customer_name'))
- ->required(),
- ])
- ->reactive(),
-
- Placeholder::make('customer_info')
- ->label(trans('ip.client_information'))
- ->content(fn (Get $get) => optional($get('customer'))->company_name ?? '-'),
- ]),
- ]),
-
- //
- // RIGHT COLUMN: Project details
- //
- Schemas\Components\Group::make()
- ->columnSpan(1)
+ Section::make(trans('ip.details'))
+ ->icon(Heroicon::OutlinedClipboardDocumentCheck)
+ ->columnSpan(2)
->schema([
- Section::make(trans('ip.details'))
- ->columns(2)
+ Grid::make(4)
->schema([
TextInput::make('project_number')
->label(trans('ip.project_number'))
- ->maxLength(255),
+ ->prefixIcon(Heroicon::OutlinedHashtag)
+ ->maxLength(255)
+ ->columnSpan(2),
TextInput::make('project_name')
->label(trans('ip.project_name'))
+ ->prefixIcon(Heroicon::OutlinedTag)
->required()
- ->maxLength(255),
+ ->maxLength(255)
+ ->columnSpan(2),
Select::make('project_status')
->label(trans('ip.project_status'))
+ ->prefixIcon(Heroicon::OutlinedCheckBadge)
->options(ProjectStatus::options())
->required()
->native(false)
- ->rule(new Enum(ProjectStatus::class)),
+ ->rule(new Enum(ProjectStatus::class))
+ ->columnSpan(2),
DatePicker::make('start_at')
->label(trans('ip.start_at'))
+ ->prefixIcon(Heroicon::OutlinedCalendar)
->required()
- ->native(false),
+ ->native(false)
+ ->columnSpan(1),
DatePicker::make('end_at')
->label(trans('ip.end_at'))
- ->native(false),
+ ->prefixIcon(Heroicon::OutlinedCalendarDays)
+ ->native(false)
+ ->columnSpan(1),
+ ]),
+ ]),
+
+ Section::make(trans('ip.client'))
+ ->icon(Heroicon::OutlinedUserGroup)
+ ->columnSpan(1)
+ ->schema([
+ Select::make('customer_id')
+ ->label(trans('ip.client'))
+ ->prefixIcon(Heroicon::OutlinedUserGroup)
+ ->relationship('customer', 'company_name')
+ ->searchable()
+ ->preload()
+ ->required()
+ ->createOptionForm([
+ TextInput::make('company_name')
+ ->label(trans('ip.customer_name'))
+ ->required(),
]),
]),
]),
- //
- // DESCRIPTION (collapsed)
- //
Section::make(trans('ip.description'))
+ ->icon(Heroicon::OutlinedPencilSquare)
->collapsed()
->schema([
TextInput::make('description')
diff --git a/Modules/Projects/Filament/Company/Resources/Projects/Tables/ProjectsTable.php b/Modules/Projects/Filament/Company/Resources/Projects/Tables/ProjectsTable.php
index 4c9a28d82..b853da9fb 100644
--- a/Modules/Projects/Filament/Company/Resources/Projects/Tables/ProjectsTable.php
+++ b/Modules/Projects/Filament/Company/Resources/Projects/Tables/ProjectsTable.php
@@ -16,6 +16,7 @@
use Modules\Core\Enums\Permission;
use Modules\Core\Helpers\EnumHelper;
use Modules\Projects\Enums\ProjectStatus;
+use Modules\Projects\Filament\Company\Resources\Projects\ProjectResource;
use Modules\Projects\Models\Project;
use Modules\Projects\Services\ProjectBillingService;
use Modules\Projects\Services\ProjectService;
@@ -34,10 +35,6 @@ public static function configure(Table $table): Table
TextColumn::make('project_name')
->limit(10)
->label(trans('ip.project_name'))
- ->formatStateUsing(fn ($state) => $state)
- ->extraAttributes([
- 'class' => '!border-curious-200 dark:!border-curious-600 rounded-2xl !p-4',
- ])
->searchable()
->sortable()
->toggleable(),
@@ -60,10 +57,9 @@ public static function configure(Table $table): Table
ActionGroup::make([
EditAction::make('edit')
->visible(fn () => auth()->user()?->can(Permission::EDIT_PROJECTS->value))
- ->action(function (Project $record, array $data) {
- app(ProjectService::class)->updateProject($record, $data);
- })
- ->modalWidth('full'),
+ ->url(fn (Project $record): string => ProjectResource::getUrl('edit', [
+ 'record' => $record,
+ ])),
Action::make('bill_tasks')
->label(trans('ip.bill_tasks'))
->icon('heroicon-o-banknotes')
diff --git a/Modules/Projects/Filament/Company/Resources/Tasks/Pages/ListTasks.php b/Modules/Projects/Filament/Company/Resources/Tasks/Pages/ListTasks.php
index 941ad2146..61db0bdf4 100644
--- a/Modules/Projects/Filament/Company/Resources/Tasks/Pages/ListTasks.php
+++ b/Modules/Projects/Filament/Company/Resources/Tasks/Pages/ListTasks.php
@@ -8,7 +8,6 @@
use Illuminate\Database\Eloquent\Relations\Relation;
use Modules\Projects\Filament\Company\Resources\Tasks\TaskResource;
use Modules\Projects\Models\Task;
-use Modules\Projects\Services\TaskService;
class ListTasks extends ListRecords
{
@@ -18,9 +17,7 @@ protected function getHeaderActions(): array
{
return [
CreateAction::make()
- ->action(function (array $data) {
- app(TaskService::class)->createTask($data);
- })->modalWidth('full'),
+ ->url(fn (): string => TaskResource::getUrl('create')),
];
}
diff --git a/Modules/Projects/Filament/Company/Resources/Tasks/Schemas/TaskForm.php b/Modules/Projects/Filament/Company/Resources/Tasks/Schemas/TaskForm.php
index 43596b30c..178b4544e 100644
--- a/Modules/Projects/Filament/Company/Resources/Tasks/Schemas/TaskForm.php
+++ b/Modules/Projects/Filament/Company/Resources/Tasks/Schemas/TaskForm.php
@@ -4,14 +4,12 @@
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\MarkdownEditor;
-use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
-use Filament\Schemas;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Section;
-use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Schema;
+use Filament\Support\Icons\Heroicon;
use Modules\Projects\Enums\TaskStatus;
use Modules\Projects\Models\Project;
@@ -21,115 +19,115 @@ public static function configure(Schema $schema): Schema
{
return $schema
->components([
- Grid::make(5)
+ Grid::make(3)
->columnSpanFull()
->schema([
- //
- // LEFT COLUMN: name, status, project + summary
- //
- Schemas\Components\Group::make()
- ->columnSpan(3)
+ Section::make(trans('ip.task'))
+ ->icon(Heroicon::OutlinedClipboardDocumentList)
+ ->columnSpan(2)
->schema([
- Section::make(trans('ip.task'))
+ Grid::make(4)
->schema([
TextInput::make('task_number')
->label(trans('ip.task_number'))
- ->maxLength(255),
+ ->prefixIcon(Heroicon::OutlinedHashtag)
+ ->maxLength(255)
+ ->columnSpan(2),
TextInput::make('task_name')
->label(trans('ip.task_name'))
+ ->prefixIcon(Heroicon::OutlinedTag)
->required()
->maxLength(255)
- ->autofocus(),
-
- Select::make('project_id')
- ->label(trans('ip.project'))
- ->searchable()
- ->preload()
- ->required()
- ->getOptionLabelUsing(fn ($value) => Project::find($value)?->project_name)
- ->getSearchResultsUsing(function (string $search): array {
- return Project::query()
- ->where('project_name', 'like', "%{$search}%")
- ->with('customer')
- ->limit(50)
- ->get()
- ->mapWithKeys(fn (Project $p) => [
- $p->id => $p->project_name,
- ])->toArray();
- })
- ->createOptionForm([
- Select::make('customer_id')
- ->label(trans('ip.client'))
- ->relationship('customer', 'company_name')
- ->searchable()
- ->preload()
- ->required()
- ->createOptionForm([
- TextInput::make('company_name')
- ->required()
- ->maxLength(255),
- ]),
- TextInput::make('project_name')
- ->label(trans('ip.project_name'))
- ->required()
- ->maxLength(255),
- ]),
+ ->autofocus()
+ ->columnSpan(2),
- Placeholder::make('project_summary')
- ->label(trans('ip.client_information'))
- ->content(
- fn (Get $get) => optional($get('project'))->name
- . ' – '
- . optional($get('project.customer'))->company_name
- ),
- ]),
- ]),
-
- //
- // RIGHT COLUMN: due date, price, tax & description
- //
- Schemas\Components\Group::make()
- ->columnSpan(2)
- ->schema([
- Section::make(trans('ip.details'))
- ->columns(2)
- ->schema([
Select::make('task_status')
+ ->label(trans('ip.task_status'))
+ ->prefixIcon(Heroicon::OutlinedCheckBadge)
->options(TaskStatus::options())
->getOptionLabelUsing(fn ($value) => TaskStatus::tryFrom($value)?->label())
->searchable()
->preload()
->native(false)
- ->required(),
+ ->required()
+ ->columnSpan(2),
DatePicker::make('due_at')
+ ->label(trans('ip.task_finish_date'))
+ ->prefixIcon(Heroicon::OutlinedCalendarDays)
->date()
->native(false)
- ->label(trans('ip.task_finish_date'))
- ->required(),
+ ->required()
+ ->columnSpan(2),
TextInput::make('task_price')
->label(trans('ip.task_price'))
- ->numeric(),
+ ->prefixIcon(Heroicon::OutlinedCurrencyDollar)
+ ->numeric()
+ ->columnSpan(2),
Select::make('tax_rate_id')
->label(trans('ip.tax_rate'))
+ ->prefixIcon(Heroicon::OutlinedReceiptPercent)
->relationship('taxRate', 'name')
->searchable()
->preload()
- ->required(),
+ ->required()
+ ->columnSpan(2),
]),
]),
- Section::make(trans('ip.description'))
+ Section::make(trans('ip.project'))
+ ->icon(Heroicon::OutlinedFolderOpen)
+ ->columnSpan(1)
->schema([
- MarkdownEditor::make('description')
- ->toolbarButtons(['bold', 'italic']),
- ])
- ->collapsed(true)
- ->columnSpanFull(),
+ Select::make('project_id')
+ ->label(trans('ip.project'))
+ ->prefixIcon(Heroicon::OutlinedFolderOpen)
+ ->searchable()
+ ->preload()
+ ->required()
+ ->getOptionLabelUsing(fn ($value) => Project::find($value)?->project_name)
+ ->getSearchResultsUsing(function (string $search): array {
+ return Project::query()
+ ->where('project_name', 'like', "%{$search}%")
+ ->with('customer')
+ ->limit(50)
+ ->get()
+ ->mapWithKeys(fn (Project $p) => [
+ $p->id => $p->project_name,
+ ])->toArray();
+ })
+ ->createOptionForm([
+ Select::make('customer_id')
+ ->label(trans('ip.client'))
+ ->relationship('customer', 'company_name')
+ ->searchable()
+ ->preload()
+ ->required()
+ ->createOptionForm([
+ TextInput::make('company_name')
+ ->required()
+ ->maxLength(255),
+ ]),
+ TextInput::make('project_name')
+ ->label(trans('ip.project_name'))
+ ->required()
+ ->maxLength(255),
+ ]),
+ ]),
]),
+
+ Section::make(trans('ip.description'))
+ ->icon(Heroicon::OutlinedPencilSquare)
+ ->schema([
+ MarkdownEditor::make('description')
+ ->label(trans('ip.description'))
+ ->toolbarButtons(['bold', 'italic']),
+ ])
+ ->collapsed()
+ ->columnSpanFull(),
]);
}
}
diff --git a/Modules/Projects/Filament/Company/Resources/Tasks/Tables/TasksTable.php b/Modules/Projects/Filament/Company/Resources/Tasks/Tables/TasksTable.php
index 8a82c3a90..43c0a8760 100644
--- a/Modules/Projects/Filament/Company/Resources/Tasks/Tables/TasksTable.php
+++ b/Modules/Projects/Filament/Company/Resources/Tasks/Tables/TasksTable.php
@@ -11,6 +11,7 @@
use Filament\Tables\Table;
use Modules\Core\Enums\Permission;
use Modules\Projects\Enums\TaskStatus;
+use Modules\Projects\Filament\Company\Resources\Tasks\TaskResource;
use Modules\Projects\Models\Task;
use Modules\Projects\Services\TaskService;
@@ -89,11 +90,9 @@ public static function configure(Table $table): Table
ActionGroup::make([
EditAction::make()
->visible(fn () => auth()->user()?->can(Permission::EDIT_TASKS->value))
- ->action(
- fn (Task $record, array $data) => app(TaskService::class)
- ->updateTask($record, $data)
- )
- ->modalWidth('full')
+ ->url(fn (Task $record): string => TaskResource::getUrl('edit', [
+ 'record' => $record,
+ ]))
->tooltip(trans('filament-actions::edit.single.label')),
DeleteAction::make('delete')
->visible(fn () => auth()->user()?->can(Permission::DELETE_TASKS->value))
diff --git a/Modules/Projects/Filament/Company/Resources/Tasks/TaskResource.php b/Modules/Projects/Filament/Company/Resources/Tasks/TaskResource.php
index 6d27e8fec..f0c382cd2 100644
--- a/Modules/Projects/Filament/Company/Resources/Tasks/TaskResource.php
+++ b/Modules/Projects/Filament/Company/Resources/Tasks/TaskResource.php
@@ -9,6 +9,8 @@
use Illuminate\Database\Eloquent\Model;
use Modules\Core\Enums\Permission;
use Modules\Core\Filament\Company\Resources\BaseResource;
+use Modules\Projects\Filament\Company\Resources\Tasks\Pages\CreateTask;
+use Modules\Projects\Filament\Company\Resources\Tasks\Pages\EditTask;
use Modules\Projects\Filament\Company\Resources\Tasks\Pages\ListTasks;
use Modules\Projects\Filament\Company\Resources\Tasks\Schemas\TaskForm;
use Modules\Projects\Filament\Company\Resources\Tasks\Tables\TasksTable;
@@ -59,7 +61,9 @@ public static function getRelations(): array
public static function getPages(): array
{
return [
- 'index' => ListTasks::route('/'),
+ 'index' => ListTasks::route('/'),
+ 'create' => CreateTask::route('/create'),
+ 'edit' => EditTask::route('/{record}/edit'),
];
}
diff --git a/Modules/Projects/Tests/Feature/ProjectsTest.php b/Modules/Projects/Tests/Feature/ProjectsTest.php
index d2ceb72c9..6a078b0e8 100644
--- a/Modules/Projects/Tests/Feature/ProjectsTest.php
+++ b/Modules/Projects/Tests/Feature/ProjectsTest.php
@@ -57,212 +57,6 @@ public function it_lists_projects(): void
}
# endregion
- # region modals
- #[Test]
- #[Group('crud')]
- /**
- * @payload
- * {
- * "company_id": 1,
- * "customer_id": 2,
- * "project_status": "active",
- * "project_name": "Website Redesign",
- * "start_at": "2025-05-01",
- * "end_at": "2025-06-01",
- * "description": "Redesigning the corporate website"
- * }
- */
- public function it_creates_a_project_through_a_modal(): void
- {
- $customer = Relation::factory()->for($this->company)->create(['company_name' => 'Test Client']);
-
- /* Arrange */
- $payload = [
- 'customer_id' => $customer->id,
- 'project_status' => ProjectStatus::ACTIVE->value,
- 'project_name' => 'Website Redesign',
- 'start_at' => '2025-05-01',
- 'end_at' => '2025-06-01',
- 'description' => 'Redesigning the corporate website',
- ];
-
- /* Act */
- $component = Livewire::actingAs($this->user)
- ->test(ListProjects::class)
- ->mountAction('create')
- ->fillForm($payload)
- ->callMountedAction()
- ->assertHasNoFormErrors();
-
- /* Assert */
- $component->assertSuccessful();
- $this->assertDatabaseHas('projects', array_merge(
- ['company_id' => $this->company->id],
- [
- 'customer_id' => $payload['customer_id'],
- 'project_status' => $payload['project_status'],
- 'project_name' => $payload['project_name'],
- 'start_at' => isset($payload['start_at']) && mb_strlen($payload['start_at']) === 10 ? $payload['start_at'] . ' 00:00:00' : $payload['start_at'],
- 'end_at' => isset($payload['end_at']) && mb_strlen($payload['end_at']) === 10 ? $payload['end_at'] . ' 00:00:00' : $payload['end_at'],
- 'description' => $payload['description'],
- ]
- ));
- }
-
- #[Test]
- #[Group('crud')]
- /**
- * @payload
- * {
- * "company_id": 1,
- * "customer_id": 2,
- * "project_name": "Website Redesign",
- * "start_at": "2025-05-01",
- * "end_at": "2025-06-01",
- * "description": "Redesigning the corporate website"
- * }
- */
- public function it_fails_to_create_project_through_a_modal_without_required_status(): void
- {
- /* Arrange */
- $customer = Relation::factory()->for($this->company)->create(['company_name' => '::client_name::']);
-
- $payload = [
- 'company_id' => $this->company->id,
- 'customer_id' => $customer->id,
- 'project_name' => 'Website Redesign',
- 'start_at' => '2025-05-01',
- 'end_at' => '2025-06-01',
- 'description' => 'Redesigning the corporate website',
- ];
-
- /* Act */
- $component = Livewire::actingAs($this->user)
- ->test(ListProjects::class)
- ->mountAction('create')
- ->fillForm($payload)
- ->callMountedAction();
-
- /* Assert */
- $component
- ->assertHasFormErrors(['project_status' => 'required']);
-
- $this->assertDatabaseMissing('projects', $payload);
- }
-
- #[Test]
- #[Group('crud')]
- /**
- * @payload missing: project_name
- * {
- * "customer_id": 2,
- * "project_status": "active",
- * "start_at": "2025-05-01",
- * "end_at": "2025-06-01",
- * "description": "Redesigning the corporate website"
- * }
- */
- public function it_fails_to_create_project_through_a_modal_without_required_project_name(): void
- {
- $customer = Relation::factory()
- ->for($this->company, 'company')
- ->create(['company_name' => '::client_name::']);
-
- $payload = [
- 'customer_id' => $customer->id,
- 'project_status' => 'active',
- 'start_at' => '2025-05-01',
- 'end_at' => '2025-06-01',
- 'description' => 'Redesigning the corporate website',
- ];
-
- Livewire::actingAs($this->user)
- ->test(ListProjects::class)
- ->mountAction('create')
- ->fillForm($payload)
- ->callMountedAction()
- ->assertHasFormErrors(['project_name' => 'required']);
-
- $this->assertDatabaseMissing('projects', $payload);
- }
-
- #[Test]
- #[Group('crud')]
- /**
- * @payload missing: start_at
- * {
- * "project_name": "Client Redesign",
- * "description": "Modernizing UX",
- * "ends_at": "2025-06-30"
- * }
- */
- public function it_fails_to_create_project_through_a_modal_without_required_start_at(): void
- {
- /* Arrange */
- $customer = Relation::factory()->for($this->company)->create(['company_name' => '::client_name::']);
-
- $payload = [
- 'customer_id' => $customer->id,
- 'project_name' => 'Client Redesign',
- 'project_status' => ProjectStatus::ACTIVE->value,
- 'description' => 'Modernizing UX',
- 'end_at' => '2025-06-30',
- ];
-
- /* Act */
- $component = Livewire::actingAs($this->user)
- ->test(ListProjects::class)
- ->mountAction('create')
- ->fillForm($payload)
- ->callMountedAction();
-
- /* Assert */
- $component
- ->assertHasFormErrors(['start_at']);
-
- $this->assertDatabaseMissing('projects', $payload);
- }
-
- #[Test]
- #[Group('crud')]
- /**
- * @payload
- * {
- * "project_name": "Updated Project Name"
- * }
- */
- public function it_updates_a_project_through_a_modal(): void
- {
- /* Arrange */
- $customer = Relation::factory()->for($this->company)->create(['company_name' => 'Test Client']);
- $project = Project::factory()->for($this->company)->create([
- 'project_name' => 'Old Project Name',
- 'customer_id' => $customer->id,
- 'project_status' => ProjectStatus::ACTIVE->value,
- ]);
-
- $updatedData = [
- 'project_name' => 'Updated Project Name',
- 'description' => 'Updated description',
- ];
-
- /* Act */
- $component = Livewire::actingAs($this->user)
- ->test(ListProjects::class)
- ->mountAction(TestAction::make('edit')->table($project), $updatedData)
- ->fillForm($updatedData)
- ->callMountedAction()
- ->assertHasNoFormErrors();
-
- /* Assert */
- $component->assertSuccessful();
- $this->assertDatabaseHas('projects', array_merge(
- ['id' => $project->id],
- $updatedData
- ));
- }
- # endregion
-
# region crud
#[Test]
#[Group('crud')]
diff --git a/Modules/Projects/Tests/Feature/TasksTest.php b/Modules/Projects/Tests/Feature/TasksTest.php
index ecf2cc98a..a5c1d435a 100644
--- a/Modules/Projects/Tests/Feature/TasksTest.php
+++ b/Modules/Projects/Tests/Feature/TasksTest.php
@@ -68,282 +68,6 @@ public function it_lists_tasks(): void
}
# endregion
- # region modals
- #[Test]
- #[Group('crud')]
- /**
- * @payload
- * {
- * "company_id": 1,
- * "customer_id": 2,
- * "project_id": 3,
- * "tax_rate_id": 4,
- * "assigned_to": "john.doe@example.com",
- * "task_status": "in_progress",
- * "name": "Design Landing Page",
- * "price": "150.00",
- * "due_at": "2025-05-20",
- * "description": "Create a responsive landing page"
- * }
- */
- public function it_creates_a_task_through_a_modal(): void
- {
- /* Arrange */
- $customer = Customer::factory()->for($this->company)->create(['company_name' => '::customer_name::']);
- $project = Project::factory()
- ->for($customer, 'customer')
- ->for($this->company)
- ->create([
- 'project_name' => '::project_name::',
- ]);
- $taxRate = TaxRate::factory()->create([
- 'name' => '::taxrate_name::',
- ]);
-
- $payload = [
- 'project_id' => $project->id,
- 'customer_id' => $customer->id,
- 'tax_rate_id' => $taxRate->id,
- 'assigned_to' => null,
- 'task_status' => TaskStatus::OPEN->value,
- 'task_name' => 'Design Landing Page',
- 'task_price' => 150.00,
- 'due_at' => now()->addDays(5)->format('Y-m-d'),
- 'description' => 'Create a responsive landing page',
- ];
-
- /* Act */
- $component = Livewire::actingAs($this->user)
- ->test(ListTasks::class)
- ->mountAction('create')
- ->fillForm($payload)
- ->callMountedAction()
- ->assertHasNoFormErrors();
-
- /* Assert */
- $component
- ->assertSuccessful()
- ->assertNotSet('isSaving', true);
-
- $this->assertDatabaseHas('tasks', array_merge(
- $payload,
- [
- 'due_at' => isset($payload['due_at']) ? $this->formatDateForDb($payload['due_at']) : null,
- ]
- ));
- }
-
- #[Test]
- #[Group('crud')]
- /**
- * @payload missing: name
- * {
- * "company_id": 1,
- * "customer_id": 2,
- * "project_id": 3,
- * "tax_rate_id": 4,
- * "assigned_to": "john.doe@example.com",
- * "task_status": "in_progress",
- * "price": "150.00",
- * "due_at": "2025-05-20",
- * "description": "Create a responsive landing page"
- * }
- */
- public function it_fails_to_create_task_through_a_modal_without_required_task_name(): void
- {
- /* Arrange */
- $customer = Customer::factory()->for($this->company)->create(['company_name' => '::customer_name::']);
- $project = Project::factory()
- ->for($customer, 'customer')
- ->for($this->company)
- ->create([
- 'project_name' => '::project_name::',
- ]);
-
- $taxRate = TaxRate::factory()->create(['company_id' => $this->company->id]);
-
- $payload = [
- 'project_id' => $project->id,
- 'customer_id' => $customer->id,
- 'tax_rate_id' => $taxRate->id,
- 'assigned_to' => null,
- 'task_status' => TaskStatus::OPEN->value,
- 'task_price' => 150.00,
- 'due_at' => now()->addDays(5)->format('Y-m-d'),
- 'description' => 'Create a responsive landing page',
- ];
-
- /* Act */
- Livewire::actingAs($this->user)
- ->test(ListTasks::class)
- ->mountAction('create')
- ->fillForm($payload)
- ->callMountedAction()
- ->assertHasFormErrors(['task_name' => 'required']);
-
- /* Assert */
- $this->assertDatabaseMissing('tasks', $payload);
- }
-
- #[Test]
- #[Group('crud')]
- /**
- * @payload missing: project_id
- * {
- * "company_id": 1,
- * "tax_rate_id": 4,
- * "assigned_to": "john.doe@example.com",
- * "task_status": "in_progress",
- * "task_name": "Design Landing Page",
- * "task_price": "150.00",
- * "due_at": "2025-05-20",
- * "description": "Create a responsive landing page"
- * }
- */
- public function it_fails_to_create_task_through_a_modal_without_required_project(): void
- {
- /* Arrange */
-
- $taxRate = TaxRate::factory()
- ->for($this->company)
- ->create(['name' => '::taxrate_name::']);
-
- $payload = [
- // 'project_id' intentionally omitted
- 'tax_rate_id' => $taxRate->id,
- 'assigned_to' => null,
- 'task_status' => TaskStatus::OPEN->value,
- 'task_name' => 'Design Landing Page',
- 'task_price' => 150.00,
- 'due_at' => '2025-06-01',
- 'description' => 'Create a responsive landing page',
- ];
-
- /* Act */
- $component = Livewire::actingAs($this->user)
- ->test(ListTasks::class)
- ->mountAction('create')
- ->fillForm($payload)
- ->callMountedAction();
-
- /* Assert */
- $component->assertHasFormErrors(['project_id' => 'required']);
- $this->assertDatabaseMissing('tasks', $payload);
- }
-
- #[Test]
- #[Group('crud')]
- /**
- * @payload missing: tax_rate
- * {
- * "company_id": 1,
- * "customer_id": 2,
- * "project_id": 3,
- * "assigned_to": "john.doe@example.com",
- * "task_status": "in_progress",
- * "price": "150.00",
- * "due_at": "2025-05-20",
- * "description": "Create a responsive landing page"
- * }
- */
- public function it_fails_to_create_task_through_a_modal_without_required_tax_rate(): void
- {
- /* Arrange */
- $customer = Customer::factory()->for($this->company)->create(['company_name' => '::customer_name::']);
- $project = Project::factory()
- ->for($customer, 'customer')
- ->for($this->company)
- ->create([
- 'project_name' => '::project_name::',
- ]);
-
- $payload = [
- 'project_id' => $project->id,
- 'customer_id' => $customer->id,
- 'assigned_to' => $this->user->id,
- 'task_status' => TaskStatus::OPEN->value,
- 'task_name' => 'Design Landing Page',
- 'task_price' => 150.00,
- 'due_at' => now()->addDays(5)->format('Y-m-d'),
- 'description' => 'Create a responsive landing page',
- ];
-
- /* Act */
- $component = Livewire::actingAs($this->user)
- ->test(ListTasks::class)
- ->mountAction('create')
- ->fillForm($payload)
- ->callMountedAction();
-
- /* Assert */
- $component->assertHasFormErrors(['tax_rate_id' => 'required']);
-
- $this->assertDatabaseMissing('tasks', $payload);
- }
-
- #[Test]
- #[Group('crud')]
- /**
- * @payload
- * {
- * "company_id": "Value",
- * "customer_id": "Value",
- * "project_id": "Value",
- * "tax_rate_id": "Value",
- * "assigned_to": "Example",
- * "task_status": "Value",
- * "task_name": "Example",
- * "price": "9.99",
- * "due_at": "2025-04-30",
- * "description": "Example"
- * }
- */
- public function it_updates_a_task_through_a_modal(): void
- {
- /* Arrange */
- $customer = Customer::factory()->for($this->company)->create(['company_name' => '::customer_name::']);
- $project = Project::factory()
- ->for($customer, 'customer')
- ->for($this->company)
- ->create([
- 'project_name' => '::project_name::',
- ]);
- $taxRate = TaxRate::factory()->create(['company_id' => $this->company->id]);
-
- $task = Task::factory()
- ->for($this->company)
- ->for($customer)
- ->for($project)
- ->for($taxRate, 'taxRate')
- ->create([
- 'assigned_to' => $this->user->id,
- 'tax_rate_id' => $taxRate->id,
- ]);
-
- $updatedData = [
- 'task_name' => 'Updated Task Name',
- 'task_price' => 199.99,
- 'description' => 'Updated description',
- ];
-
- /* Act */
- $component = Livewire::actingAs($this->user)
- ->test(ListTasks::class, ['record' => $task->getKey()])
- ->mountAction(TestAction::make('edit')->table($task), $updatedData)
- ->fillForm($updatedData)
- ->callMountedAction();
-
- /* Assert */
- $component
- ->assertSuccessful()
- ->assertHasNoErrors();
-
- $this->assertDatabaseHas('tasks', array_merge($updatedData, [
- 'id' => $task->getKey(),
- ]));
- }
- # endregion
-
# region crud
#[Test]
#[Group('crud')]
diff --git a/Modules/Quotes/Filament/Company/Resources/Quotes/Pages/ListQuotes.php b/Modules/Quotes/Filament/Company/Resources/Quotes/Pages/ListQuotes.php
index 45beaae7a..ff7749d11 100644
--- a/Modules/Quotes/Filament/Company/Resources/Quotes/Pages/ListQuotes.php
+++ b/Modules/Quotes/Filament/Company/Resources/Quotes/Pages/ListQuotes.php
@@ -2,11 +2,9 @@
namespace Modules\Quotes\Filament\Company\Resources\Quotes\Pages;
-use Filament\Actions\Action;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
use Modules\Quotes\Filament\Company\Resources\Quotes\QuoteResource;
-use Modules\Quotes\Services\QuoteService;
class ListQuotes extends ListRecords
{
@@ -16,16 +14,7 @@ protected function getHeaderActions(): array
{
return [
CreateAction::make()
- ->action(function (array $data, Action $action) {
- $quote = app(QuoteService::class)->createQuote($data);
-
- if (filled($quote->quote_number)) {
- $action->successNotificationTitle(
- trans('ip.quote_created_with_number', ['number' => $quote->quote_number])
- );
- }
- })
- ->modalWidth('full'),
+ ->url(fn (): string => QuoteResource::getUrl('create')),
];
}
}
diff --git a/Modules/Quotes/Filament/Company/Resources/Quotes/Schemas/QuoteForm.php b/Modules/Quotes/Filament/Company/Resources/Quotes/Schemas/QuoteForm.php
index d5eb2d7e2..e6f4268e7 100644
--- a/Modules/Quotes/Filament/Company/Resources/Quotes/Schemas/QuoteForm.php
+++ b/Modules/Quotes/Filament/Company/Resources/Quotes/Schemas/QuoteForm.php
@@ -4,17 +4,14 @@
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\MarkdownEditor;
-use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
-use Filament\Infolists\Components\TextEntry;
-use Filament\Schemas\Components\Fieldset;
use Filament\Schemas\Components\Grid;
-use Filament\Schemas\Components\Group;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Schema;
+use Filament\Support\Icons\Heroicon;
use Modules\Clients\Enums\RelationType;
use Modules\Clients\Services\RelationService;
use Modules\Core\Enums\NumberingType;
@@ -35,57 +32,48 @@ public static function configure(Schema $schema): Schema
return $schema
->components([
- Grid::make(5)
+ Grid::make(3)
->columnSpanFull()
->schema([
- Group::make()
+ Section::make(trans('ip.quote_details'))
+ ->icon(Heroicon::OutlinedDocumentText)
+ ->columnSpan(2)
->schema([
- Select::make('prospect_id')
- ->label(trans('ip.customer_name'))
- ->relationship('prospect', 'company_name')
- ->searchable()
- ->preload()
- ->required()
- ->createOptionForm([
- TextInput::make('company_name')
+ Grid::make(2)
+ ->schema([
+ Select::make('prospect_id')
->label(trans('ip.customer_name'))
+ ->prefixIcon(Heroicon::OutlinedUserGroup)
+ ->relationship('prospect', 'company_name')
+ ->searchable()
+ ->preload()
->required()
- // relations.company_name is varchar(150).
- ->maxLength(150),
- ])
- ->createOptionUsing(function (array $data): int {
- // Filament's default createOptionUsing() does a raw
- // Relation::create($data), which omits relation_type /
- // relation_number / registered_at — all NOT NULL with no
- // DB default — and 500s. RelationService::createRelation()
- // fills those. Mirrors InvoiceForm's customer_id select.
- return app(RelationService::class)->createRelation([
- 'relation_type' => RelationType::PROSPECT->value,
- 'company_name' => $data['company_name'],
- ])->getKey();
- })
- ->reactive(),
+ ->createOptionForm([
+ TextInput::make('company_name')
+ ->label(trans('ip.customer_name'))
+ ->required()
+ ->maxLength(150),
+ ])
+ ->createOptionUsing(function (array $data): int {
+ return app(RelationService::class)->createRelation([
+ 'relation_type' => RelationType::PROSPECT->value,
+ 'company_name' => $data['company_name'],
+ ])->getKey();
+ })
+ ->reactive(),
- Fieldset::make(trans('ip.client_information'))
- ->extraAttributes([
- 'class' => '!border-curious-200 dark:!border-curious-600 rounded-2xl !p-4',
- ])
- ->schema([
- Placeholder::make('customer_info')
- ->label(trans('ip.client'))
- ->content(fn (Get $get) => optional($get('prospect'))->company_name ?? '-'),
- ])
- ->columns(1)
- ->visible(fn (Get $get) => filled($get('prospect_id'))),
- ])
- ->columnSpan(3),
+ Select::make('numbering_id')
+ ->label(trans('ip.numbering'))
+ ->prefixIcon(Heroicon::OutlinedQueueList)
+ ->relationship('numbering', 'name', fn ($query) => $query->where('type', NumberingType::QUOTE->value))
+ ->required()
+ ->searchable()
+ ->preload()
+ ->native(false),
- Group::make()
- ->schema([
- Grid::make(2)
- ->schema([
TextInput::make('quote_number')
->label(trans('ip.quote_number'))
+ ->prefixIcon(Heroicon::OutlinedHashtag)
->required()
->default(function (Get $get, string $operation) {
if ($operation !== 'create') {
@@ -98,6 +86,7 @@ public static function configure(Schema $schema): Schema
Select::make('quote_status')
->label(trans('ip.quote_status'))
+ ->prefixIcon(Heroicon::OutlinedCheckBadge)
->required()
->options(
collect(QuoteStatus::cases())
@@ -116,9 +105,6 @@ public static function configure(Schema $schema): Schema
->native(false)
->reactive()
->afterStateUpdated(function (callable $set, Get $get, string $operation): void {
- // Only (re)generate on create, and only when the field is still
- // empty -- never clobber a number the user already typed or one
- // that was already generated for this record.
if ($operation !== 'create' || filled($get('quote_number'))) {
return;
}
@@ -128,35 +114,69 @@ public static function configure(Schema $schema): Schema
DatePicker::make('quoted_at')
->label(trans('ip.quote_date'))
+ ->prefixIcon(Heroicon::OutlinedCalendar)
->default(now())
->native(false),
DatePicker::make('quote_expires_at')
->label(trans('ip.quote_expires_at'))
- ->native(false),
-
- Select::make('numbering_id')
- ->label(trans('ip.numbering'))
- ->relationship('numbering', 'name', fn ($query) => $query->where('type', NumberingType::QUOTE->value))
- ->required()
- ->searchable()
- ->preload()
+ ->prefixIcon(Heroicon::OutlinedCalendarDays)
->native(false),
TextInput::make('client_reference')
->label(trans('ip.client_reference'))
+ ->prefixIcon(Heroicon::OutlinedBookmark)
->maxLength(255),
TextInput::make('work_order')
->label(trans('ip.work_order'))
+ ->prefixIcon(Heroicon::OutlinedClipboardDocumentList)
->maxLength(255),
- ])
- ->columns(2),
- ])
- ->columnSpan(2),
+ ]),
+ ]),
+
+ Section::make(trans('ip.quote_totals'))
+ ->icon(Heroicon::OutlinedCalculator)
+ ->columnSpan(1)
+ ->columns(2)
+ ->schema([
+ TextInput::make('quote_subtotal')
+ ->label(trans('ip.subtotal'))
+ ->prefixIcon(Heroicon::OutlinedBanknotes)
+ ->disabled()
+ ->dehydrated()
+ ->reactive()
+ ->afterStateUpdated(function (callable $set, callable $get) {
+ (new QuoteCalculator())->updateGrandTotal($set, $get, 'quoteItems', 'subtotal', 'quote_item_subtotal');
+ }),
+
+ TextInput::make('quote_discount_amount')
+ ->label(trans('ip.discount_amount'))
+ ->prefixIcon(Heroicon::OutlinedMinusCircle)
+ ->nullable(),
+
+ TextInput::make('quote_discount_percent')
+ ->label(trans('ip.discount_percent'))
+ ->prefixIcon(Heroicon::OutlinedReceiptPercent)
+ ->nullable()
+ ->dehydrated(false),
+
+ TextInput::make('quote_tax_total')
+ ->label(trans('ip.tax_total'))
+ ->prefixIcon(Heroicon::OutlinedBuildingLibrary)
+ ->disabled(),
+
+ TextInput::make('quote_total')
+ ->label(trans('ip.total'))
+ ->prefixIcon(Heroicon::OutlinedCurrencyDollar)
+ ->disabled()
+ ->columnSpanFull(),
+ ]),
]),
Section::make(trans('ip.quote_items'))
+ ->icon(Heroicon::OutlinedListBullet)
+ ->columnSpanFull()
->schema([
Repeater::make('quoteItems')
->relationship('quoteItems')
@@ -164,102 +184,62 @@ public static function configure(Schema $schema): Schema
->reorderable()
->addActionLabel(trans('ip.add_new_row'))
->schema([
- Grid::make(6)
+ Grid::make(12)
->schema([
Select::make('product_id')
->label(trans('ip.product'))
+ ->prefixIcon(Heroicon::OutlinedCube)
->options(Product::query()->pluck('product_name', 'id')->toArray())
->searchable()
->preload()
->required()
->placeholder(trans('ip.select_product'))
- ->reactive()
- ->afterStateUpdated(function (callable $set, $state) {
- $product = Product::query()->find($state);
- $set('product_name', $product?->product_name ?? '');
- }),
-
- TextEntry::make('product_name')
- ->disabled(),
+ ->columnSpan(4),
TextInput::make('quantity')
->label(trans('ip.quantity'))
+ ->prefixIcon(Heroicon::OutlinedHashtag)
->numeric()
->required()
->reactive()
- ->afterStateUpdated(fn (callable $set, callable $get) => (new QuoteCalculator())->updateItemTotals($set, $get)),
+ ->afterStateUpdated(fn (callable $set, callable $get) => (new QuoteCalculator())->updateItemTotals($set, $get))
+ ->columnSpan(2),
TextInput::make('price')
->label(trans('ip.price'))
+ ->prefixIcon(Heroicon::OutlinedCurrencyDollar)
->numeric()
->required()
->reactive()
- ->afterStateUpdated(fn (callable $set, callable $get) => (new QuoteCalculator())->updateItemTotals($set, $get)),
+ ->afterStateUpdated(fn (callable $set, callable $get) => (new QuoteCalculator())->updateItemTotals($set, $get))
+ ->columnSpan(2),
TextInput::make('discount')
->label(trans('ip.discount'))
+ ->prefixIcon(Heroicon::OutlinedMinusCircle)
->numeric()
->default(0)
->reactive()
- ->afterStateUpdated(fn (callable $set, callable $get) => (new QuoteCalculator())->updateItemTotals($set, $get)),
+ ->afterStateUpdated(fn (callable $set, callable $get) => (new QuoteCalculator())->updateItemTotals($set, $get))
+ ->columnSpan(2),
TextInput::make('subtotal')
->label(trans('ip.subtotal'))
+ ->prefixIcon(Heroicon::OutlinedBanknotes)
->dehydrated()
- ->disabled(),
- ])
- ->columns(5),
+ ->disabled()
+ ->columnSpan(2),
+ ]),
])
->columns(1)
->reactive()
->dehydrated()
->defaultItems(0)
->afterStateUpdated(function (callable $set, $get, $state) {}),
- ])
- ->collapsed()
- ->columnSpanFull(),
-
- Section::make(trans('ip.quote_totals'))
- ->schema([
- Grid::make(2)
- ->schema([
- Group::make()
- ->schema([]),
-
- Group::make()
- ->schema([
- TextInput::make('quote_subtotal')
- ->label(trans('ip.subtotal'))
- ->disabled()
- ->dehydrated()
- ->reactive()
- ->afterStateUpdated(function (callable $set, callable $get) {
- (new QuoteCalculator())->updateGrandTotal($set, $get, 'quoteItems', 'subtotal', 'quote_item_subtotal');
- }),
-
- TextInput::make('quote_discount_amount')
- ->label(trans('ip.discount_amount'))
- ->nullable(),
-
- TextInput::make('quote_discount_percent')
- ->label(trans('ip.discount_percent'))
- ->nullable()
- ->dehydrated(false),
-
- TextInput::make('quote_tax_total')
- ->label(trans('ip.tax_total'))
- ->disabled(),
-
- TextInput::make('quote_total')
- ->label(trans('ip.total'))
- ->disabled(),
- ]),
- ]),
- ])
- ->collapsed()
- ->columns(2),
+ ]),
Section::make(trans('ip.quote_notes'))
+ ->icon(Heroicon::OutlinedPencilSquare)
->schema([
MarkdownEditor::make('notes')
->label(trans('ip.notes'))
diff --git a/Modules/Quotes/Filament/Company/Resources/Quotes/Tables/QuotesTable.php b/Modules/Quotes/Filament/Company/Resources/Quotes/Tables/QuotesTable.php
index 269051159..33453c732 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;
@@ -68,10 +69,9 @@ public static function configure(Table $table): Table
ActionGroup::make([
EditAction::make('edit')
->visible(fn () => auth()->user()?->can(Permission::EDIT_QUOTES->value))
- ->action(function (Quote $record, array $data) {
- app(QuoteService::class)->updateQuote($record, $data);
- })
- ->modalWidth('full'),
+ ->url(fn (Quote $record): string => QuoteResource::getUrl('edit', [
+ 'record' => $record,
+ ])),
Action::make('duplicate')
->visible(fn () => auth()->user()?->can(Permission::DUPLICATE_QUOTES->value))
diff --git a/Modules/Quotes/Tests/Feature/QuoteNumberGenerationOnCreateTest.php b/Modules/Quotes/Tests/Feature/QuoteNumberGenerationOnCreateTest.php
index fb86fdb61..7171281f6 100644
--- a/Modules/Quotes/Tests/Feature/QuoteNumberGenerationOnCreateTest.php
+++ b/Modules/Quotes/Tests/Feature/QuoteNumberGenerationOnCreateTest.php
@@ -2,7 +2,6 @@
namespace Modules\Quotes\Tests\Feature;
-use Illuminate\Support\Str;
use Livewire\Livewire;
use Modules\Clients\Models\Relation;
use Modules\Core\Enums\NumberingType;
@@ -10,12 +9,12 @@
use Modules\Core\Models\Setting;
use Modules\Core\Tests\AbstractCompanyPanelTestCase;
use Modules\Quotes\Enums\QuoteStatus;
-use Modules\Quotes\Filament\Company\Resources\Quotes\Pages\ListQuotes;
+use Modules\Quotes\Filament\Company\Resources\Quotes\Pages\CreateQuote;
use Modules\Quotes\Models\Quote;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
-#[CoversClass(ListQuotes::class)]
+#[CoversClass(CreateQuote::class)]
class QuoteNumberGenerationOnCreateTest extends AbstractCompanyPanelTestCase
{
#[Test]
@@ -35,10 +34,9 @@ public function it_auto_generates_a_quote_number_on_create_when_none_is_supplied
/* Act */
$component = Livewire::actingAs($this->user)
- ->test(ListQuotes::class, ['tenant' => Str::lower($this->company->search_code)])
- ->mountAction('create')
+ ->test(CreateQuote::class)
->fillForm($payload)
- ->callMountedAction();
+ ->call('create');
/* Assert */
$component->assertHasNoFormErrors();
@@ -73,10 +71,9 @@ public function it_does_not_auto_populate_quote_number_for_drafts_when_the_setti
/* Act */
$component = Livewire::actingAs($this->user)
- ->test(ListQuotes::class, ['tenant' => Str::lower($this->company->search_code)])
- ->mountAction('create')
+ ->test(CreateQuote::class)
->fillForm($payload)
- ->callMountedAction();
+ ->call('create');
/* Assert */
$component->assertHasFormErrors(['quote_number' => 'required']);
@@ -101,17 +98,16 @@ public function it_still_generates_a_quote_number_for_non_draft_status_when_the_
// quote_status is set via a real Livewire property update (not fillForm,
// which bypasses afterStateUpdated hooks) so the form's reactive
// regeneration of quote_number on status change actually fires --
- // mirroring a user picking "Approved" interactively in the modal.
+ // mirroring a user picking "Approved" interactively on the create page.
$payload = $this->basePayload($prospect->id, $numbering->id, QuoteStatus::APPROVED->value);
unset($payload['quote_status']);
/* Act */
$component = Livewire::actingAs($this->user)
- ->test(ListQuotes::class, ['tenant' => Str::lower($this->company->search_code)])
- ->mountAction('create')
- ->set('mountedActions.0.data.quote_status', QuoteStatus::APPROVED->value)
+ ->test(CreateQuote::class)
+ ->set('data.quote_status', QuoteStatus::APPROVED->value)
->fillForm($payload)
- ->callMountedAction();
+ ->call('create');
/* Assert */
$component->assertHasNoFormErrors();
diff --git a/Modules/Quotes/Tests/Feature/QuotesTest.php b/Modules/Quotes/Tests/Feature/QuotesTest.php
index 18ba852cd..180823120 100644
--- a/Modules/Quotes/Tests/Feature/QuotesTest.php
+++ b/Modules/Quotes/Tests/Feature/QuotesTest.php
@@ -61,290 +61,6 @@ public function it_lists_quotes(): void
}
# endregion
- # region modals
- #[Test]
- #[Group('crud')]
- public function it_creates_a_quote_through_a_modal(): void
- {
- /* Arrange */
- $prospect = Relation::factory()->for($this->company)->prospect()->create();
- $documentGroup = Numbering::factory()->for($this->company)->state(['type' => NumberingType::QUOTE->value])->create();
-
- $taxRate = TaxRate::factory()->for($this->company)->create();
- $productCategory = ProductCategory::factory()->for($this->company)->create();
- $productUnit = ProductUnit::factory()->for($this->company)->create();
- $product = Product::factory()->for($this->company)->create([
- 'category_id' => $productCategory->id,
- 'unit_id' => $productUnit->id,
- 'tax_rate_id' => $taxRate->id,
- 'tax_rate_2_id' => null,
- ]);
-
- $payload = [
- 'quote_number' => 'Q-0001',
- 'prospect_id' => $prospect->id,
- 'numbering_id' => $documentGroup->id,
- 'quote_status' => QuoteStatus::DRAFT->value,
- 'quoted_at' => now()->format('Y-m-d'),
- 'quote_expires_at' => now()->addDays(30)->format('Y-m-d'),
- 'quote_discount_amount' => 0.0000,
- 'quote_discount_percent' => 0.0000,
- 'quote_tax_total' => 60,
- 'quote_item_subtotal' => 300,
- 'quote_total' => 360,
- 'quoteItems' => [
- [
- 'product_id' => $product->id,
- 'product_unit_id' => $productUnit->id,
- 'item_name' => 'Design',
- 'quantity' => 2,
- 'price' => 150,
- 'subtotal' => 300,
- 'total' => 300,
- ],
- ],
- ];
-
- /* Act */
- $component = Livewire::actingAs($this->user)
- ->test(ListQuotes::class, ['tenant' => Str::lower($this->company->search_code)])
- ->mountAction('create')
- ->fillForm($payload)
- ->callMountedAction();
-
- /* Assert */
- $component->assertHasNoFormErrors();
-
- // Patch date fields for DB assertion
- $dbPayload = Arr::except($payload, [
- 'quoteItems', 'quote_total', 'quote_item_subtotal',
- 'quote_tax_total', 'quote_discount_amount', 'quote_discount_percent',
- ]);
- if (isset($dbPayload['quoted_at'])) {
- $dbPayload['quoted_at'] = $dbPayload['quoted_at'] . ' 00:00:00';
- }
- if (isset($dbPayload['quote_expires_at'])) {
- $dbPayload['quote_expires_at'] = $dbPayload['quote_expires_at'] . ' 00:00:00';
- }
- $this->assertDatabaseHas('quotes', $dbPayload);
-
- // Regression guard for the add_default_to_quote_discount_percent
- // migration: quote_discount_percent is ->dehydrated(false) on the
- // form, so the user's 0.0000 in $payload never reaches the DB — the
- // NOT NULL column is filled purely by the schema default this
- // migration added. FormDbConstraintAuditTest can't catch a revert
- // here (it early-returns on a non-dehydrated field), so assert it.
- $this->assertSame(0.0, (float) Quote::query()->latest('id')->value('quote_discount_percent'));
- }
-
- #[Test]
- #[Group('crud')]
- /**
- * @payload missing: prospect_id
- * {
- * "quote_status": "draft",
- * "quoted_at": "2025-05-10",
- * "quote_expires_at": "2025-06-09"
- * }
- */
- public function it_fails_to_create_a_quote_through_a_modal_without_required_prospect(): void
- {
- /* Arrange */
- $documentGroup = Numbering::factory()->for($this->company)->state(['type' => NumberingType::QUOTE->value])->create();
-
- $taxRate = TaxRate::factory()->for($this->company)->create();
- $productCategory = ProductCategory::factory()->for($this->company)->create();
- $productUnit = ProductUnit::factory()->for($this->company)->create();
- $product = Product::factory()->for($this->company)->create([
- 'category_id' => $productCategory->id,
- 'unit_id' => $productUnit->id,
- 'tax_rate_id' => $taxRate->id,
- 'tax_rate_2_id' => null,
- ]);
-
- $payload = [
- 'numbering_id' => $documentGroup->id,
- 'quote_status' => QuoteStatus::DRAFT->value,
- 'quoted_at' => now()->format('Y-m-d'),
- 'quote_expires_at' => now()->addDays(30)->format('Y-m-d'),
- 'quote_item_subtotal' => 0,
- 'quote_tax_total' => 0,
- 'quote_total' => 0,
- 'quoteItems' => [
- [
- 'product_id' => $product->id,
- 'quantity' => 1,
- 'price' => 0,
- 'subtotal' => 0,
- 'total' => 0,
- ],
- ],
- ];
-
- /* Act */
- $component = Livewire::actingAs($this->user)
- ->test(ListQuotes::class)
- ->mountAction('create')
- ->fillForm($payload)
- ->callMountedAction();
-
- /* Assert */
- $component->assertHasFormErrors(['prospect_id' => 'required']);
- $this->assertDatabaseMissing('quotes', Arr::except($payload, ['quoteItems']));
- }
-
- #[Test]
- #[Group('crud')]
- /**
- * @payload missing: quote_number
- * {
- * "prospect_id": 1,
- * "quote_status": "draft",
- * "quote_discount_percent": 5,
- * "quote_item_subtotal": 200,
- * "quote_tax_total": 40,
- * "quote_total": 240
- * }
- */
- public function it_fails_to_create_quote_through_a_modal_without_required_quote_number(): void
- {
- /* Arrange */
- $prospect = Relation::factory()
- ->for($this->company)
- ->create(['relation_type' => 'prospect']);
-
- $payload = [
- 'prospect_id' => $prospect->id,
- 'quote_status' => QuoteStatus::DRAFT->value,
- 'quote_total' => 120.00,
- ];
-
- /* Act */
- $component = Livewire::actingAs($this->user)
- ->test(ListQuotes::class, ['tenant' => Str::lower($this->company->search_code)])
- ->mountAction('create')
- ->fillForm($payload)
- ->callMountedAction();
-
- /* Assert */
- $component->assertHasFormErrors(['quote_number']);
- }
-
- #[Test]
- #[Group('crud')]
- public function it_fails_to_create_quote_through_a_modal_without_required_quote_status(): void
- {
- /* Arrange */
- $prospect = Relation::factory()->for($this->company)->create(['relation_type' => 'prospect']);
-
- $payload = [
- 'prospect_id' => $prospect->id,
- 'quote_number' => 'Q-2025-004',
- 'quote_discount_percent' => 5,
- 'quote_item_subtotal' => 100,
- 'quote_tax_total' => 20,
- 'quote_total' => 120,
- ];
-
- /* Act */
- $component = Livewire::actingAs($this->user)
- ->test(ListQuotes::class, ['tenant' => Str::lower($this->company->search_code)])
- ->mountAction('create')
- ->fillForm($payload)
- ->callMountedAction();
-
- /* Assert */
- $component->assertHasFormErrors(['quote_status']);
- }
-
- #[Test]
- #[Group('crud')]
- #[Group('failing')]
- public function it_fails_to_create_quote_through_a_modal_without_required_quote_discount_percent(): void
- {
- $this->markTestSkipped('quote_discount_percent is nullable/dehydrated(false) — no required validation; computed by QuoteCalculator');
- }
-
- #[Test]
- #[Group('crud')]
- #[Group('failing')]
- public function it_fails_to_create_quote_through_a_modal_without_required_quote_item_subtotal(): void
- {
- $this->markTestSkipped('quote_item_subtotal is computed by QuoteCalculator — no standalone required validation');
- }
-
- #[Test]
- #[Group('crud')]
- #[Group('failing')]
- public function it_fails_to_create_quote_through_a_modal_without_required_quote_tax_total(): void
- {
- $this->markTestSkipped('quote_tax_total is computed by QuoteCalculator — no standalone required validation');
- }
-
- #[Test]
- #[Group('crud')]
- #[Group('failing')]
- public function it_fails_to_create_quote_through_a_modal_without_required_quote_total(): void
- {
- $this->markTestSkipped('quote_total is computed by QuoteCalculator — no standalone required validation');
- }
-
- #[Test]
- #[Group('crud')]
- /**
- * @payload missing: quote_status
- * {
- * "prospect_id": 1,
- * "quote_number": "Q-2025-01",
- * "quote_discount_percent": 5,
- * "quote_item_subtotal": 200,
- * "quote_tax_total": 40,
- * "quote_total": 240
- * }
- */
- public function it_updates_a_quote_through_a_modal(): void
- {
- /* Arrange */
- $prospect = Relation::factory()->for($this->company)->prospect()->create();
- $documentGroup = Numbering::factory()->for($this->company)->state(['type' => NumberingType::QUOTE->value])->create();
-
- $quote = Quote::factory()
- ->for($this->company)
- ->create([
- 'quote_number' => 'Q-0001',
- 'prospect_id' => $prospect->id,
- 'numbering_id' => $documentGroup->id,
- 'user_id' => $this->user->id,
- 'quote_status' => QuoteStatus::DRAFT->value,
- 'quoted_at' => now()->format('Y-m-d'),
- 'quote_expires_at' => now()->addDays(30)->format('Y-m-d'),
- 'quote_discount_amount' => 0.0000,
- 'quote_discount_percent' => 0.0000,
- 'quote_tax_total' => 60,
- 'quote_item_subtotal' => 300,
- 'quote_total' => 360,
- ]);
-
- $payload = ['quote_status' => QuoteStatus::SENT];
-
- /* Act */
- $component = Livewire::actingAs($this->user)
- ->test(ListQuotes::class, ['record' => $quote->id])
- ->mountAction(TestAction::make('edit')->table($quote), $payload)
- ->fillForm($payload)
- ->callMountedAction();
-
- /* Assert */
- $component
- ->assertSuccessful()
- ->assertHasNoErrors();
-
- $this->assertDatabaseHas('quotes', [
- 'id' => $quote->id,
- 'quote_status' => QuoteStatus::SENT->value,
- ]);
- }
-
#[Test]
#[Group('crud')]
public function it_persists_a_changed_numbering_id_on_update(): void
diff --git a/resources/css/filament/company/invoiceplane-blue.css b/resources/css/filament/company/invoiceplane-blue.css
deleted file mode 100644
index cbaaccd09..000000000
--- a/resources/css/filament/company/invoiceplane-blue.css
+++ /dev/null
@@ -1,217 +0,0 @@
-@import 'tailwindcss';
-@import '../../../../vendor/filament/filament/resources/css/theme.css';
-
-@source '../../../../Modules/**/resources/views/**/*';
-@source '../../../../Modules/**/*.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;
-}
diff --git a/resources/css/filament/company/invoiceplane.css b/resources/css/filament/company/invoiceplane.css
deleted file mode 100644
index cd97219e5..000000000
--- a/resources/css/filament/company/invoiceplane.css
+++ /dev/null
@@ -1,212 +0,0 @@
-@import 'tailwindcss';
-@import '../../../../vendor/filament/filament/resources/css/theme.css';
-
-@source '../../../../app/Filament/Tenant/**/*';
-@source '../../../../resources/views/filament/tenant/**/*';
-@source '../../../../Modules/**/resources/views/**/*';
-@source '../../../../Modules/**/*.php';
-
-.fi-bg-color-600 {
- @apply bg-primary-700;
- @apply hover:bg-primary-500;
-}
-
-.fi-topbar {
- /* Background color */
- @apply bg-primary-500;
-
- /* Collapse icon */
-
- .fi-topbar-close-collapse-sidebar-btn {
- @apply text-white;
- @apply hover:text-primary-600;
- }
-
- .fi-topbar-open-collapse-sidebar-btn {
- @apply text-white;
- @apply hover:text-primary-600;
- }
-
- .fi-logo {
- @apply text-white;
- }
-}
-
-.fi-sidebar {
- /* Background color */
- @apply bg-primary-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-primary-700!;
- @apply rounded-lg;
- }
-
- .fi-sidebar-header {
- @apply bg-primary-500;
- }
-}
-
-/*
- * Checkbox
- */
-.fi-checkbox-input {
- @apply ring-primary-700;
- @apply focus:ring-primary-500;
- @apply hover:ring-primary-500
-}
-
-.fi-header-heading {
- @apply text-primary-700;
-}
-
-.fi-checkbox-input:checked {
- @apply bg-primary-700;
- @apply hover:bg-primary-500;
-}
-
-/*
- * Open/collapse
- */
-.fi-section-collapse-btn {
- @apply text-primary-700;
- @apply hover:text-primary-500;
-}
-
-/*
- * Modals
- */
-.fi-modal-heading {
- @apply text-primary-700;
-}
-
-.fi-section-header-heading {
- @apply text-primary-700;
-}
-
-.fi-modal-close-btn {
- @apply text-primary-700;
- @apply hover:text-primary-500;
-}
-
-.fi-fo-field-label-content {
- @apply text-primary-700;
-}
-
-.fi-modal-content .fi-input-wrp-content-ctn {
- @apply text-primary-700;
-}
-
-.fi-modal-content .fi-input-wrp-content-ctn {
- @apply border border-primary-700 rounded-lg;
-}
-
-.fi-modal-content .fi-in-entry-label {
- @apply text-primary-700;
-}
-
-/*
- * Pagination
- */
-.fi-pagination-records-per-page-select-ctn {
- @apply border border-primary-700 rounded-lg;
-}
-
-.fi-pagination-previous-btn {
- @apply text-primary-700;
- @apply hover:text-primary-500;
-}
-
-.fi-pagination-next-btn {
- @apply text-primary-700;
- @apply hover:text-primary-500;
-}
-
-/*
- * Input
- */
-.fi-input-wrp-label {
- @apply text-primary-700;
-}
-
-/*
- * Breadcrums
- */
-.fi-breadcrumbs {
- @apply text-primary-700;
- @apply hover:text-primary-500;
-
-}
-
-.fi-breadcrumbs-item-label {
- @apply text-primary-700;
- @apply hover:text-primary-500;
-}
-
-.fi-breadcrumbs-item-separator {
- @apply text-primary-700;
- @apply hover:text-primary-500;
-}
-
-/*
- * Tables
- */
-.fi-ta-header-cell-sort-btn {
- @apply text-primary-700;
-}
-
-.fi-ta-header-cell-sort-btn .fi-icon {
- @apply text-primary-700;
-}
-
-.fi-ta-row {
- @apply border-b border-primary-700 rounded-lg;
-}
-
-.fi-dropdown-trigger {
- @apply text-primary-700;
-}
-
-.fi-ta-header-toolbar .fi-icon {
- @apply text-primary-700;
- @apply hover:text-primary-500;
-}
-
-/*
- * Buttons
- */
-.fi-ac-icon-btn-action {
- @apply text-primary-700;
- @apply hover:text-primary-500;
-}
-
-.fi-ta-search-field .fi-icon {
- @apply text-primary-700;
-}
-
-/*
- * User menu
- */
-.fi-user-menu .fi-icon {
- @apply text-primary-700;
-}
diff --git a/resources/css/filament/company/nord.css b/resources/css/filament/company/nord.css
deleted file mode 100644
index 695ac829e..000000000
--- a/resources/css/filament/company/nord.css
+++ /dev/null
@@ -1,394 +0,0 @@
-@import 'tailwindcss';
-@import '../../../../vendor/filament/filament/resources/css/theme.css';
-
-@source '../../../../Modules/**/resources/views/**/*';
-@source '../../../../Modules/**/*.php';
-@source '../../../../resources/views/filament/tenant/**/*';
-
-@theme {
- --color-primary-50: #F2F7FD;
- --color-primary-100: #E3EFFB;
- --color-primary-200: #C1DFF6;
- --color-primary-300: #8FC0EE;
- --color-primary-400: #429AE1;
- --color-primary-500: #2684D1;
- --color-primary-600: #1868B1;
- --color-primary-700: #145390;
- --color-primary-800: #154777;
- --color-primary-900: #173C63;
- --color-primary-950: #0F2742;
- --color-secondary-50: #E3E9F0;
- --color-secondary-100: #D1D7E0;
- --color-secondary-200: #A7B1C5;
- --color-secondary-300: #8C9AB3;
- --color-secondary-400: #71829B;
- --color-secondary-500: #5E81AC;
- --color-secondary-600: #527397;
- --color-secondary-700: #466582;
- --color-secondary-800: #3A576D;
- --color-secondary-900: #2E4958;
- --color-secondary-950: #223B43;
-
- /* Polar Night: Used for dark backgrounds and text */
- --color-polarnight-50: #e5e9f0;
- --color-polarnight-100: #d1d7e0;
- --color-polarnight-200: #a7b1c5;
- --color-polarnight-300: #8c9ab3;
- --color-polarnight-400: #71829b;
- --color-polarnight-500: #4c566a;
- --color-polarnight-600: #434c5e;
- --color-polarnight-700: #3b4252;
- --color-polarnight-800: #2e3440;
- --color-polarnight-900: #232831;
- --color-polarnight-950: #1b2027;
-
- /* Snow Storm: Used for light backgrounds and text */
- --color-snowstorm-400: #d8dee9;
- --color-snowstorm-500: #e5e9f0;
- --color-snowstorm-600: #eceff4;
-
- /* Frost: Used for primary and secondary accents */
- --color-frost-400: #8fbcbb;
- --color-frost-500: #88c0d0;
- --color-frost-600: #81a1c1;
- --color-frost-700: #5e81ac;
-
- /* Aurora: Used for success, warning, etc. */
- --color-aurora-danger: #bf616a;
- --color-aurora-warning: #ebcb8b;
- --color-aurora-success: #a3be8c;
- --color-aurora-info: #81a1c1;
- --color-aurora-purple: #b48ead;
- --color-aurora-orange: #d08770;
-
- /* Semantic color mapping to Nord palette */
- --color-danger-50: #fef2f2;
- --color-danger-100: #fee2e2;
- --color-danger-200: #fecaca;
- --color-danger-300: #fca5a5;
- --color-danger-400: #f87171;
- --color-danger-500: #bf616a;
- --color-danger-600: #b85860;
- --color-danger-700: #a84f56;
- --color-danger-800: #98464c;
- --color-danger-900: #883d42;
- --color-danger-950: #782f34;
-
- --color-warning-50: #fefce8;
- --color-warning-100: #fef9c3;
- --color-warning-200: #fef08a;
- --color-warning-300: #fde047;
- --color-warning-400: #facc15;
- --color-warning-500: #ebcb8b;
- --color-warning-600: #d4b87a;
- --color-warning-700: #bda569;
- --color-warning-800: #a69258;
- --color-warning-900: #8f7f47;
- --color-warning-950: #786c36;
-
- --color-success-50: #f0fdf4;
- --color-success-100: #dcfce7;
- --color-success-200: #bbf7d0;
- --color-success-300: #86efac;
- --color-success-400: #4ade80;
- --color-success-500: #a3be8c;
- --color-success-600: #93ae7d;
- --color-success-700: #839e6e;
- --color-success-800: #738e5f;
- --color-success-900: #637e50;
- --color-success-950: #536e41;
-
- --color-info-50: #eff6ff;
- --color-info-100: #dbeafe;
- --color-info-200: #bfdbfe;
- --color-info-300: #93c5fd;
- --color-info-400: #60a5fa;
- --color-info-500: #81a1c1;
- --color-info-600: #7491ae;
- --color-info-700: #67819b;
- --color-info-800: #5a7188;
- --color-info-900: #4d6175;
- --color-info-950: #405162;
-}
-
-.fi-bg-color-600 {
- @apply bg-[var(--color-frost-700)];
- @apply hover:bg-[var(--color-frost-500)];
-}
-
-.fi-topbar {
- /* Background color using Polar Night */
- @apply bg-[var(--color-polarnight-700)];
-
- /* Collapse icon */
-
- .fi-topbar-close-collapse-sidebar-btn {
- @apply text-[var(--color-snowstorm-600)];
- @apply hover:text-[var(--color-frost-500)];
- }
-
- .fi-topbar-open-collapse-sidebar-btn {
- @apply text-[var(--color-snowstorm-600)];
- @apply hover:text-[var(--color-frost-500)];
- }
-
- .fi-logo {
- @apply text-[var(--color-snowstorm-600)];
- }
-}
-
-.fi-sidebar {
- /* Background color using Polar Night */
- @apply bg-[var(--color-polarnight-700)];
- /* Text color using Snow Storm */
- @apply text-[var(--color-snowstorm-600)];
-
- .fi-icon {
- @apply text-[var(--color-snowstorm-600)];
- }
-
- .fi-icon-btn {
- @apply text-[var(--color-snowstorm-600)];
- }
-
- /* Sidebar items */
-
- .fi-sidebar-group-label {
- @apply text-[var(--color-snowstorm-600)];
- }
-
- .fi-sidebar-item-label {
- @apply text-[var(--color-snowstorm-600)];
- }
-
- .fi-sidebar-item.fi-active {
- @apply text-[var(--color-snowstorm-600)];
- @apply !bg-[var(--color-polarnight-800)];
- @apply rounded-lg;
- }
-
- /*
- * Filament's own gray-100 background on the active item's inner button
- * sits on top of the dark background above (unreadable light pill on a
- * dark sidebar) unless the child button itself is also overridden.
- * Same fix applies to hover/focus-visible on any sidebar item button.
- */
- .fi-sidebar-item.fi-active > .fi-sidebar-item-btn {
- @apply !bg-[var(--color-polarnight-800)];
- }
-
- .fi-sidebar-item-btn:hover,
- .fi-sidebar-item-btn:focus-visible {
- @apply !bg-[var(--color-polarnight-800)];
- }
-
- .fi-sidebar-group-dropdown-trigger-btn:hover,
- .fi-sidebar-database-notifications-btn:hover {
- @apply !bg-[var(--color-polarnight-800)];
- }
-
- .fi-sidebar-header {
- @apply bg-[var(--color-polarnight-700)];
- }
-}
-
-/*
- * Checkbox
- */
-.fi-checkbox-input {
- @apply ring-[var(--color-frost-700)];
- @apply focus:ring-[var(--color-frost-500)];
- @apply hover:ring-[var(--color-frost-500)]
-}
-
-.fi-header-heading {
- @apply text-[var(--color-frost-700)];
-}
-
-.fi-checkbox-input:checked {
- @apply bg-[var(--color-frost-700)];
- @apply hover:bg-[var(--color-frost-500)];
-}
-
-/*
- * Open/collapse
- */
-.fi-section-collapse-btn {
- @apply text-[var(--color-frost-700)];
- @apply hover:text-[var(--color-frost-500)];
-}
-
-/*
- * Modals
- */
-.fi-modal-heading {
- @apply text-[var(--color-frost-700)];
-}
-
-.fi-section-header-heading {
- @apply text-[var(--color-frost-700)];
-}
-
-.fi-modal-close-btn {
- @apply text-[var(--color-frost-700)];
- @apply hover:text-[var(--color-frost-500)];
-}
-
-.fi-fo-field-label-content {
- @apply text-[var(--color-frost-700)];
-}
-
-.fi-modal-content .fi-input-wrp-content-ctn {
- @apply text-[var(--color-frost-700)];
-}
-
-.fi-modal-content .fi-input-wrp-content-ctn {
- @apply border border-[var(--color-frost-700)] rounded-lg;
-}
-
-.fi-modal-content .fi-in-entry-label {
- @apply text-[var(--color-frost-700)];
-}
-
-/*
- * Pagination
- */
-.fi-pagination-records-per-page-select-ctn {
- @apply border border-[var(--color-frost-700)] rounded-lg;
-}
-
-.fi-pagination-previous-btn {
- @apply text-[var(--color-frost-700)];
- @apply hover:text-[var(--color-frost-500)];
-}
-
-.fi-pagination-next-btn {
- @apply text-[var(--color-frost-700)];
- @apply hover:text-[var(--color-frost-500)];
-}
-
-/*
- * Input
- */
-.fi-input-wrp-label {
- @apply text-[var(--color-frost-700)];
-}
-
-/*
- * Breadcrums
- */
-.fi-breadcrumbs {
- @apply text-[var(--color-frost-700)];
- @apply hover:text-[var(--color-frost-500)];
-
-}
-
-.fi-breadcrumbs-item-label {
- @apply text-[var(--color-frost-700)];
- @apply hover:text-[var(--color-frost-500)];
-}
-
-.fi-breadcrumbs-item-separator {
- @apply text-[var(--color-frost-700)];
- @apply hover:text-[var(--color-frost-500)];
-}
-
-/*
- * Tables
- */
-.fi-ta-header-cell-sort-btn {
- @apply text-[var(--color-frost-700)];
-}
-
-.fi-ta-header-cell-sort-btn .fi-icon {
- @apply text-[var(--color-frost-700)];
-}
-
-.fi-ta-row {
- @apply border-b border-[var(--color-frost-700)] rounded-lg;
-}
-
-.fi-dropdown-trigger {
- @apply text-[var(--color-frost-700)];
-}
-
-.fi-ta-header-toolbar .fi-icon {
- @apply text-[var(--color-frost-700)];
- @apply hover:text-[var(--color-frost-500)];
-}
-
-/*
- * Buttons
- */
-.fi-ac-icon-btn-action {
- @apply text-[var(--color-frost-700)];
- @apply hover:text-[var(--color-frost-500)];
-}
-
-.fi-ta-search-field .fi-icon {
- @apply text-[var(--color-frost-700)];
-}
-
-/*
- * User menu
- */
-.fi-user-menu .fi-icon {
- @apply text-[var(--color-frost-700)];
-}
-
-.fi-main {
- @apply bg-[#ECEFF4] dark:bg-[#2E3440];
-}
-
-/*
- * Base page background -- Filament's own light-gray fallback (.fi-body)
- * otherwise shows through around/behind panel content.
- */
-.fi-body {
- @apply dark:bg-[var(--color-polarnight-900)];
-}
-
-/*
- * Topbar items (e.g. tenant switcher trigger, user menu trigger) --
- * same light-gray-on-dark hover/active pattern as the sidebar.
- */
-.fi-topbar-item-btn:hover,
-.fi-topbar-item.fi-active .fi-topbar-item-btn,
-.fi-tenant-menu-trigger:hover,
-.fi-theme-switcher-btn:hover {
- @apply !bg-[var(--color-polarnight-800)];
-}
-
-/*
- * Dropdown menus (e.g. the user menu) -- item hover.
- */
-.fi-dropdown-list-item:hover {
- @apply dark:!bg-[var(--color-polarnight-800)];
-}
-
-/*
- * Tabs -- hover state on an inactive tab.
- */
-.fi-tabs-item:hover {
- @apply dark:!bg-[var(--color-polarnight-800)];
-}
-
-/*
- * Tables -- header row and clickable-row hover both default to a light
- * gray fill that reads poorly against the dark Nord table background.
- */
-.fi-ta-table > thead > tr {
- @apply dark:!bg-[var(--color-polarnight-800)];
-}
-
-.fi-ta-row.fi-clickable:hover {
- @apply dark:!bg-[var(--color-polarnight-800)];
-}
-
-/*
- * Pagination -- active page number.
- */
-.fi-pagination-item.fi-active .fi-pagination-item-btn {
- @apply dark:!bg-[var(--color-polarnight-800)];
-}
-
diff --git a/resources/css/filament/company/orange.css b/resources/css/filament/company/orange.css
deleted file mode 100644
index 8e832fb8f..000000000
--- a/resources/css/filament/company/orange.css
+++ /dev/null
@@ -1,212 +0,0 @@
-@import 'tailwindcss';
-@import '../../../../vendor/filament/filament/resources/css/theme.css';
-
-@source '../../../../app/Filament/Tenant/**/*';
-@source '../../../../resources/views/filament/tenant/**/*';
-@source '../../../../Modules/**/resources/views/**/*';
-@source '../../../../Modules/**/*.php';
-
-.fi-bg-color-600 {
- @apply bg-orange-700;
- @apply hover:bg-orange-500;
-}
-
-.fi-topbar {
- /* Background color */
- @apply bg-orange-500;
-
- /* Collapse icon */
-
- .fi-topbar-close-collapse-sidebar-btn {
- @apply text-white;
- @apply hover:text-orange-600;
- }
-
- .fi-topbar-open-collapse-sidebar-btn {
- @apply text-white;
- @apply hover:text-orange-600;
- }
-
- .fi-logo {
- @apply text-white;
- }
-}
-
-.fi-sidebar {
- /* Background color */
- @apply bg-orange-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-orange-700;
- @apply rounded-lg;
- }
-
- .fi-sidebar-header {
- @apply bg-orange-500;
- }
-}
-
-/*
- * Checkbox
- */
-.fi-checkbox-input {
- @apply ring-orange-700;
- @apply focus:ring-orange-500;
- @apply hover:ring-orange-500
-}
-
-.fi-header-heading {
- @apply text-orange-700;
-}
-
-.fi-checkbox-input:checked {
- @apply bg-orange-700;
- @apply hover:bg-orange-500;
-}
-
-/*
- * Open/collapse
- */
-.fi-section-collapse-btn {
- @apply text-orange-700;
- @apply hover:text-orange-500;
-}
-
-/*
- * Modals
- */
-.fi-modal-heading {
- @apply text-orange-700;
-}
-
-.fi-section-header-heading {
- @apply text-orange-700;
-}
-
-.fi-modal-close-btn {
- @apply text-orange-700;
- @apply hover:text-orange-500;
-}
-
-.fi-fo-field-label-content {
- @apply text-orange-700;
-}
-
-.fi-modal-content .fi-input-wrp-content-ctn {
- @apply text-orange-700;
-}
-
-.fi-modal-content .fi-input-wrp-content-ctn {
- @apply border border-orange-700 rounded-lg;
-}
-
-.fi-modal-content .fi-in-entry-label {
- @apply text-orange-700;
-}
-
-/*
- * Pagination
- */
-.fi-pagination-records-per-page-select-ctn {
- @apply border border-orange-700 rounded-lg;
-}
-
-.fi-pagination-previous-btn {
- @apply text-orange-700;
- @apply hover:text-orange-500;
-}
-
-.fi-pagination-next-btn {
- @apply text-orange-700;
- @apply hover:text-orange-500;
-}
-
-/*
- * Input
- */
-.fi-input-wrp-label {
- @apply text-orange-700;
-}
-
-/*
- * Breadcrums
- */
-.fi-breadcrumbs {
- @apply text-orange-700;
- @apply hover:text-orange-500;
-
-}
-
-.fi-breadcrumbs-item-label {
- @apply text-orange-700;
- @apply hover:text-orange-500;
-}
-
-.fi-breadcrumbs-item-separator {
- @apply text-orange-700;
- @apply hover:text-orange-500;
-}
-
-/*
- * Tables
- */
-.fi-ta-header-cell-sort-btn {
- @apply text-orange-700;
-}
-
-.fi-ta-header-cell-sort-btn .fi-icon {
- @apply text-orange-700;
-}
-
-.fi-ta-row {
- @apply border-b border-orange-700 rounded-lg;
-}
-
-.fi-dropdown-trigger {
- @apply text-orange-700;
-}
-
-.fi-ta-header-toolbar .fi-icon {
- @apply text-orange-700;
- @apply hover:text-orange-500;
-}
-
-/*
- * Buttons
- */
-.fi-ac-icon-btn-action {
- @apply text-orange-700;
- @apply hover:text-orange-500;
-}
-
-.fi-ta-search-field .fi-icon {
- @apply text-orange-700;
-}
-
-/*
- * User menu
- */
-.fi-user-menu .fi-icon {
- @apply text-orange-700;
-}
diff --git a/resources/css/filament/company/reddit.css b/resources/css/filament/company/reddit.css
deleted file mode 100644
index 4436211e1..000000000
--- a/resources/css/filament/company/reddit.css
+++ /dev/null
@@ -1,212 +0,0 @@
-@import 'tailwindcss';
-@import '../../../../vendor/filament/filament/resources/css/theme.css';
-
-@source '../../../../app/Filament/Tenant/**/*';
-@source '../../../../resources/views/filament/tenant/**/*';
-@source '../../../../Modules/**/resources/views/**/*';
-@source '../../../../Modules/**/*.php';
-
-.fi-bg-color-600 {
- @apply bg-[#FF4500];
- @apply hover:bg-[#ff5722];
-}
-
-.fi-topbar {
- /* Background color - Reddit orange */
- @apply bg-[#FF4500];
-
- /* Collapse icon */
-
- .fi-topbar-close-collapse-sidebar-btn {
- @apply text-white;
- @apply hover:text-[#ff5722];
- }
-
- .fi-topbar-open-collapse-sidebar-btn {
- @apply text-white;
- @apply hover:text-[#ff5722];
- }
-
- .fi-logo {
- @apply text-white;
- }
-}
-
-.fi-sidebar {
- /* Background color - Reddit orange */
- @apply bg-[#FF4500];
- /* 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-[#d93900];
- @apply rounded-lg;
- }
-
- .fi-sidebar-header {
- @apply bg-[#FF4500];
- }
-}
-
-/*
- * Checkbox
- */
-.fi-checkbox-input {
- @apply ring-[#d93900];
- @apply focus:ring-[#FF4500];
- @apply hover:ring-[#FF4500]
-}
-
-.fi-header-heading {
- @apply text-[#d93900];
-}
-
-.fi-checkbox-input:checked {
- @apply bg-[#d93900];
- @apply hover:bg-[#FF4500];
-}
-
-/*
- * Open/collapse
- */
-.fi-section-collapse-btn {
- @apply text-[#d93900];
- @apply hover:text-[#FF4500];
-}
-
-/*
- * Modals
- */
-.fi-modal-heading {
- @apply text-[#d93900];
-}
-
-.fi-section-header-heading {
- @apply text-[#d93900];
-}
-
-.fi-modal-close-btn {
- @apply text-[#d93900];
- @apply hover:text-[#FF4500];
-}
-
-.fi-fo-field-label-content {
- @apply text-[#d93900];
-}
-
-.fi-modal-content .fi-input-wrp-content-ctn {
- @apply text-[#d93900];
-}
-
-.fi-modal-content .fi-input-wrp-content-ctn {
- @apply border border-[#d93900] rounded-lg;
-}
-
-.fi-modal-content .fi-in-entry-label {
- @apply text-[#d93900];
-}
-
-/*
- * Pagination
- */
-.fi-pagination-records-per-page-select-ctn {
- @apply border border-[#d93900] rounded-lg;
-}
-
-.fi-pagination-previous-btn {
- @apply text-[#d93900];
- @apply hover:text-[#FF4500];
-}
-
-.fi-pagination-next-btn {
- @apply text-[#d93900];
- @apply hover:text-[#FF4500];
-}
-
-/*
- * Input
- */
-.fi-input-wrp-label {
- @apply text-[#d93900];
-}
-
-/*
- * Breadcrums
- */
-.fi-breadcrumbs {
- @apply text-[#d93900];
- @apply hover:text-[#FF4500];
-
-}
-
-.fi-breadcrumbs-item-label {
- @apply text-[#d93900];
- @apply hover:text-[#FF4500];
-}
-
-.fi-breadcrumbs-item-separator {
- @apply text-[#d93900];
- @apply hover:text-[#FF4500];
-}
-
-/*
- * Tables
- */
-.fi-ta-header-cell-sort-btn {
- @apply text-[#d93900];
-}
-
-.fi-ta-header-cell-sort-btn .fi-icon {
- @apply text-[#d93900];
-}
-
-.fi-ta-row {
- @apply border-b border-[#d93900] rounded-lg;
-}
-
-.fi-dropdown-trigger {
- @apply text-[#d93900];
-}
-
-.fi-ta-header-toolbar .fi-icon {
- @apply text-[#d93900];
- @apply hover:text-[#FF4500];
-}
-
-/*
- * Buttons
- */
-.fi-ac-icon-btn-action {
- @apply text-[#d93900];
- @apply hover:text-[#FF4500];
-}
-
-.fi-ta-search-field .fi-icon {
- @apply text-[#d93900];
-}
-
-/*
- * User menu
- */
-.fi-user-menu .fi-icon {
- @apply text-[#d93900];
-}
diff --git a/resources/css/filament/theme.css b/resources/css/filament/theme.css
new file mode 100644
index 000000000..e74373c43
--- /dev/null
+++ b/resources/css/filament/theme.css
@@ -0,0 +1,72 @@
+@import '../../../vendor/filament/filament/resources/css/theme.css';
+
+@source '../../../vendor/filament/**/*.blade.php';
+@source '../../../resources/**/*.blade.php';
+@source '../../../Modules/**/*.blade.php';
+@source '../../../Modules/**/*.php';
+@source '../../../storage/framework/views/*.php';
+
+/* Report Builder grid + band classes are built up dynamically in PHP, so Tailwind
+ can't see them via static source scanning — force-include them explicitly
+ (mirrors the old tailwind.config.js `safelist`). */
+@source inline("grid grid-cols-12 col-span-1 col-span-2 col-span-3 col-span-4 col-span-6 col-span-8 col-span-9 col-span-12 gap-4 gap-6");
+@source inline("bg-warning-500 bg-warning-600 bg-danger-500 bg-danger-600 bg-primary-500 bg-primary-600 bg-success-500 bg-success-600 bg-info-500 bg-info-600");
+@source inline("border-warning-700 border-warning-800 border-danger-700 border-danger-800 border-primary-700 border-primary-800 border-success-700 border-success-800 border-info-700 border-info-800");
+@source inline("rounded-xl rounded-2xl shadow-lg shadow-xl p-3 p-4 p-10 sticky top-4");
+
+@theme {
+ /* Fluent-tightened radius scale (Filament defaults: sm .25rem, md .375rem, lg .5rem, xl .75rem) */
+ --radius-sm: 0.125rem;
+ --radius-md: 0.1875rem;
+ --radius-lg: 0.25rem;
+ --radius-xl: 0.375rem;
+
+ /* Fluent "depth" shadow scale — tight two-layer ambient+direct shadows instead of
+ Tailwind's default wide, loose blur/spread (used on cards, dropdowns, modals). */
+ --shadow-2xs: 0 1px 2px #0000000f;
+ --shadow-xs: 0 1px 2px #00000014;
+ --shadow-sm: 0 1px 2px #00000024, 0 0 2px #0000001f;
+ --shadow-md: 0 2px 4px #00000024, 0 0 2px #0000001f;
+ --shadow-lg: 0 4px 8px #00000024, 0 0 2px #0000001f;
+ --shadow-xl: 0 8px 16px #00000024, 0 0 2px #0000001f;
+ --shadow-2xl: 0 16px 32px #00000024, 0 0 2px #0000001f;
+}
+
+/* Starter-kit-style shell: the sidebar blends into the page background (Filament
+ already does this via `lg:bg-transparent`) and is pinned to the full viewport
+ height (its own nav scrolls internally, via vendor `.fi-sidebar-nav`). The main
+ content region mirrors that: it's a fixed-height inset card that never grows
+ with its content — only the content inside it scrolls, the card itself doesn't. */
+.fi-main-ctn {
+ @apply my-2 me-2 flex flex-col overflow-y-auto rounded-xl bg-white ring-1 ring-gray-950/5 dark:bg-gray-900 dark:ring-white/10;
+
+ /* Fixed `height` (not `min-height`) so the card's own box stays pinned to the
+ viewport instead of growing with page content — that's what makes only the
+ content *inside* scroll. `100dvh - 1rem` accounts for the `my-2` margin (no
+ topbar to subtract now that `->topbar(false)` is set on every panel).
+ Vendor CSS also sets `min-height: 100dvh` once the topbar is disabled
+ (`.fi-body:not(.fi-body-has-topbar) .fi-main-ctn`), which — being a floor —
+ would otherwise win over our smaller `height` and stretch the card past the
+ viewport again, so it has to be overridden to the same value too. */
+ height: calc(100dvh - 1rem);
+ min-height: calc(100dvh - 1rem);
+}
+
+/* The sidebar footer (user menu / notifications) sits right after the scrollable nav
+ list with no visual break, so with a long nav it's unclear where items end and the
+ footer begins. Give it a top border spanning the full sidebar width (padding instead
+ of the vendor's inset margin) plus a faint bg tint so it reads as its own pinned
+ section, distinct from the scrolling nav above it. */
+.fi-sidebar-footer {
+ @apply mx-0 my-0 border-t border-gray-950/5 bg-gray-50/60 px-4 py-3 dark:border-white/10 dark:bg-white/2;
+}
+
+/* Sections and table containers are `bg-white shadow-sm ring-1 ring-gray-950/5` in
+ vendor CSS — that worked when they sat on the gray page background, but now that
+ `.fi-main-ctn` is itself a white card, they're white-on-white and needed a touch
+ more definition. Landed back on vendor's own default `shadow-sm` (our `shadow-xs`
+ was a bit too faint) — just with the ring deepened slightly. */
+.fi-section,
+.fi-ta-ctn {
+ @apply shadow-sm ring-gray-950/8 dark:ring-white/12;
+}
diff --git a/resources/lang/en/ip.php b/resources/lang/en/ip.php
index 64dfb4322..e02304de7 100644
--- a/resources/lang/en/ip.php
+++ b/resources/lang/en/ip.php
@@ -37,6 +37,7 @@
'primary_contact' => 'Primary Contact',
'recent_clients' => 'Recent Clients',
'registered_at' => 'Registration Date',
+ 'registration_tax_details' => 'Registration & Tax Details',
'relation_number' => 'Relation Number',
'relation_required' => 'The customer relation is required.',
'save_client_before_adding_contact' => 'Please save the client before adding a new contact.',
@@ -852,6 +853,7 @@
'quote_default_email_template' => 'Quote default email template',
'quote_default_pdf_template' => 'Quote default pdf template',
'quote_default_public_pdf_template' => 'Quote default public pdf template',
+ 'quote_details' => 'Quote Details',
'quote_footer' => 'Quote footer',
'quote_items' => 'Quote items',
'quote_notes' => 'Quote notes',
@@ -1275,6 +1277,8 @@
'show_vendor' => 'Show Vendor',
'show_amount' => 'Show Amount',
'expense_number' => 'Expense Number',
+ 'expense_type' => 'Expense Type',
+ 'expense_amount' => 'Expense Amount',
'expense_description' => 'Expense Description',
'sku' => 'SKU',
'unit_price' => 'Unit Price',
@@ -1337,6 +1341,7 @@
'archive' => 'Archive',
'client_id' => 'Client ID',
'companies' => 'Companies',
+ 'contact' => 'Contact',
'contact_details' => 'Contact Details',
'default_quote_notes' => 'Default Quote Notes',
'expense_status' => 'Expense Status',
diff --git a/vite.config.js b/vite.config.js
index eb095ee12..f66a8ccd2 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -8,13 +8,9 @@ export default defineConfig({
input: [
'resources/css/app.css',
'resources/css/guest.css',
+ 'resources/css/filament/theme.css',
'resources/js/app.js',
'resources/js/signature-pad.js',
- 'resources/css/filament/company/invoiceplane.css',
- 'resources/css/filament/company/invoiceplane-blue.css',
- 'resources/css/filament/company/nord.css',
- 'resources/css/filament/company/orange.css',
- 'resources/css/filament/company/reddit.css'
],
refresh: true,
}),