From f78f2db6aac1467dcaa8e810906b93c27da98a8e Mon Sep 17 00:00:00 2001 From: Kim Speer <93331309+Kim-the-Diamond@users.noreply.github.com> Date: Tue, 26 May 2026 16:58:01 +0200 Subject: [PATCH 1/8] feautre core Support MorphPivotRelation --- .../Items/Record/BaseRecordResource.php | 5 +- .../MorphPivotRelationManager.php | 557 ++++++++++++++++++ .../Services/MorphPivotRelationService.php | 251 ++++++++ .../MorphPivot/MorphPivotRelationRegistry.php | 53 ++ .../HasMorphPivotRelationService.php | 26 + .../MorphPivot/HasMorphPivotRelations.php | 111 ++++ .../HasResourceMorphPivotRelations.php | 81 +++ 7 files changed, 1083 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/Filament/RelationManagers/MorphPivotRelationManager.php create mode 100644 packages/core/src/Services/MorphPivotRelationService.php create mode 100644 packages/core/src/Support/MorphPivot/MorphPivotRelationRegistry.php create mode 100644 packages/core/src/Traits/MorphPivot/HasMorphPivotRelationService.php create mode 100644 packages/core/src/Traits/MorphPivot/HasMorphPivotRelations.php create mode 100644 packages/core/src/Traits/MorphPivot/HasResourceMorphPivotRelations.php diff --git a/packages/core/src/Entities/Items/Record/BaseRecordResource.php b/packages/core/src/Entities/Items/Record/BaseRecordResource.php index 2ac796b190..5b59e7fa17 100644 --- a/packages/core/src/Entities/Items/Record/BaseRecordResource.php +++ b/packages/core/src/Entities/Items/Record/BaseRecordResource.php @@ -7,11 +7,14 @@ use Illuminate\Contracts\Database\Eloquent\Builder; use Moox\Core\Entities\BaseResource; use Moox\Core\Traits\HasStatusColors; +use Moox\Core\Traits\MorphPivot\HasResourceMorphPivotRelations; use Moox\Core\Traits\Tabs\HasResourceTabs; class BaseRecordResource extends BaseResource { - use HasResourceTabs, HasStatusColors; + use HasResourceMorphPivotRelations; + use HasResourceTabs; + use HasStatusColors; protected static function getReadonlyConfig(): bool { diff --git a/packages/core/src/Filament/RelationManagers/MorphPivotRelationManager.php b/packages/core/src/Filament/RelationManagers/MorphPivotRelationManager.php new file mode 100644 index 0000000000..5cabc4215c --- /dev/null +++ b/packages/core/src/Filament/RelationManagers/MorphPivotRelationManager.php @@ -0,0 +1,557 @@ +relatedResourceClass) && class_exists($component->relatedResourceClass)) { + return $component->relatedResourceClass; + } + + if ( + $component instanceof self + && filled($component->morphRelationConfigKey) + && isset($component->ownerRecord) + ) { + $config = static::resolveConfig($component->getOwnerRecord(), $component->morphRelationConfigKey); + $resource = $config['related_resource'] ?? null; + + if (is_string($resource) && $resource !== '' && class_exists($resource)) { + return $resource; + } + } + + return null; + } + + #[Override] + public static function canViewForRecord(Model $ownerRecord, string $pageClass): bool + { + return true; + } + + #[Override] + public static function getRelationshipName(): string + { + if (isset(static::$relationship)) { + return static::$relationship; + } + + $component = Livewire::current(); + + if ($component instanceof self && filled($component->relationshipName)) { + return (string) $component->relationshipName; + } + + if ( + $component instanceof self + && filled($component->morphRelationConfigKey) + && isset($component->ownerRecord) + ) { + $service = app(MorphPivotRelationService::class); + $service->setCurrentResource($component->getOwnerRecord()::getResourceName()); + + return $service->getRelationshipMethodName((string) $component->morphRelationConfigKey); + } + + throw new RuntimeException( + 'MorphPivotRelationManager requires relationshipName or morphRelationConfigKey on the Livewire component.', + ); + } + + #[Override] + public static function getTitle(Model $ownerRecord, string $pageClass): string + { + $component = Livewire::current(); + + if ($component instanceof self && filled($component->morphRelationConfigKey)) { + $config = static::resolveConfig($ownerRecord, $component->morphRelationConfigKey); + $label = $config['label'] ?? null; + + if (is_string($label) && $label !== '') { + return static::translateMorphPivotLabel($label); + } + } + + return parent::getTitle($ownerRecord, $pageClass); + } + + public function getRelationship(): Relation|\Illuminate\Database\Eloquent\Builder + { + $key = (string) $this->morphRelationConfigKey; + $relationship = $this->morphPivotService()->getRelationshipMethodName($key); + + return $this->getOwnerRecord()->{$relationship}(); + } + + #[Override] + public function form(Schema $schema): Schema + { + parent::form($schema); + + $pivotFields = $this->pivotFormFields(); + + if ($pivotFields === []) { + return $schema; + } + + return $schema->components([ + ...$schema->getComponents(withHidden: true), + Section::make($this->getPivotSectionLabel()) + ->schema($pivotFields) + ->columns(2), + ]); + } + + #[Override] + public function getDefaultActionUrl(Action $action): ?string + { + if ($action instanceof CreateAction) { + return null; + } + + $record = $action->getRecord(); + + if ($record instanceof Model && ($resource = $this->getResolvedRelatedResource())) { + if ($action instanceof EditAction && $resource::hasPage('edit')) { + return $this->getRelatedRecordResourceUrl($record, 'edit'); + } + + if ($action instanceof ViewAction && $resource::hasPage('view')) { + return $this->getRelatedRecordResourceUrl($record, 'view'); + } + } + + return parent::getDefaultActionUrl($action); + } + + #[Override] + protected function makeTable(): Table + { + $table = $this->makeBaseTable() + ->relationship(fn (): Relation | Builder => $this->getRelationship()) + ->modifyQueryUsing($this->modifyQueryWithActiveTab(...)) + ->queryStringIdentifier(Str::lcfirst(class_basename(static::class))); + + $table->authorizeReorder(fn (): bool => $this->canReorder()); + + if ($relatedResource = static::getRelatedResource()) { + $table + ->modelLabel($relatedResource::getModelLabel()) + ->pluralModelLabel($relatedResource::getPluralModelLabel()); + } + + return $table->heading($this->getTableHeading() ?? static::getTitle($this->getOwnerRecord(), $this->getPageClass())); + } + + public function table(Table $table): Table + { + $config = static::resolveConfig($this->getOwnerRecord(), $this->morphRelationConfigKey); + $prefix = (string) ($config['translation_prefix'] ?? ''); + + $columns = []; + + foreach ((array) ($config['display_columns'] ?? ['name']) as $column) { + if (! is_string($column) || $column === '') { + continue; + } + + $field = TextColumn::make($column) + ->label($this->fieldLabel($prefix, $column)) + ->searchable(); + + if ($column === 'is_primary') { + $columns[] = IconColumn::make($column) + ->label($this->fieldLabel($prefix, $column)) + ->boolean(); + + continue; + } + + $columns[] = $field; + } + + foreach ($this->morphPivotService()->getMorphPivotRelationPivotColumns( + (string) $this->morphRelationConfigKey, + ) as $column) { + $columns[] = IconColumn::make($column) + ->label($this->fieldLabel($prefix, $column)) + ->boolean(); + } + + $relatedResource = $this->getResolvedRelatedResource(); + $attachAction = $this->configureAttachAction( + AttachAction::make() + ->preloadRecordSelect() + ->schema(fn (AttachAction $action): array => [ + $action->getRecordSelect(), + ...$this->pivotFormFields(), + ]), + $config, + ); + + $searchColumns = $config['record_select_search_columns'] ?? null; + + if (is_array($searchColumns) && $searchColumns !== []) { + $attachAction->recordSelectSearchColumns(array_values(array_map(strval(...), $searchColumns))); + } + + $headerActions = [$attachAction]; + + if ($relatedResource !== null) { + $headerActions[] = CreateAction::make() + ->label($this->getCreateActionLabel($relatedResource)); + } + + $table = $table + ->columns($columns) + ->headerActions($headerActions); + + $inverseRelationship = $config['inverse_relationship'] ?? null; + + if (is_string($inverseRelationship) && $inverseRelationship !== '') { + $table->inverseRelationship($inverseRelationship); + } else { + // Morph pivots: Filament would guess e.g. Address::companies() — use pivot keys instead. + $table->allowDuplicates(); + } + + $recordActions = []; + + if ($relatedResource !== null && $relatedResource::hasPage('edit')) { + $recordActions[] = EditAction::make('editRelated') + ->label(__('filament-actions::edit.single.label', [ + 'label' => $relatedResource::getModelLabel(), + ])) + ->url(fn (Model $record): string => $this->getRelatedRecordResourceUrl($record, 'edit')); + } + + $recordActions[] = EditAction::make('editPivot') + ->label($this->getPivotSectionLabel()) + ->schema(fn (Schema $schema): Schema => $schema->components($this->pivotFormFields())); + $recordActions[] = DetachAction::make(); + + return $table + ->recordActions($recordActions) + ->toolbarActions([ + DetachBulkAction::make(), + ]); + } + + /** + * @return array + */ + protected static function resolveConfig(Model $ownerRecord, ?string $key): array + { + if ($key === null || $key === '' || ! method_exists($ownerRecord, 'getResourceName')) { + return []; + } + + $config = config($ownerRecord::getResourceName().".morph_relations.{$key}", []); + + return is_array($config) ? MorphPivotRelationRegistry::mergeConfig($config) : []; + } + + /** + * @param array $config + */ + protected function configureAttachAction(AttachAction $attachAction, array $config): AttachAction + { + $attachAction = $attachAction->recordTitle( + fn (?Model $record): ?string => $record ? $this->formatRecordSelectLabel($record, $config) : null, + ); + + if (isset($config['inverse_relationship']) && is_string($config['inverse_relationship']) && $config['inverse_relationship'] !== '') { + return $attachAction; + } + + return $attachAction->recordSelectOptionsQuery(function (Builder $query): Builder { + $relationship = $this->getRelationship(); + + if (! $relationship instanceof BelongsToMany) { + return $query; + } + + $attachedIds = $relationship->allRelatedIds(); + + if ($attachedIds->isEmpty()) { + return $query; + } + + return $query->whereNotIn( + $relationship->getRelated()->getQualifiedKeyName(), + $attachedIds, + ); + }); + } + + /** + * @param array $config + */ + protected function formatRecordSelectLabel(Model $record, array $config): string + { + $method = $config['record_select_label'] ?? null; + + if (is_string($method) && $method !== '' && method_exists($record, $method)) { + $label = $record->{$method}(); + + if (is_string($label) && $label !== '') { + return $label; + } + } + + $columns = $config['record_select_label_columns'] ?? $config['display_columns'] ?? ['name']; + $parts = []; + + foreach ((array) $columns as $column) { + if (! is_string($column) || $column === '' || in_array($column, ['is_primary'], true)) { + continue; + } + + $value = $record->getAttribute($column); + + if ($value !== null && $value !== '') { + $parts[] = (string) $value; + } + } + + if ($parts !== []) { + return implode(', ', $parts); + } + + return (string) $record->getKey(); + } + + /** + * @return class-string|null + */ + protected function getResolvedRelatedResource(): ?string + { + if (filled($this->relatedResourceClass) && class_exists($this->relatedResourceClass)) { + return $this->relatedResourceClass; + } + + $config = static::resolveConfig($this->getOwnerRecord(), $this->morphRelationConfigKey); + $resource = $config['related_resource'] ?? null; + + if (is_string($resource) && $resource !== '' && class_exists($resource)) { + return $resource; + } + + return null; + } + + protected function getPivotSectionLabel(): string + { + $config = static::resolveConfig($this->getOwnerRecord(), $this->morphRelationConfigKey); + $prefix = (string) ($config['translation_prefix'] ?? ''); + + if ($prefix !== '' && Lang::has($prefix.'.assignments')) { + return __($prefix.'.assignments'); + } + + return __('filament-actions::attach.single.modal.heading', [ + 'label' => static::getRelationshipTitle(), + ]); + } + + /** + * @param class-string $relatedResource + */ + protected function getCreateActionLabel(string $relatedResource): string + { + $config = static::resolveConfig($this->getOwnerRecord(), $this->morphRelationConfigKey); + $prefix = (string) ($config['translation_prefix'] ?? ''); + $model = $config['model'] ?? null; + + if (is_string($prefix) && $prefix !== '' && is_string($model) && $model !== '') { + $key = $prefix.'.create_'.Str::snake(class_basename($model)); + + if (Lang::has($key)) { + return __($key); + } + } + + return __('filament-actions::create.single.label', [ + 'label' => $relatedResource::getModelLabel(), + ]); + } + + protected function morphPivotService(): MorphPivotRelationService + { + $service = app(MorphPivotRelationService::class); + $service->setCurrentResource($this->getOwnerRecord()::getResourceName()); + + return $service; + } + + /** + * @return list + */ + protected function pivotFormFields(): array + { + $config = static::resolveConfig($this->getOwnerRecord(), $this->morphRelationConfigKey); + $prefix = (string) ($config['translation_prefix'] ?? ''); + + $fields = []; + + foreach ($this->morphPivotService()->getMorphPivotRelationPivotColumns( + (string) $this->morphRelationConfigKey, + ) as $column) { + $fields[] = Checkbox::make($column) + ->label($this->fieldLabel($prefix, $column)); + } + + return $fields; + } + + protected function fieldLabel(string $translationPrefix, string $column): string + { + if ($translationPrefix !== '') { + return __($translationPrefix.'.'.$column); + } + + return $column; + } + + protected static function translateMorphPivotLabel(string $label): string + { + if (str_starts_with($label, 'trans//')) { + $key = substr($label, 7); + $translated = trans($key); + + return $translated !== $key ? $translated : $key; + } + + return $label; + } + + protected function getRelatedRecordResourceUrl(Model $record, string $page): string + { + $resource = $this->getResolvedRelatedResource(); + + if ($resource === null || ! $resource::hasPage($page)) { + return ''; + } + + return $resource::getUrl($page, [ + 'record' => $this->resolveRelatedRecordForUrl($record), + ], shouldGuessMissingParameters: false); + } + + /** + * Related model for resource URLs — not the pivot row when {@see allowDuplicates()} is on. + */ + protected function resolveRelatedRecordForUrl(Model $record): Model + { + $resource = $this->getResolvedRelatedResource(); + + if ($resource === null) { + return $record; + } + + /** @var class-string $relatedClass */ + $relatedClass = $resource::getModel(); + $relationship = $this->getRelationship(); + + if (! $relationship instanceof BelongsToMany) { + return $record; + } + + $relatedId = $this->resolveRelatedRecordPrimaryKey($record, $relationship); + + return $relatedClass::query()->findOrFail($relatedId); + } + + protected function resolveRelatedRecordPrimaryKey(Model $record, BelongsToMany $relationship): string|int + { + $pivotRelatedKey = $relationship->getRelatedPivotKeyName(); + + if ($record->relationLoaded('pivot') && filled($record->pivot?->getAttribute($pivotRelatedKey))) { + return $record->pivot->getAttribute($pivotRelatedKey); + } + + if (filled($record->getAttribute($pivotRelatedKey))) { + return $record->getAttribute($pivotRelatedKey); + } + + if ($this->usesDuplicatePivotRowKeys()) { + $pivotClass = $relationship->getPivotClass(); + $pivotKeyName = app($pivotClass)->getKeyName(); + $pivotRowKey = $record->getAttribute($pivotKeyName) ?? $record->getKey(); + + $relatedId = $pivotClass::query() + ->whereKey($pivotRowKey) + ->value($pivotRelatedKey); + + if (filled($relatedId)) { + return $relatedId; + } + } + + $relatedKeyName = $relationship->getRelated()->getKeyName(); + + return $record->getAttribute($relatedKeyName) ?? $record->getKey(); + } + + protected function usesDuplicatePivotRowKeys(): bool + { + $config = static::resolveConfig($this->getOwnerRecord(), $this->morphRelationConfigKey); + $inverse = $config['inverse_relationship'] ?? null; + + return ! (is_string($inverse) && $inverse !== ''); + } +} diff --git a/packages/core/src/Services/MorphPivotRelationService.php b/packages/core/src/Services/MorphPivotRelationService.php new file mode 100644 index 0000000000..322c81d7d4 --- /dev/null +++ b/packages/core/src/Services/MorphPivotRelationService.php @@ -0,0 +1,251 @@ +>> */ + private array $cached = []; + + public function setCurrentResource(string $resource): void + { + $this->currentResource = $resource; + } + + public function getCurrentResource(): ?string + { + return $this->currentResource; + } + + private function ensureResourceIsSet(): void + { + if ($this->currentResource === null) { + throw new RuntimeException('Current resource is not set. Call setCurrentResource() first.'); + } + } + + /** + * @return array> + */ + public function getMorphPivotRelations(): array + { + $this->ensureResourceIsSet(); + + $resourceName = $this->currentResource; + + if (isset($this->cached[$resourceName])) { + return $this->cached[$resourceName]; + } + + /** @var array> $relations */ + $relations = config("{$resourceName}.morph_relations", []); + + $this->cached[$resourceName] = $relations; + + return $relations; + } + + /** + * @return array + */ + public function getMorphPivotRelationConfig(string $relation): array + { + $config = $this->getMorphPivotRelations()[$relation] ?? []; + + return is_array($config) ? MorphPivotRelationRegistry::mergeConfig($config) : []; + } + + /** + * @return class-string|null + */ + public function getMorphPivotRelationModel(string $relation): ?string + { + $model = $this->getMorphPivotRelationConfig($relation)['model'] ?? null; + + if (! is_string($model) || $model === '' || ! class_exists($model)) { + return null; + } + + return $model; + } + + public function validateMorphPivotRelation(string $relation): void + { + if ($this->getMorphPivotRelationModel($relation) === null) { + throw new InvalidArgumentException(sprintf( + 'Invalid or missing model for morph pivot relation [%s] on resource [%s].', + $relation, + $this->currentResource, + )); + } + } + + public function getMorphName(string $relation): string + { + $config = $this->getMorphPivotRelationConfig($relation); + + if (isset($config['morph_name']) && is_string($config['morph_name']) && $config['morph_name'] !== '') { + return $config['morph_name']; + } + + return (string) ($config['relationship'] ?? 'addressable'); + } + + public function getPivotTable(string $relation): string + { + $config = $this->getMorphPivotRelationConfig($relation); + + if (isset($config['pivot_table']) && is_string($config['pivot_table']) && $config['pivot_table'] !== '') { + return $config['pivot_table']; + } + + return (string) ($config['table'] ?? 'addressables'); + } + + public function getForeignKey(string $relation): string + { + $config = $this->getMorphPivotRelationConfig($relation); + + if (isset($config['foreignKey']) && is_string($config['foreignKey']) && $config['foreignKey'] !== '') { + return $config['foreignKey']; + } + + return $this->getMorphName($relation).'_id'; + } + + public function getRelatedKey(string $relation): string + { + $config = $this->getMorphPivotRelationConfig($relation); + + if (isset($config['related_key']) && is_string($config['related_key']) && $config['related_key'] !== '') { + return $config['related_key']; + } + + if (isset($config['relatedKey']) && is_string($config['relatedKey']) && $config['relatedKey'] !== '') { + return $config['relatedKey']; + } + + $model = $this->getMorphPivotRelationModel($relation); + + if ($model !== null) { + return Str::snake(class_basename($model)).'_id'; + } + + return 'related_id'; + } + + public function getRelationshipMethodName(string $relation): string + { + $config = $this->getMorphPivotRelationConfig($relation); + $name = $config['relationship'] ?? null; + + if (is_string($name) && $name !== '') { + return $name; + } + + return $relation; + } + + /** + * @return list + */ + public function getMorphPivotRelationPivotColumns(string $relation): array + { + $columns = $this->getMorphPivotRelationConfig($relation)['pivot_columns'] ?? []; + + if (! is_array($columns)) { + return []; + } + + if (array_is_list($columns)) { + return array_values(array_map(strval(...), $columns)); + } + + return array_keys($columns); + } + + /** + * @return class-string|null + */ + public function getMorphPivotRelationPivotModel(string $relation): ?string + { + $model = $this->getMorphPivotRelationConfig($relation)['pivot_model'] ?? null; + + if (! is_string($model) || $model === '' || ! class_exists($model)) { + return null; + } + + return $model; + } + + /** + * @return array + */ + public function getPrimaryConfig(string $relation): array + { + $primary = $this->getMorphPivotRelationConfig($relation)['primary'] ?? []; + + return is_array($primary) ? $primary : []; + } + + public function getPrimaryOn(string $relation): string + { + $primary = $this->getPrimaryConfig($relation); + + if (isset($primary['on']) && is_string($primary['on']) && $primary['on'] !== '') { + return $primary['on']; + } + + $column = (string) ($primary['column'] ?? 'id'); + + return in_array($column, ['id', 'is_primary'], true) ? 'related' : 'pivot'; + } + + public function getPrimaryColumn(string $relation): string + { + return (string) ($this->getPrimaryConfig($relation)['column'] ?? 'id'); + } + + /** + * @return mixed + */ + public function getPrimaryValue(string $relation, mixed $default = true): mixed + { + return $this->getPrimaryConfig($relation)['value'] ?? $default; + } + + /** + * Resolved column on the related model (e.g. id → is_primary for Address). + */ + public function getPrimaryRelatedColumn(string $relation): string + { + $column = $this->getPrimaryColumn($relation); + + if ($column === 'id') { + return 'is_primary'; + } + + return $column; + } + + public function hasMorphPivotRelations(): bool + { + $this->ensureResourceIsSet(); + + return $this->getMorphPivotRelations() !== []; + } +} diff --git a/packages/core/src/Support/MorphPivot/MorphPivotRelationRegistry.php b/packages/core/src/Support/MorphPivot/MorphPivotRelationRegistry.php new file mode 100644 index 0000000000..429de8c91e --- /dev/null +++ b/packages/core/src/Support/MorphPivot/MorphPivotRelationRegistry.php @@ -0,0 +1,53 @@ +> */ + protected static array $relatedModels = []; + + /** + * @param class-string $relatedModel + * @param array $defaults + */ + public static function registerRelatedModel(string $relatedModel, array $defaults): void + { + static::$relatedModels[$relatedModel] = array_replace( + static::$relatedModels[$relatedModel] ?? [], + $defaults, + ); + } + + /** + * @return array + */ + public static function defaultsFor(?string $relatedModel): array + { + if (! is_string($relatedModel) || $relatedModel === '') { + return []; + } + + return static::$relatedModels[$relatedModel] ?? []; + } + + /** + * @param array $config + * @return array + */ + public static function mergeConfig(array $config): array + { + $model = $config['model'] ?? null; + + if (! is_string($model) || $model === '') { + return $config; + } + + return array_replace_recursive(static::defaultsFor($model), $config); + } +} diff --git a/packages/core/src/Traits/MorphPivot/HasMorphPivotRelationService.php b/packages/core/src/Traits/MorphPivot/HasMorphPivotRelationService.php new file mode 100644 index 0000000000..2e1395c2da --- /dev/null +++ b/packages/core/src/Traits/MorphPivot/HasMorphPivotRelationService.php @@ -0,0 +1,26 @@ + */ + protected static array $morphPivotRelationServiceCache = []; + + protected function getMorphPivotRelationService(): MorphPivotRelationService + { + $className = static::class; + + if (! isset(static::$morphPivotRelationServiceCache[$className])) { + $service = app(MorphPivotRelationService::class); + $service->setCurrentResource(static::getResourceName()); + static::$morphPivotRelationServiceCache[$className] = $service; + } + + return static::$morphPivotRelationServiceCache[$className]; + } +} diff --git a/packages/core/src/Traits/MorphPivot/HasMorphPivotRelations.php b/packages/core/src/Traits/MorphPivot/HasMorphPivotRelations.php new file mode 100644 index 0000000000..71edc19c38 --- /dev/null +++ b/packages/core/src/Traits/MorphPivot/HasMorphPivotRelations.php @@ -0,0 +1,111 @@ +getMorphPivotRelationService()->getMorphPivotRelations(); + + if (! isset($relations[$relation])) { + Log::error('Morph pivot relation not found: '.$relation); + + return $this->emptyMorphPivotRelation(); + } + + $model = $this->getMorphPivotRelationService()->getMorphPivotRelationModel($relation); + + if ($model === null) { + return $this->emptyMorphPivotRelation(); + } + + $service = $this->getMorphPivotRelationService(); + + $builder = $this->morphToMany( + $model, + $service->getMorphName($relation), + $service->getPivotTable($relation), + $service->getForeignKey($relation), + $service->getRelatedKey($relation), + ); + + $pivotColumns = $service->getMorphPivotRelationPivotColumns($relation); + + if ($pivotColumns !== []) { + $builder->withPivot($pivotColumns); + } + + $pivotModel = $service->getMorphPivotRelationPivotModel($relation); + + if ($pivotModel !== null) { + $builder->using($pivotModel); + } + + return $builder->withTimestamps(); + } + + public function primaryMorphPivotRelation(string $relation): MorphToMany + { + $service = $this->getMorphPivotRelationService(); + $query = $this->morphPivotRelation($relation); + + if ($service->getPrimaryOn($relation) === 'related') { + $model = $service->getMorphPivotRelationModel($relation); + + if ($model === null) { + return $query; + } + + $table = (new $model)->getTable(); + + return $query->where( + "{$table}.{$service->getPrimaryRelatedColumn($relation)}", + $service->getPrimaryValue($relation), + ); + } + + return $query->wherePivot( + $service->getPrimaryColumn($relation), + $service->getPrimaryValue($relation), + ); + } + + protected function emptyMorphPivotRelation(): MorphToMany + { + return $this->morphToMany(Model::class, 'addressable', 'addressables')->whereRaw('1 = 0'); + } + + /** + * Resolve {@see morphPivotRelation()} by config key or {@see MorphPivotRelationService::getRelationshipMethodName()}. + * + * @param array $parameters + */ + public function morphPivotCall(string $method, array $parameters): mixed + { + $service = $this->getMorphPivotRelationService(); + + foreach ($service->getMorphPivotRelations() as $configKey => $config) { + if (! is_array($config)) { + continue; + } + + $config = MorphPivotRelationRegistry::mergeConfig($config); + + if ($configKey === $method || ($config['relationship'] ?? $configKey) === $method) { + return $this->morphPivotRelation((string) $configKey); + } + } + + return parent::__call($method, $parameters); + } +} diff --git a/packages/core/src/Traits/MorphPivot/HasResourceMorphPivotRelations.php b/packages/core/src/Traits/MorphPivot/HasResourceMorphPivotRelations.php new file mode 100644 index 0000000000..25e4401dc1 --- /dev/null +++ b/packages/core/src/Traits/MorphPivot/HasResourceMorphPivotRelations.php @@ -0,0 +1,81 @@ + + */ + protected static function getDeclaredRelations(): array + { + return []; + } + + /** + * @return array + */ + public static function getRelations(): array + { + return array_merge( + static::getDeclaredRelations(), + static::buildMorphPivotRelationManagers(), + ); + } + + /** + * @return array + */ + protected static function buildMorphPivotRelationManagers(): array + { + if (! method_exists(static::getModel(), 'getResourceName')) { + return []; + } + + $resourceName = static::getModel()::getResourceName(); + $relations = config("{$resourceName}.morph_relations", []); + + $managers = []; + + foreach ($relations as $configKey => $config) { + if (! is_array($config)) { + continue; + } + + $config = MorphPivotRelationRegistry::mergeConfig($config); + $model = $config['model'] ?? null; + + if (! is_string($model) || $model === '' || ! class_exists($model)) { + continue; + } + + $label = $config['label'] ?? $configKey; + $relationshipName = $config['relationship'] ?? $configKey; + $relatedResource = $config['related_resource'] ?? null; + + $managers[] = RelationGroup::make( + is_string($label) ? $label : (string) $configKey, + [ + MorphPivotRelationManager::make([ + 'morphRelationConfigKey' => (string) $configKey, + 'relationshipName' => is_string($relationshipName) ? $relationshipName : (string) $configKey, + 'relatedResourceClass' => is_string($relatedResource) && $relatedResource !== '' + ? $relatedResource + : null, + ]), + ], + ); + } + + return $managers; + } +} From d7f07ae0b2a0cb1cc0326e71db3004a423a77cb3 Mon Sep 17 00:00:00 2001 From: Kim Speer <93331309+Kim-the-Diamond@users.noreply.github.com> Date: Tue, 26 May 2026 16:58:52 +0200 Subject: [PATCH 2/8] init company package --- packages/company/.gitignore | 46 +++ packages/company/README.md | 31 ++ packages/company/composer.json | 67 ++++ packages/company/config/company.php | 176 +++++++++ .../database/Factories/CompanyFactory.php | 92 +++++ .../create_companies_table.php.stub | 61 ++++ .../company/resources/lang/de/company.php | 6 + packages/company/resources/lang/de/fields.php | 41 +++ .../company/resources/lang/en/company.php | 6 + packages/company/resources/lang/en/fields.php | 42 +++ .../company/src/CompanyServiceProvider.php | 75 ++++ .../company/src/Frontend/CompanyFrontend.php | 20 ++ packages/company/src/Models/Company.php | 173 +++++++++ .../company/src/Plugins/CompanyPlugin.php | 38 ++ .../Resources/Company/Pages/CreateCompany.php | 26 ++ .../Resources/Company/Pages/EditCompany.php | 13 + .../Resources/Company/Pages/ListCompanies.php | 22 ++ .../Resources/Company/Pages/ViewCompany.php | 13 + .../ChildrenRelationManager.php | 55 +++ .../company/src/Resources/CompanyResource.php | 339 ++++++++++++++++++ packages/company/src/Support/CompanyRules.php | 53 +++ packages/company/tests/ArchTest.php | 17 + .../tests/Feature/FilamentCompanyTest.php | 79 ++++ packages/company/tests/FeatureTestCase.php | 23 ++ packages/company/tests/Pest.php | 11 + packages/company/tests/TestCase.php | 220 ++++++++++++ .../company/tests/Unit/CompanyModelTest.php | 48 +++ packages/company/tests/bootstrap.php | 18 + 28 files changed, 1811 insertions(+) create mode 100644 packages/company/.gitignore create mode 100644 packages/company/README.md create mode 100644 packages/company/composer.json create mode 100644 packages/company/config/company.php create mode 100644 packages/company/database/Factories/CompanyFactory.php create mode 100644 packages/company/database/migrations/create_companies_table.php.stub create mode 100644 packages/company/resources/lang/de/company.php create mode 100644 packages/company/resources/lang/de/fields.php create mode 100644 packages/company/resources/lang/en/company.php create mode 100644 packages/company/resources/lang/en/fields.php create mode 100644 packages/company/src/CompanyServiceProvider.php create mode 100644 packages/company/src/Frontend/CompanyFrontend.php create mode 100644 packages/company/src/Models/Company.php create mode 100644 packages/company/src/Plugins/CompanyPlugin.php create mode 100644 packages/company/src/Resources/Company/Pages/CreateCompany.php create mode 100644 packages/company/src/Resources/Company/Pages/EditCompany.php create mode 100644 packages/company/src/Resources/Company/Pages/ListCompanies.php create mode 100644 packages/company/src/Resources/Company/Pages/ViewCompany.php create mode 100644 packages/company/src/Resources/Company/RelationManagers/ChildrenRelationManager.php create mode 100644 packages/company/src/Resources/CompanyResource.php create mode 100644 packages/company/src/Support/CompanyRules.php create mode 100644 packages/company/tests/ArchTest.php create mode 100644 packages/company/tests/Feature/FilamentCompanyTest.php create mode 100644 packages/company/tests/FeatureTestCase.php create mode 100644 packages/company/tests/Pest.php create mode 100644 packages/company/tests/TestCase.php create mode 100644 packages/company/tests/Unit/CompanyModelTest.php create mode 100644 packages/company/tests/bootstrap.php diff --git a/packages/company/.gitignore b/packages/company/.gitignore new file mode 100644 index 0000000000..087c64bb03 --- /dev/null +++ b/packages/company/.gitignore @@ -0,0 +1,46 @@ +# Environment +.env +.env.backup + +# Composer +/vendor +composer.lock +auth.json + +# NPM / Node +/node_modules +npm-debug.log +package-lock.json + +# Laravel +/public/hot +/public/storage +/storage/*.key + +# PHPUnit +.phpunit.result.cache + +# PHPStan +/build +phpstan.neon + +# Testbench +testbench.yaml +/workbench/* + +# PHP CS Fixer +.php-cs-fixer.cache + +# Homestead +Homestead.json +Homestead.yaml + +# IDEs +/.idea +/.vscode + +# MacOS +.DS_Store + +# Windows +Thumbs.db diff --git a/packages/company/README.md b/packages/company/README.md new file mode 100644 index 0000000000..febcd196d9 --- /dev/null +++ b/packages/company/README.md @@ -0,0 +1,31 @@ +![Moox Company](https://github.com/mooxphp/moox/raw/main/art/banner/record.jpg) + +# Moox Company + +ERP-style company entity (customers, suppliers, subsidiaries). No payment fields, no `employee_id`, no default-address foreign keys — addresses and commercial terms use pivots (`addressables`, `employee_assignments`, `commercial_term_assignments`). + +## Features + +- UUID primary key, soft deletes, JSON `data` +- Parent / subsidiary hierarchy (`parent_id`) +- Config-driven statuses, company types, Filament tabs, scopes +- `address` / `addresses` relations via config + `moox/address` pivot config +- Filament resource, factory, Pest tests + +## Installation + +```bash +composer require moox/company +php artisan moox:install +``` + +## Factory + +```php +Company::factory()->customer()->create(); +Company::factory()->withParent($parent)->create(); +``` + +## Configuration + +Publish and edit `config/company.php` for statuses, company types, navigation group, tabs, and relation labels. diff --git a/packages/company/composer.json b/packages/company/composer.json new file mode 100644 index 0000000000..c81fec8cea --- /dev/null +++ b/packages/company/composer.json @@ -0,0 +1,67 @@ +{ + "name": "moox/company", + "description": "Company is a Moox Entity for ERP-style company records (customers, suppliers, subsidiaries) without payment or default-address FKs.", + "keywords": [ + "Moox", + "Laravel", + "Filament", + "Moox package", + "Laravel package", + "ERP", + "Company" + ], + "homepage": "https://moox.org/docs/company", + "license": "MIT", + "authors": [ + { + "name": "Moox Developer", + "email": "dev@moox.org", + "role": "Developer" + } + ], + "require": { + "moox/core": "dev-main", + "moox/data": "dev-main" + }, + "autoload": { + "psr-4": { + "Moox\\Company\\": "src", + "Moox\\Company\\Database\\Factories\\": "database/factories" + } + }, + "autoload-dev": { + "psr-4": { + "Moox\\Company\\Tests\\": "tests/" + } + }, + "extra": { + "laravel": { + "providers": [ + "Moox\\Company\\CompanyServiceProvider" + ] + }, + "moox": { + "stability": "stable" + } + }, + "minimum-stability": "stable", + "prefer-stable": true, + "require-dev": { + "moox/devtools": "dev-main", + "pestphp/pest": "^4.7", + "pestphp/pest-plugin-livewire": "^4.0" + }, + "scripts": { + "test": [ + "@php ../../vendor/bin/pest --configuration=phpunit.xml tests/Unit tests/Feature" + ], + "test:arch": [ + "@php ../../vendor/bin/pest --configuration=phpunit.xml tests/ArchTest.php" + ] + }, + "config": { + "allow-plugins": { + "pestphp/pest-plugin": true + } + } +} \ No newline at end of file diff --git a/packages/company/config/company.php b/packages/company/config/company.php new file mode 100644 index 0000000000..0a761f6e10 --- /dev/null +++ b/packages/company/config/company.php @@ -0,0 +1,176 @@ + false, + + 'statuses' => [ + 'draft', + 'active', + 'inactive', + 'approved', + 'archived', + ], + + 'company_types' => [ + 'customer', + 'supplier', + 'partner', + 'prospect', + 'internal', + ], + + 'default_currency_code' => 'EUR', + + 'resources' => [ + 'company' => [ + + 'single' => 'trans//company::company.company', + 'plural' => 'trans//company::company.companies', + + 'tabs' => [ + 'all' => [ + 'label' => 'trans//core::core.all', + 'icon' => 'gmdi-filter-list', + 'query' => [ + [ + 'field' => 'deleted_at', + 'operator' => '=', + 'value' => null, + ], + ], + ], + 'active' => [ + 'label' => 'trans//company::fields.active', + 'icon' => 'gmdi-check-circle-o', + 'query' => [ + [ + 'field' => 'is_active', + 'operator' => '=', + 'value' => true, + ], + [ + 'field' => 'deleted_at', + 'operator' => '=', + 'value' => null, + ], + ], + ], + 'deleted' => [ + 'label' => 'trans//core::core.deleted', + 'icon' => 'gmdi-delete', + 'query' => [ + [ + 'field' => 'deleted_at', + 'operator' => '!=', + 'value' => null, + ], + ], + ], + ], + + 'scopes' => [ + 'allowed' => [ + 'news' => [ + 'resource' => NewsResource::class, + ], + 'media' => [ + 'resource' => MediaResource::class, + ], + 'tag' => [ + 'resource' => TagResource::class, + ], + 'category' => [ + 'resource' => CategoryResource::class, + ], + 'user' => [ + 'resource' => UserResource::class, + ], + 'user-device' => [ + 'resource' => UserDeviceResource::class, + ], + ], + 'registry' => [ + 'sources' => [ + 'company' => Company::class, + ], + ], + ], + ], + ], + + 'relations' => [ + 'parent' => [ + 'label' => 'trans//company::fields.parent', + 'relationship' => 'parent', + 'model' => Company::class, + ], + 'children' => [ + 'label' => 'trans//company::fields.children', + 'relationship' => 'children', + 'model' => Company::class, + ], + ], + + /* + | Morph pivots (moox/core HasMorphPivotRelations + MorphPivotRelationManager). + | Same keys as address.relations.addressables / draft taxonomies. + | App sets model, pivot_model, related_resource. Further packages register + | display_columns via MorphPivotRelationRegistry::registerRelatedModel(). + */ + 'morph_relations' => [ + 'addressables' => [ + 'label' => 'trans//company::fields.addresses', + 'relationship' => 'addresses', + 'model' => null, + 'pivot_model' => null, + 'pivot_table' => 'addressables', + 'morph_name' => 'addressable', + 'pivot_columns' => [ + 'billing_address', + 'postal_address', + 'delivery_address', + ], + 'related_key' => 'address_id', + 'primary' => [ + 'on' => 'related', + 'column' => 'id', + 'value' => true, + ], + ], + ], + + 'taxonomies' => [ + ], + + 'user_models' => [ + App\Models\User::class => [ + 'title_attribute' => 'name', + 'label' => 'App User', + ], + User::class => [ + 'title_attribute' => 'name', + 'label' => 'Moox User', + ], + ], + + 'navigation_group' => 'Portal', +]; diff --git a/packages/company/database/Factories/CompanyFactory.php b/packages/company/database/Factories/CompanyFactory.php new file mode 100644 index 0000000000..be28727e98 --- /dev/null +++ b/packages/company/database/Factories/CompanyFactory.php @@ -0,0 +1,92 @@ + + */ +class CompanyFactory extends Factory +{ + protected $model = Company::class; + + /** + * @return array + */ + public function definition(): array + { + $name = fake()->company(); + + return [ + 'status' => fake()->randomElement(config('company.statuses', ['draft', 'active'])), + 'name' => $name, + 'display_name' => $name, + 'legal_name' => fake()->optional(0.6)->company().' '.fake()->randomElement(['GmbH', 'AG', 'KG', 'OHG', 'Ltd.']), + 'note' => fake()->optional(0.2)->sentence(), + 'search_terms' => null, + 'parent_id' => null, + 'external_reference' => fake()->optional(0.3)->bothify('EXT-####'), + 'phone' => fake()->optional(0.7)->phoneNumber(), + 'fax' => fake()->optional(0.1)->phoneNumber(), + 'url' => fake()->optional(0.5)->url(), + 'email' => fake()->optional(0.8)->companyEmail(), + 'tax_number' => fake()->optional(0.4)->numerify('########'), + 'vat_number' => fake()->optional(0.5)->bothify('DE#########'), + 'has_no_vat_number' => false, + 'partner_type' => null, + 'partner_id' => null, + 'company_type' => fake()->randomElement(config('company.company_types', ['customer'])), + 'default_currency_code' => config('company.default_currency_code', 'EUR'), + 'is_fully_owned_subsidiary' => false, + 'no_marketing_action' => fake()->boolean(10), + 'no_marketing_action_reason' => null, + 'language_id' => null, + 'localization_id' => null, + 'sort' => fake()->optional(0.3)->numberBetween(1, 999), + 'is_active' => true, + 'approved_at' => null, + 'data' => null, + ]; + } + + public function customer(): static + { + return $this->state(fn (): array => [ + 'company_type' => 'customer', + ]); + } + + public function supplier(): static + { + return $this->state(fn (): array => [ + 'company_type' => 'supplier', + ]); + } + + public function draft(): static + { + return $this->state(fn (): array => [ + 'status' => 'draft', + ]); + } + + public function inactive(): static + { + return $this->state(fn (): array => [ + 'is_active' => false, + 'status' => 'inactive', + ]); + } + + public function withParent(Company $parent): static + { + return $this->state(fn (): array => [ + 'parent_id' => $parent->getKey(), + 'is_fully_owned_subsidiary' => fake()->boolean(70), + ]); + } +} diff --git a/packages/company/database/migrations/create_companies_table.php.stub b/packages/company/database/migrations/create_companies_table.php.stub new file mode 100644 index 0000000000..e161cadc80 --- /dev/null +++ b/packages/company/database/migrations/create_companies_table.php.stub @@ -0,0 +1,61 @@ +uuid('id')->primary(); + $table->string('status', 30)->default('draft')->index(); + + $table->string('name', 120)->nullable()->index(); + $table->string('display_name', 120)->nullable(); + $table->string('legal_name', 120)->nullable(); + $table->text('note')->nullable(); + $table->text('search_terms')->nullable(); + + $table->foreignUuid('parent_id')->nullable()->constrained('companies')->nullOnDelete(); + $table->string('external_reference', 100)->nullable()->index(); + + $table->string('phone', 30)->nullable(); + $table->string('fax', 30)->nullable(); + $table->string('url', 255)->nullable(); + $table->string('email', 100)->nullable()->index(); + + $table->string('tax_number', 30)->nullable(); + $table->string('vat_number', 30)->nullable(); + $table->boolean('has_no_vat_number')->default(false); + + $table->unsignedTinyInteger('partner_type')->nullable()->index(); + $table->unsignedBigInteger('partner_id')->nullable()->index(); + $table->string('company_type', 30)->default('customer')->index(); + $table->char('default_currency_code', 3)->default('EUR'); + $table->boolean('is_fully_owned_subsidiary')->default(false); + $table->boolean('no_marketing_action')->default(false); + $table->string('no_marketing_action_reason', 255)->nullable(); + + $table->foreignId('language_id')->nullable()->constrained('static_languages')->nullOnDelete(); + $table->foreignId('localization_id')->nullable()->constrained('localizations')->nullOnDelete(); + + $table->integer('sort')->nullable(); + $table->boolean('is_active')->default(true)->index(); + $table->timestamp('approved_at')->nullable(); + $table->nullableMorphs('approved_by'); + + $table->json('data')->nullable(); + $table->softDeletes(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('companies'); + } +}; diff --git a/packages/company/resources/lang/de/company.php b/packages/company/resources/lang/de/company.php new file mode 100644 index 0000000000..b7f6aef0cc --- /dev/null +++ b/packages/company/resources/lang/de/company.php @@ -0,0 +1,6 @@ + 'Firma', + 'companies' => 'Firmen', +]; diff --git a/packages/company/resources/lang/de/fields.php b/packages/company/resources/lang/de/fields.php new file mode 100644 index 0000000000..129763638b --- /dev/null +++ b/packages/company/resources/lang/de/fields.php @@ -0,0 +1,41 @@ + 'Status', + 'name' => 'Name', + 'display_name' => 'Anzeigename', + 'legal_name' => 'Rechtlicher Name', + 'note' => 'Notiz', + 'search_terms' => 'Suchbegriffe', + 'parent' => 'Muttergesellschaft', + 'children' => 'Tochtergesellschaften', + 'external_reference' => 'Externe Referenz', + 'phone' => 'Telefon', + 'fax' => 'Fax', + 'url' => 'Website', + 'email' => 'E-Mail', + 'tax_number' => 'Steuernummer', + 'vat_number' => 'USt-IdNr.', + 'has_no_vat_number' => 'Keine USt-IdNr.', + 'partner_type' => 'Partner-Typ', + 'partner_id' => 'Partner-ID', + 'company_type' => 'Firmentyp', + 'default_currency_code' => 'Standardwährung', + 'is_fully_owned_subsidiary' => 'Vollständige Tochter', + 'no_marketing_action' => 'Kein Marketing', + 'no_marketing_action_reason' => 'Grund (kein Marketing)', + 'language_id' => 'Sprache', + 'localization_id' => 'Lokalisierung', + 'sort' => 'Sortierung', + 'is_active' => 'Aktiv', + 'approved_at' => 'Freigegeben am', + 'data' => 'Zusätzliche Daten', + 'identity' => 'Stammdaten', + 'contact' => 'Kontakt', + 'tax' => 'Steuer & Register', + 'partner' => 'Partner-Verknüpfung', + 'settings' => 'Einstellungen', + 'active' => 'Aktiv', + 'customers' => 'Kunden', + 'suppliers' => 'Lieferanten', +]; diff --git a/packages/company/resources/lang/en/company.php b/packages/company/resources/lang/en/company.php new file mode 100644 index 0000000000..0dd85ad041 --- /dev/null +++ b/packages/company/resources/lang/en/company.php @@ -0,0 +1,6 @@ + 'Company', + 'companies' => 'Companies', +]; diff --git a/packages/company/resources/lang/en/fields.php b/packages/company/resources/lang/en/fields.php new file mode 100644 index 0000000000..c21be52937 --- /dev/null +++ b/packages/company/resources/lang/en/fields.php @@ -0,0 +1,42 @@ + 'Status', + 'name' => 'Name', + 'display_name' => 'Display name', + 'legal_name' => 'Legal name', + 'note' => 'Note', + 'search_terms' => 'Search terms', + 'parent' => 'Parent company', + 'children' => 'Subsidiaries', + 'addresses' => 'Addresses', + 'external_reference' => 'External reference', + 'phone' => 'Phone', + 'fax' => 'Fax', + 'url' => 'Website', + 'email' => 'Email', + 'tax_number' => 'Tax number', + 'vat_number' => 'VAT number', + 'has_no_vat_number' => 'No VAT number', + 'partner_type' => 'Partner type', + 'partner_id' => 'Partner ID', + 'company_type' => 'Company type', + 'default_currency_code' => 'Default currency', + 'is_fully_owned_subsidiary' => 'Fully owned subsidiary', + 'no_marketing_action' => 'No marketing', + 'no_marketing_action_reason' => 'No marketing reason', + 'language_id' => 'Language', + 'localization_id' => 'Localization', + 'sort' => 'Sort order', + 'is_active' => 'Active', + 'approved_at' => 'Approved at', + 'data' => 'Additional data', + 'identity' => 'Identity', + 'contact' => 'Contact', + 'tax' => 'Tax & registration', + 'partner' => 'Partner link', + 'settings' => 'Settings', + 'active' => 'Active', + 'customers' => 'Customers', + 'suppliers' => 'Suppliers', +]; diff --git a/packages/company/src/CompanyServiceProvider.php b/packages/company/src/CompanyServiceProvider.php new file mode 100644 index 0000000000..915beda9c2 --- /dev/null +++ b/packages/company/src/CompanyServiceProvider.php @@ -0,0 +1,75 @@ +name('company') + ->hasConfigFile() + ->hasTranslations() + ->hasMigrations([ + 'create_companies_table', + ]) + ->hasCommands(); + + $this->getMooxPackage() + ->title('Moox Company') + ->released(false) + ->stability('stable') + ->category('development') + ->usedFor([ + 'ERP company records (customers, suppliers, subsidiaries)', + ]) + ->alternatePackages([ + '', + ]) + ->templateFor([ + 'creating ERP-style company entities with pivot-based addresses', + ]) + ->templateReplace([ + 'Company' => '%%PackageName%%', + 'company' => '%%PackageSlug%%', + 'Company is a Moox Entity for ERP-style company records (customers, suppliers, subsidiaries) without payment or default-address FKs.' => '%%Description%%', + 'ERP company records (customers, suppliers, subsidiaries)' => '%%UsedFor%%', + 'released(true)' => 'released(false)', + 'stability(stable)' => 'stability(dev)', + 'category(development)' => 'category(unknown)', + 'moox/builder' => '', + ]) + ->templateRename([ + 'Company' => '%%PackageName%%', + 'company' => '%%PackageSlug%%', + ]) + ->templateSectionReplace([ + "/.*/s" => '%%Description%%', + ]) + ->templateEntityFiles([ + 'config/company.php', + 'database/factories/CompanyFactory.php', + 'database/migrations/create_companies_table.php.stub', + 'resources/lang/en/company.php', + 'resources/lang/en/fields.php', + 'src/Models/Company.php', + 'src/Frontend/CompanyFrontend.php', + 'src/Resources/Company/Pages/CreateCompany.php', + 'src/Resources/Company/Pages/EditCompany.php', + 'src/Resources/Company/Pages/ListCompanies.php', + 'src/Resources/Company/Pages/ViewCompany.php', + 'src/Resources/CompanyResource.php', + 'src/Support/CompanyRules.php', + 'src/Plugins/CompanyPlugin.php', + 'resources/lang/de/fields.php', + ]) + ->templateRemove([ + '', + ]); + } +} diff --git a/packages/company/src/Frontend/CompanyFrontend.php b/packages/company/src/Frontend/CompanyFrontend.php new file mode 100644 index 0000000000..45fd9a5c78 --- /dev/null +++ b/packages/company/src/Frontend/CompanyFrontend.php @@ -0,0 +1,20 @@ + */ + use HasFactory; + use HasModelTaxonomy; + use HasMorphPivotRelations; + use HasUuids; + use SoftDeletes; + + protected $keyType = 'string'; + + public $incrementing = false; + + protected $fillable = [ + 'status', + 'name', + 'display_name', + 'legal_name', + 'note', + 'search_terms', + 'parent_id', + 'external_reference', + 'phone', + 'fax', + 'url', + 'email', + 'tax_number', + 'vat_number', + 'has_no_vat_number', + 'partner_type', + 'partner_id', + 'company_type', + 'default_currency_code', + 'is_fully_owned_subsidiary', + 'no_marketing_action', + 'no_marketing_action_reason', + 'language_id', + 'localization_id', + 'sort', + 'is_active', + 'approved_at', + 'approved_by_type', + 'approved_by_id', + 'data', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'has_no_vat_number' => 'boolean', + 'partner_type' => 'integer', + 'partner_id' => 'integer', + 'is_fully_owned_subsidiary' => 'boolean', + 'no_marketing_action' => 'boolean', + 'is_active' => 'boolean', + 'approved_at' => 'datetime', + 'data' => 'array', + 'sort' => 'integer', + ]; + } + + public static function getResourceName(): string + { + return 'company'; + } + + public static function newFactory(): CompanyFactory + { + return CompanyFactory::new(); + } + + /** + * @return BelongsTo + */ + public function parent(): BelongsTo + { + return $this->belongsTo(self::class, 'parent_id'); + } + + /** + * @return HasMany + */ + public function children(): HasMany + { + return $this->hasMany(self::class, 'parent_id'); + } + + /** + * @return MorphTo + */ + public function approvedBy(): MorphTo + { + return $this->morphTo(); + } + + /** + * @return MorphToMany + */ + /** + * @return MorphToMany + */ + public function addresses(): MorphToMany + { + return $this->morphPivotRelation('addressables'); + } + + /** + * Primary related record ({@see addresses()} + config primary). + * + * @return MorphToMany + */ + public function address(): MorphToMany + { + return $this->primaryMorphPivotRelation('addressables'); + } + + /** + * @param array $parameters + */ + public function __call($method, $parameters): mixed + { + $taxonomies = $this->getTaxonomyService()->getTaxonomies(); + + if (array_key_exists($method, $taxonomies)) { + return $this->taxonomy($method); + } + + return $this->morphPivotCall($method, $parameters); + } + + public function displayLabel(): string + { + return $this->display_name + ?? $this->name + ?? $this->legal_name + ?? (string) $this->getKey(); + } + + protected static function booted(): void + { + static::saving(function (Company $company): void { + if ($company->parent_id !== null && $company->parent_id === $company->getKey()) { + $company->parent_id = null; + } + + if ($company->default_currency_code !== null) { + $company->default_currency_code = strtoupper(trim($company->default_currency_code)); + } + }); + } +} diff --git a/packages/company/src/Plugins/CompanyPlugin.php b/packages/company/src/Plugins/CompanyPlugin.php new file mode 100644 index 0000000000..860319b867 --- /dev/null +++ b/packages/company/src/Plugins/CompanyPlugin.php @@ -0,0 +1,38 @@ +query('parent_id'); + + if (filled($parentId)) { + $this->form->fill([ + 'parent_id' => $parentId, + ]); + } + } +} diff --git a/packages/company/src/Resources/Company/Pages/EditCompany.php b/packages/company/src/Resources/Company/Pages/EditCompany.php new file mode 100644 index 0000000000..1e77f86818 --- /dev/null +++ b/packages/company/src/Resources/Company/Pages/EditCompany.php @@ -0,0 +1,13 @@ +getDynamicTabs('company.resources.company.tabs', Company::class); + } +} diff --git a/packages/company/src/Resources/Company/Pages/ViewCompany.php b/packages/company/src/Resources/Company/Pages/ViewCompany.php new file mode 100644 index 0000000000..ea00c382f8 --- /dev/null +++ b/packages/company/src/Resources/Company/Pages/ViewCompany.php @@ -0,0 +1,13 @@ +columns([ + TextColumn::make('name') + ->label(__('company::fields.name')) + ->searchable(), + TextColumn::make('company_type') + ->label(__('company::fields.company_type')) + ->badge(), + TextColumn::make('status') + ->label(__('company::fields.status')) + ->badge(), + IconColumn::make('is_active') + ->label(__('company::fields.is_active')) + ->boolean(), + ]) + ->headerActions([ + CreateAction::make() + ->url(fn (): string => CompanyResource::getUrl('create', [ + 'parent_id' => $this->getOwnerRecord()->getKey(), + ])), + ]) + ->recordActions([ + EditAction::make() + ->url(fn (Company $record): string => CompanyResource::getUrl('edit', ['record' => $record])), + DeleteAction::make(), + ]); + } +} diff --git a/packages/company/src/Resources/CompanyResource.php b/packages/company/src/Resources/CompanyResource.php new file mode 100644 index 0000000000..42b8e23337 --- /dev/null +++ b/packages/company/src/Resources/CompanyResource.php @@ -0,0 +1,339 @@ +schema([ + Section::make(__('company::fields.identity')) + ->schema([ + Select::make('status') + ->label(__('company::fields.status')) + ->options($statusOptions) + ->required() + ->rules(CompanyRules::for('status')) + ->default('draft'), + TextInput::make('name') + ->label(__('company::fields.name')) + ->rules(CompanyRules::for('name')) + ->maxLength(120), + TextInput::make('display_name') + ->label(__('company::fields.display_name')) + ->rules(CompanyRules::for('display_name')) + ->maxLength(120), + TextInput::make('legal_name') + ->label(__('company::fields.legal_name')) + ->rules(CompanyRules::for('legal_name')) + ->maxLength(120), + Select::make('company_type') + ->label(__('company::fields.company_type')) + ->options($typeOptions) + ->required() + ->rules(CompanyRules::for('company_type')) + ->default('customer'), + Select::make('parent_id') + ->label(__('company::fields.parent')) + ->relationship('parent', 'display_name') + ->getOptionLabelFromRecordUsing(fn (Company $record): string => $record->displayLabel()) + ->searchable() + ->preload() + ->rules(CompanyRules::for('parent_id')), + TextInput::make('external_reference') + ->label(__('company::fields.external_reference')) + ->rules(CompanyRules::for('external_reference')) + ->maxLength(100), + Textarea::make('note') + ->label(__('company::fields.note')) + ->rules(CompanyRules::for('note')) + ->columnSpanFull(), + Textarea::make('search_terms') + ->label(__('company::fields.search_terms')) + ->rules(CompanyRules::for('search_terms')) + ->columnSpanFull(), + ]) + ->columnSpan(2), + Grid::make() + ->schema([ + Section::make() + ->schema([ + static::getFormActions(), + ]), + Section::make(__('company::fields.contact')) + ->schema([ + TextInput::make('phone') + ->label(__('company::fields.phone')) + ->tel() + ->rules(CompanyRules::for('phone')) + ->maxLength(30), + TextInput::make('fax') + ->label(__('company::fields.fax')) + ->rules(CompanyRules::for('fax')) + ->maxLength(30), + TextInput::make('email') + ->label(__('company::fields.email')) + ->email() + ->rules(CompanyRules::for('email')) + ->maxLength(100), + TextInput::make('url') + ->label(__('company::fields.url')) + ->url() + ->rules(CompanyRules::for('url')) + ->maxLength(255), + ]), + Section::make(__('company::fields.tax')) + ->schema([ + TextInput::make('tax_number') + ->label(__('company::fields.tax_number')) + ->rules(CompanyRules::for('tax_number')) + ->maxLength(30), + TextInput::make('vat_number') + ->label(__('company::fields.vat_number')) + ->rules(CompanyRules::for('vat_number')) + ->maxLength(30) + ->disabled(fn ($get): bool => (bool) $get('has_no_vat_number')), + Toggle::make('has_no_vat_number') + ->label(__('company::fields.has_no_vat_number')) + ->live(), + ]), + Section::make(__('company::fields.settings')) + ->schema([ + TextInput::make('default_currency_code') + ->label(__('company::fields.default_currency_code')) + ->required() + ->rules(CompanyRules::for('default_currency_code')) + ->maxLength(3) + ->length(3) + ->default(config('company.default_currency_code', 'EUR')), + Toggle::make('is_fully_owned_subsidiary') + ->label(__('company::fields.is_fully_owned_subsidiary')), + Toggle::make('no_marketing_action') + ->label(__('company::fields.no_marketing_action')) + ->live(), + TextInput::make('no_marketing_action_reason') + ->label(__('company::fields.no_marketing_action_reason')) + ->rules(CompanyRules::for('no_marketing_action_reason')) + ->maxLength(255) + ->visible(fn ($get): bool => (bool) $get('no_marketing_action')), + TextInput::make('sort') + ->label(__('company::fields.sort')) + ->numeric() + ->rules(CompanyRules::for('sort')), + Toggle::make('is_active') + ->label(__('company::fields.is_active')) + ->default(true), + DateTimePicker::make('approved_at') + ->label(__('company::fields.approved_at')) + ->rules(CompanyRules::for('approved_at')), + ]), + Section::make('') + ->schema($taxonomyFields), + Section::make('') + ->schema([ + Section::make('') + ->schema([ + ...static::getStandardTimestampFields(), + ]), + ]) + ->hidden(fn (?Company $record) => $record === null), + ]) + ->columnSpan(1) + ->columns(1), + ]) + ->columns(3) + ->columnSpanFull(), + ]; + + return $form->components($schema); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + TextColumn::make('name') + ->label(__('company::fields.name')) + ->searchable() + ->sortable(), + TextColumn::make('display_name') + ->label(__('company::fields.display_name')) + ->searchable() + ->toggleable(), + TextColumn::make('company_type') + ->label(__('company::fields.company_type')) + ->badge() + ->color( + fn (string $state): string => match ($state) { + 'customer' => 'success', + 'supplier' => 'warning', + 'partner' => 'primary', + 'prospect' => 'warning', + 'internal' => 'gray', + } + ) + ->sortable(), + TextColumn::make('status') + ->label(__('company::fields.status')) + ->badge() + ->color( + fn (string $state): string => match ($state) { + 'draft' => 'info', + 'active' => 'success', + 'inactive' => 'warning', + 'approved' => 'success', + 'archived' => 'danger', + } + ) + ->sortable(), + TextColumn::make('parent.display_name') + ->label(__('company::fields.parent')) + ->toggleable(), + TextColumn::make('email') + ->label(__('company::fields.email')) + ->searchable() + ->toggleable(), + TextColumn::make('default_currency_code') + ->label(__('company::fields.default_currency_code')) + ->toggleable(isToggledHiddenByDefault: true), + IconColumn::make('is_active') + ->label(__('company::fields.is_active')) + ->boolean(), + TextColumn::make('children_count') + ->counts('children') + ->label(__('company::fields.children')), + TextColumn::make('created_at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ...static::getTaxonomyColumns(), + ]) + ->defaultSort('name') + ->recordActions([...static::getTableActions()]) + ->toolbarActions([...static::getBulkActions()]) + ->filters([ + ...static::getCompanyTableFilters(), + ...static::getTaxonomyFilters(), + ]) + ->deferFilters(false) + ->persistFiltersInSession(); + } + + /** + * @return array + */ + protected static function getCompanyTableFilters(): array + { + return [ + SelectFilter::make('status') + ->label(__('company::fields.status')) + ->options(static::configOptions('company.statuses')), + SelectFilter::make('company_type') + ->label(__('company::fields.company_type')) + ->options(static::configOptions('company.company_types')), + TernaryFilter::make('is_active') + ->label(__('company::fields.is_active')), + ]; + } + + /** + * @return array + */ + protected static function configOptions(string $configKey): array + { + $values = config($configKey, []); + + return collect($values) + ->mapWithKeys(fn (string $value): array => [$value => $value]) + ->all(); + } + + /** + * @return array + */ + protected static function getDeclaredRelations(): array + { + return [ + ChildrenRelationManager::class, + ]; + } + + public static function getPages(): array + { + return [ + 'index' => ListCompanies::route('/'), + 'create' => CreateCompany::route('/create'), + 'edit' => EditCompany::route('/{record}/edit'), + 'view' => ViewCompany::route('/{record}'), + ]; + } +} diff --git a/packages/company/src/Support/CompanyRules.php b/packages/company/src/Support/CompanyRules.php new file mode 100644 index 0000000000..547147cf13 --- /dev/null +++ b/packages/company/src/Support/CompanyRules.php @@ -0,0 +1,53 @@ +> + */ + public static function rules(): array + { + return [ + 'status' => ['required', 'string', 'max:30', 'in:'.implode(',', config('company.statuses', ['draft']))], + 'name' => ['nullable', 'string', 'max:120'], + 'display_name' => ['nullable', 'string', 'max:120'], + 'legal_name' => ['nullable', 'string', 'max:120'], + 'note' => ['nullable', 'string'], + 'search_terms' => ['nullable', 'string'], + 'parent_id' => ['nullable', 'uuid', 'exists:companies,id'], + 'external_reference' => ['nullable', 'string', 'max:100'], + 'phone' => ['nullable', 'string', 'max:30'], + 'fax' => ['nullable', 'string', 'max:30'], + 'url' => ['nullable', 'string', 'max:255', 'url'], + 'email' => ['nullable', 'string', 'max:100', 'email'], + 'tax_number' => ['nullable', 'string', 'max:30'], + 'vat_number' => ['nullable', 'string', 'max:30'], + 'has_no_vat_number' => ['boolean'], + 'partner_type' => ['nullable', 'integer', 'min:0', 'max:255'], + 'partner_id' => ['nullable', 'integer', 'min:0'], + 'company_type' => ['required', 'string', 'max:30', 'in:'.implode(',', config('company.company_types', ['customer']))], + 'default_currency_code' => ['required', 'string', 'size:3', 'alpha:ascii', 'uppercase'], + 'is_fully_owned_subsidiary' => ['boolean'], + 'no_marketing_action' => ['boolean'], + 'no_marketing_action_reason' => ['nullable', 'string', 'max:255'], + 'language_id' => ['nullable', 'integer', 'exists:static_languages,id'], + 'localization_id' => ['nullable', 'integer', 'exists:localizations,id'], + 'sort' => ['nullable', 'integer'], + 'is_active' => ['boolean'], + 'approved_at' => ['nullable', 'date'], + 'data' => ['nullable', 'array'], + ]; + } + + /** + * @return list + */ + public static function for(string $field): array + { + return self::rules()[$field] ?? []; + } +} diff --git a/packages/company/tests/ArchTest.php b/packages/company/tests/ArchTest.php new file mode 100644 index 0000000000..433040f665 --- /dev/null +++ b/packages/company/tests/ArchTest.php @@ -0,0 +1,17 @@ +expect('Moox\Company') + ->toUseStrictTypes() + ->not->toUse(['die', 'dd', 'dump']); + +arch() + ->expect('Moox\Company\Models') + ->toBeClasses() + ->toExtend(Model::class) + ->toOnlyBeUsedIn('Moox\Company'); + +arch()->preset()->php(); +arch()->preset()->security()->ignoring('md5'); diff --git a/packages/company/tests/Feature/FilamentCompanyTest.php b/packages/company/tests/Feature/FilamentCompanyTest.php new file mode 100644 index 0000000000..9dde66f445 --- /dev/null +++ b/packages/company/tests/Feature/FilamentCompanyTest.php @@ -0,0 +1,79 @@ +actingAs(TestUser::query()->create([ + 'name' => 'Test User', + 'email' => 'test-'.uniqid().'@example.com', + 'password' => bcrypt('password'), + ])); +}); + +it('can render the company list page', function (): void { + livewire(ListCompanies::class)->assertSuccessful(); +}); + +it('can render table columns for companies', function (): void { + livewire(ListCompanies::class) + ->assertTableColumnExists('name') + ->assertTableColumnExists('company_type') + ->assertTableColumnExists('status') + ->assertTableColumnExists('is_active'); +}); + +it('create form contains expected company fields', function (): void { + livewire(CreateCompany::class) + ->assertFormExists('form') + ->assertFormFieldExists('name', 'form') + ->assertFormFieldExists('company_type', 'form') + ->assertFormFieldExists('default_currency_code', 'form'); +}); + +it('can create a company via filament', function (): void { + livewire(CreateCompany::class) + ->fillForm([ + 'name' => 'Filament GmbH', + 'display_name' => 'Filament GmbH', + 'status' => 'draft', + 'company_type' => 'customer', + 'default_currency_code' => 'EUR', + 'is_active' => true, + ], 'form') + ->call('create') + ->assertHasNoFormErrors(); + + expect(Company::query()->where('name', 'Filament GmbH')->exists())->toBeTrue(); +}); + +it('can edit an existing company via filament', function (): void { + $company = Company::factory()->create([ + 'name' => 'Filament GmbH', + 'display_name' => 'Filament GmbH', + 'status' => 'draft', + 'company_type' => 'customer', + 'default_currency_code' => 'EUR', + 'is_active' => true, + ]); + + livewire(EditCompany::class, ['record' => $company->getKey()]) + ->fillForm([ + 'display_name' => 'New Name', + ], 'form') + ->call('save') + ->assertHasNoFormErrors(); + + expect($company->fresh()->display_name)->toBe('New Name'); + +}); diff --git a/packages/company/tests/FeatureTestCase.php b/packages/company/tests/FeatureTestCase.php new file mode 100644 index 0000000000..f87a2f6fa6 --- /dev/null +++ b/packages/company/tests/FeatureTestCase.php @@ -0,0 +1,23 @@ +set('company.taxonomies', []); + config()->set('company.readonly', false); + } +} diff --git a/packages/company/tests/Pest.php b/packages/company/tests/Pest.php new file mode 100644 index 0000000000..c49ca9b66e --- /dev/null +++ b/packages/company/tests/Pest.php @@ -0,0 +1,11 @@ +in($packageTestsPath.'/Unit'); +uses(FeatureTestCase::class)->in($packageTestsPath.'/Feature'); diff --git a/packages/company/tests/TestCase.php b/packages/company/tests/TestCase.php new file mode 100644 index 0000000000..a3c14c9853 --- /dev/null +++ b/packages/company/tests/TestCase.php @@ -0,0 +1,220 @@ +withoutVite(); + + $panel = Filament::getPanel('admin'); + Filament::setCurrentPanel($panel); + Filament::bootCurrentPanel(); + + $errors = new ViewErrorBag; + $errors->put('default', new MessageBag); + $this->app['view']->share('errors', $errors); + + if ($this->app->bound('session')) { + $this->app['session']->put('errors', $errors); + } + } + + protected function defineDatabaseMigrations(): void + { + $this->loadDependencyMigrations(); + $this->loadPackageMigrations(); + } + + protected function getEnvironmentSetUp($app): void + { + $app['config']->set('app.key', 'base64:'.base64_encode(random_bytes(32))); + $app['config']->set('database.default', 'testing'); + $app['config']->set('session.driver', 'array'); + $app['config']->set('session.lottery', [100, 100]); + $app['config']->set('company.taxonomies', []); + $app['config']->set('company.readonly', false); + + $viewErrorBag = new ViewErrorBag; + $viewErrorBag->put('default', new MessageBag); + $app['view']->share('errors', $viewErrorBag); + + $this->setUpFilamentPanel(); + } + + protected function setUpFilamentPanel(): void + { + $panel = Panel::make() + ->default() + ->id('admin') + ->path('admin') + ->login() + ->colors([ + 'primary' => Color::Violet, + ]) + ->pages([ + Dashboard::class, + ]) + ->middleware([ + EncryptCookies::class, + AddQueuedCookiesToResponse::class, + StartSession::class, + ShareErrorsFromSession::class, + VerifyCsrfToken::class, + SubstituteBindings::class, + AuthenticateSession::class, + DisableBladeIconComponents::class, + DispatchServingFilamentEvent::class, + ]) + ->authMiddleware([ + Authenticate::class, + ]) + ->plugins([ + CompanyPlugin::make(), + ]); + + Filament::registerPanel($panel); + } + + protected function getPackageProviders($app): array + { + return [ + \BladeUI\Icons\BladeIconsServiceProvider::class, + \BladeUI\Heroicons\BladeHeroiconsServiceProvider::class, + \Filament\Actions\ActionsServiceProvider::class, + \Codeat3\BladeGoogleMaterialDesignIcons\BladeGoogleMaterialDesignIconsServiceProvider::class, + \Filament\FilamentServiceProvider::class, + \Filament\Forms\FormsServiceProvider::class, + \Filament\Infolists\InfolistsServiceProvider::class, + \Filament\Notifications\NotificationsServiceProvider::class, + \Filament\Schemas\SchemasServiceProvider::class, + \Filament\Support\SupportServiceProvider::class, + \Filament\Tables\TablesServiceProvider::class, + \Filament\Widgets\WidgetsServiceProvider::class, + \Illuminate\Auth\AuthServiceProvider::class, + \Illuminate\Cookie\CookieServiceProvider::class, + \Illuminate\Database\DatabaseServiceProvider::class, + \Illuminate\Encryption\EncryptionServiceProvider::class, + \Illuminate\Filesystem\FilesystemServiceProvider::class, + \Illuminate\Pagination\PaginationServiceProvider::class, + \Illuminate\Session\SessionServiceProvider::class, + \Illuminate\Translation\TranslationServiceProvider::class, + \Illuminate\Validation\ValidationServiceProvider::class, + \Illuminate\View\ViewServiceProvider::class, + \Livewire\LivewireServiceProvider::class, + \Moox\Company\CompanyServiceProvider::class, + \Moox\Core\CoreServiceProvider::class, + \RyanChandler\BladeCaptureDirective\BladeCaptureDirectiveServiceProvider::class, + + + ]; + } + + protected function loadDependencyMigrations(): void + { + // users/session tables come from #[WithMigration('laravel', …)] and #[WithMigration('session')] + + if (! Schema::hasTable('static_languages')) { + (new class extends Migration + { + public function up(): void + { + Schema::create('static_languages', function (Blueprint $table): void { + $table->id(); + $table->string('alpha2', 2); + $table->string('common_name'); + $table->timestamps(); + }); + } + })->up(); + } + + if (! Schema::hasTable('localizations')) { + (new class extends Migration + { + public function up(): void + { + Schema::create('localizations', function (Blueprint $table): void { + $table->id(); + $table->foreignId('language_id')->constrained('static_languages')->cascadeOnDelete(); + $table->string('title'); + $table->string('slug')->unique(); + $table->string('locale_variant'); + $table->timestamps(); + }); + } + })->up(); + } + + } + + protected function loadPackageMigrations(): void + { + $path = dirname(__DIR__).'/database/migrations/create_companies_table.php.stub'; + + if (is_file($path)) { + $instance = include $path; + $instance->up(); + } + } + + protected function createTestUser(): TestUser + { + return TestUser::query()->create([ + 'name' => 'Test User', + 'email' => 'test-'.uniqid().'@example.com', + 'password' => bcrypt('password'), + ]); + } + + /** + * @return array + */ + protected function sampleCompanyAttributes(): array + { + return [ + 'status' => 'draft', + 'name' => 'Muster GmbH', + 'display_name' => 'Muster GmbH', + 'company_type' => 'customer', + 'default_currency_code' => 'EUR', + 'is_active' => true, + ]; + } +} diff --git a/packages/company/tests/Unit/CompanyModelTest.php b/packages/company/tests/Unit/CompanyModelTest.php new file mode 100644 index 0000000000..9d29711e8e --- /dev/null +++ b/packages/company/tests/Unit/CompanyModelTest.php @@ -0,0 +1,48 @@ +create(); + + expect($company)->toBeInstanceOf(Company::class) + ->and($company->exists)->toBeTrue() + ->and($company->getKey())->toBeString() + ->and($company->name)->not->toBeEmpty(); +}); + +it('builds a display label from display name or name', function (): void { + $company = Company::factory()->create([ + 'name' => 'Legal Name GmbH', + 'display_name' => 'Muster Display', + ]); + + expect($company->displayLabel())->toBe('Muster Display'); +}); + +it('links a child company to a parent', function (): void { + $parent = Company::factory()->create(); + $child = Company::factory()->withParent($parent)->create(); + + expect($child->parent_id)->toBe($parent->getKey()) + ->and($parent->children()->count())->toBe(1); +}); + +it('normalizes default currency to uppercase', function (): void { + $company = Company::factory()->create([ + 'default_currency_code' => 'eur', + ]); + + expect($company->fresh()->default_currency_code)->toBe('EUR'); +}); + +it('clears parent when set to itself', function (): void { + $company = Company::factory()->create(); + + $company->parent_id = $company->getKey(); + $company->save(); + + expect($company->fresh()->parent_id)->toBeNull(); +}); diff --git a/packages/company/tests/bootstrap.php b/packages/company/tests/bootstrap.php new file mode 100644 index 0000000000..fa65b019bf --- /dev/null +++ b/packages/company/tests/bootstrap.php @@ -0,0 +1,18 @@ + Date: Tue, 26 May 2026 16:59:33 +0200 Subject: [PATCH 3/8] fixes Addres package --- packages/address/README.md | 114 ++++++++++++++++-- packages/address/config/address.php | 6 +- packages/address/docs/attributes/README.md | 96 +++++++++++++++ packages/address/resources/lang/de/fields.php | 3 + packages/address/resources/lang/en/fields.php | 3 + .../address/src/AddressServiceProvider.php | 16 +++ .../src/Concerns/HasMorphedByOwnerTypes.php | 0 .../AddressablesRelationManager.php | 14 +++ .../tests/Feature/FilamentAddressTest.php | 2 +- 9 files changed, 238 insertions(+), 16 deletions(-) create mode 100644 packages/address/docs/attributes/README.md create mode 100644 packages/address/src/Concerns/HasMorphedByOwnerTypes.php diff --git a/packages/address/README.md b/packages/address/README.md index b6ce0e35b7..bd3956f92b 100644 --- a/packages/address/README.md +++ b/packages/address/README.md @@ -2,20 +2,20 @@ # Moox Address -Address is a simple Moox Entity, that can be used to create and manage addresses. +Address is a simple Moox Entity that can be used to create and manage postal addresses and assign them to owners via a morph pivot. ## Features -- Title with Slug -- Active (Toggle) -- Description (Editor) -- Custom Properties (Key-Value) -- Author (User) -- UUID -- ULID +- Postal fields (street, city, country, etc.) +- Optional label and primary flag +- Custom data (JSON) +- Duplicate detection (fingerprint) +- Morph assignments with billing, postal, and delivery roles +- Soft delete - Taxonomies +- Filament resource with relation manager @@ -36,10 +36,6 @@ Curious what the install command does? See [Installation](https://github.com/moo ![Moox Address](https://github.com/mooxphp/moox/raw/main/art/screenshots/record.jpg) -## Get Started - -See [Get Started](docs/GetStarted.md). - ## Changelog Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. @@ -59,3 +55,97 @@ Thanks to so many [people for their contributions](https://github.com/mooxphp/mo ## License The MIT License (MIT). Please see [our license and copyright information](https://github.com/mooxphp/moox/blob/main/LICENSE.md) for more information. + +## The Address Model + +The `Address` model (`Moox\Address\Models\Address`) stores normalized postal data. It extends `BaseItemModel`, uses soft deletes, and supports taxonomies via `HasModelTaxonomy`. + +### Attributes + +#### Base Fields + +- `label` (string, 120) - Optional internal label (e.g. Headquarter, Warehouse) +- `name` (string, 160) - Recipient or company name on the address +- `street` (string, 160) - Street and house number +- `street2` (string, 160) - Additional address line (suite, building, etc.) +- `postal_code` (string, 20) - Postal / ZIP code +- `city` (string, 120) - City +- `state` (string, 120) - State, region, or province +- `country_code` (string, 2) - ISO 3166-1 alpha-2 country code (stored uppercase) +- `is_primary` (boolean) - Marks this address as primary (default: false) +- `data` (json) - Flexible key-value payload for custom metadata +- `deleted_at` (datetime) - Soft-delete timestamp +- `created_at` (datetime) - Creation timestamp +- `updated_at` (datetime) - Last update timestamp + +Validation rules live in `Moox\Address\Support\AddressRules` and are applied in the Filament resource. + +#### Duplicate detection + +Duplicate addresses are blocked on save via `DuplicateAddressException`. Comparison uses `AddressFingerprint` with these columns only: + +- `street` +- `street2` +- `postal_code` +- `country_code` + +Not part of the fingerprint: `label`, `name`, `city`, `state`, `is_primary`, and `data`. Two records with the same street, postal code, and country but different city or name are still treated as duplicates. + +Empty strings are normalized to `null` before comparison. `country_code` is trimmed and uppercased on save. + +### Methods + +#### Formatting + +- `formattedLine()` - Single-line summary: name, street lines, postal code + city, country code + +#### Duplicate detection + +- `findDuplicate()` - Returns an existing `Address` with the same fingerprint, or null +- `scopeWithFingerprint()` - Query scope for fingerprint lookup + +### Relationships + +- `addressables()` - Pivot rows linking this address to owners + +## The Addressable Pivot + +Assignments between an address and an owner (company, contact, user, etc.) live on `addressables`, not on `addresses`. Roles are pivot flags, not columns on the address itself. + +### Attributes + +#### Pivot Fields + +- `addressable_type` (uuid morph) - Owner model class +- `addressable_id` (uuid) - Owner primary key +- `address_id` (foreignId) - References `addresses.id` (cascade on delete) +- `billing_address` (boolean) - Use as billing address (default: false) +- `postal_address` (boolean) - Use as postal address (default: false) +- `delivery_address` (boolean) - Use as delivery address (default: false) +- `created_at` (datetime) - Creation timestamp +- `updated_at` (datetime) - Last update timestamp + +Unique constraint: `(addressable_type, addressable_id, address_id)`. + +### Methods + +- `activeRoles()` - Active role keys: `billing`, `postal`, `delivery` + +### Relationships + +- `addressable()` - Owner (`MorphTo`) +- `address()` - Linked `Address` (`BelongsTo`) + +### Owner trait + +Models that can own addresses use `Moox\Address\Concerns\HasAddresses`: + +- `addresses()` - `MorphToMany` via `addressables`, with pivot columns from `config('address.relations.addressables')` + +Register allowed owner types under `address.relations.addressables.owner_types` in `config/address.php`. + +### Translations + +Field labels for the admin UI are in `resources/lang/{locale}/fields.php` (e.g. `address::fields.street`). Entity titles use `address::address.*`. + +There are no translatable model attributes on `Address`; all address fields are stored on the main table. diff --git a/packages/address/config/address.php b/packages/address/config/address.php index b3c1d4bbcc..9aa6147f71 100644 --- a/packages/address/config/address.php +++ b/packages/address/config/address.php @@ -126,8 +126,8 @@ 'delivery_address', ], 'owner_types' => [ - // Heco\Company\Models\Company::class => 'Company', - // Heco\Contact\Models\Contact::class => 'Contact', + 'Moox\Company\Models\Company' => 'Company', + // 'Heco\Contact\Models\Contact' => 'Contact', ], ], ], @@ -164,6 +164,6 @@ | and if the panel is enabled. | */ - 'navigation_group' => 'DEV', + 'navigation_group' => 'Portal', ]; diff --git a/packages/address/docs/attributes/README.md b/packages/address/docs/attributes/README.md new file mode 100644 index 0000000000..08af0efe76 --- /dev/null +++ b/packages/address/docs/attributes/README.md @@ -0,0 +1,96 @@ +# Address Attributes + +This document describes the database fields and pivot attributes of the Moox Address entity. + +## The Address Model + +The `Address` model (`Moox\Address\Models\Address`) stores normalized postal data. It extends `BaseItemModel`, uses soft deletes, and supports taxonomies via `HasModelTaxonomy`. + +### Attributes + +#### Base Fields + +- `label` (string, 120) - Optional internal label (e.g. Headquarter, Warehouse) +- `name` (string, 160) - Recipient or company name on the address +- `street` (string, 160) - Street and house number +- `street2` (string, 160) - Additional address line (suite, building, etc.) +- `postal_code` (string, 20) - Postal / ZIP code +- `city` (string, 120) - City +- `state` (string, 120) - State, region, or province +- `country_code` (string, 2) - ISO 3166-1 alpha-2 country code (stored uppercase) +- `is_primary` (boolean) - Marks this address as primary (default: false) +- `data` (json) - Flexible key-value payload for custom metadata +- `deleted_at` (datetime) - Soft-delete timestamp +- `created_at` (datetime) - Creation timestamp +- `updated_at` (datetime) - Last update timestamp + +Validation rules live in `Moox\Address\Support\AddressRules` and are applied in the Filament resource. + +#### Duplicate detection + +Duplicate addresses are blocked on save via `DuplicateAddressException`. Comparison uses `AddressFingerprint` with these columns only: + +- `street` +- `street2` +- `postal_code` +- `country_code` + +Not part of the fingerprint: `label`, `name`, `city`, `state`, `is_primary`, and `data`. Two records with the same street, postal code, and country but different city or name are still treated as duplicates. + +Empty strings are normalized to `null` before comparison. `country_code` is trimmed and uppercased on save. + +### Methods + +#### Formatting + +- `formattedLine()` - Single-line summary: name, street lines, postal code + city, country code + +#### Duplicate detection + +- `scopeWithFingerprint()` - Query scope for fingerprint lookup + +### Relationships + +- `addressables()` - Pivot rows linking this address to owners + +## The Addressable Pivot + +Assignments between an address and an owner (company, contact, user, etc.) live on `addressables`, not on `addresses`. Roles are pivot flags, not columns on the address itself. + +### Attributes + +#### Pivot Fields + +- `addressable_type` (uuid morph) - Owner model class +- `addressable_id` (uuid) - Owner primary key +- `address_id` (foreignId) - References `addresses.id` (cascade on delete) +- `billing_address` (boolean) - Use as billing address (default: false) +- `postal_address` (boolean) - Use as postal address (default: false) +- `delivery_address` (boolean) - Use as delivery address (default: false) +- `created_at` (datetime) - Creation timestamp +- `updated_at` (datetime) - Last update timestamp + +Unique constraint: `(addressable_type, addressable_id, address_id)`. + +### Methods + +- `activeRoles()` - Active role keys: `billing`, `postal`, `delivery` + +### Relationships + +- `addressable()` - Owner (`MorphTo`) +- `address()` - Linked `Address` (`BelongsTo`) + +### Owner trait + +Models that can own addresses use `Moox\Address\Concerns\HasAddresses`: + +- `addresses()` - `MorphToMany` via `addressables`, with pivot columns from `config('address.relations.addressables')` + +Register allowed owner types under `address.relations.addressables.owner_types` in `config/address.php`. + +## Translations + +Field labels for the admin UI are in `resources/lang/{locale}/fields.php` (e.g. `address::fields.street`). Entity titles use `address::address.*`. + +There are no translatable model attributes on `Address`; all address fields are stored on the main table. diff --git a/packages/address/resources/lang/de/fields.php b/packages/address/resources/lang/de/fields.php index d97a9c2159..d96582694b 100644 --- a/packages/address/resources/lang/de/fields.php +++ b/packages/address/resources/lang/de/fields.php @@ -17,5 +17,8 @@ 'assignments' => 'Zuordnungen', 'owner' => 'Besitzer', 'add_assignment' => 'Zuordnung hinzufügen', + 'attach_address' => 'Adresse zuordnen', + 'create_address' => 'Adresse anlegen', 'duplicate_address' => 'Eine Adresse mit derselben Straße, PLZ und demselben Land existiert bereits.', + 'owner_name' => 'Name', ]; diff --git a/packages/address/resources/lang/en/fields.php b/packages/address/resources/lang/en/fields.php index 39a3784226..c833902e24 100644 --- a/packages/address/resources/lang/en/fields.php +++ b/packages/address/resources/lang/en/fields.php @@ -17,5 +17,8 @@ 'assignments' => 'Assignments', 'owner' => 'Owner', 'add_assignment' => 'Add assignment', + 'attach_address' => 'Attach address', + 'create_address' => 'Create address', 'duplicate_address' => 'An address with the same street, postal code and country already exists.', + 'owner_name' => 'Name', ]; diff --git a/packages/address/src/AddressServiceProvider.php b/packages/address/src/AddressServiceProvider.php index 4c7801c3e0..4f4e2d8785 100644 --- a/packages/address/src/AddressServiceProvider.php +++ b/packages/address/src/AddressServiceProvider.php @@ -4,11 +4,27 @@ namespace Moox\Address; +use Moox\Address\Models\Address; +use Moox\Address\Resources\AddressResource; use Moox\Core\MooxServiceProvider; +use Moox\Core\Support\MorphPivot\MorphPivotRelationRegistry; use Spatie\LaravelPackageTools\Package; class AddressServiceProvider extends MooxServiceProvider { + public function boot(): void + { + parent::boot(); + + MorphPivotRelationRegistry::registerRelatedModel(Address::class, [ + 'display_columns' => ['name', 'city', 'postal_code', 'country_code', 'is_primary'], + 'translation_prefix' => 'address::fields', + 'related_resource' => AddressResource::class, + 'record_select_label' => 'formattedLine', + 'record_select_search_columns' => ['name', 'city', 'postal_code', 'street', 'street2', 'label'], + ]); + } + public function configureMoox(Package $package): void { $package diff --git a/packages/address/src/Concerns/HasMorphedByOwnerTypes.php b/packages/address/src/Concerns/HasMorphedByOwnerTypes.php new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/address/src/Resources/Address/RelationManagers/AddressablesRelationManager.php b/packages/address/src/Resources/Address/RelationManagers/AddressablesRelationManager.php index 82a45ebae3..71a1b767cb 100644 --- a/packages/address/src/Resources/Address/RelationManagers/AddressablesRelationManager.php +++ b/packages/address/src/Resources/Address/RelationManagers/AddressablesRelationManager.php @@ -73,6 +73,20 @@ public function table(Table $table): Table TextColumn::make("{$morphName}_id") ->label('ID') ->searchable(), + TextColumn::make("{$morphName}") + ->label(__('address::fields.owner_name')) + ->formatStateUsing(function ($record) use ($morphName) { + // Versuche, den Namen des zugehörigen Modells zu holen, falls vorhanden + if ($record->{$morphName} && method_exists($record->{$morphName}, 'displayLabel')) { + return $record->{$morphName}->displayLabel(); + } + if ($record->{$morphName} && property_exists($record->{$morphName}, 'name')) { + return $record->{$morphName}->name; + } + return (string) ($record->{$morphName.'_id'} ?? ''); + }) + ->searchable(), + ]; foreach (AddressRelationConfig::pivotColumns() as $column) { diff --git a/packages/address/tests/Feature/FilamentAddressTest.php b/packages/address/tests/Feature/FilamentAddressTest.php index 9b7ab407d0..02d6da8ec5 100644 --- a/packages/address/tests/Feature/FilamentAddressTest.php +++ b/packages/address/tests/Feature/FilamentAddressTest.php @@ -9,7 +9,7 @@ use Moox\Address\Resources\AddressResource; use Moox\DevTools\Models\TestUser; -use function Pest\Livewire\livewire; + use function Pest\Livewire\livewire; beforeEach(function (): void { $this->actingAs(TestUser::query()->create([ From ee3c3b129c022b7727c4112067f347ff033de993 Mon Sep 17 00:00:00 2001 From: Kim-the-Diamond <93331309+Kim-the-Diamond@users.noreply.github.com> Date: Tue, 26 May 2026 15:00:25 +0000 Subject: [PATCH 4/8] Fix styling --- .../AddressablesRelationManager.php | 3 +- .../tests/Feature/FilamentAddressTest.php | 2 +- packages/company/src/Models/Company.php | 1 + .../ChildrenRelationManager.php | 3 +- .../company/src/Resources/CompanyResource.php | 4 +- .../tests/Feature/FilamentCompanyTest.php | 4 +- packages/company/tests/TestCase.php | 82 ++++++++++++------- packages/company/tests/bootstrap.php | 3 +- .../MorphPivotRelationManager.php | 4 +- .../Services/MorphPivotRelationService.php | 3 - 10 files changed, 67 insertions(+), 42 deletions(-) diff --git a/packages/address/src/Resources/Address/RelationManagers/AddressablesRelationManager.php b/packages/address/src/Resources/Address/RelationManagers/AddressablesRelationManager.php index 71a1b767cb..50cefe02ed 100644 --- a/packages/address/src/Resources/Address/RelationManagers/AddressablesRelationManager.php +++ b/packages/address/src/Resources/Address/RelationManagers/AddressablesRelationManager.php @@ -83,10 +83,11 @@ public function table(Table $table): Table if ($record->{$morphName} && property_exists($record->{$morphName}, 'name')) { return $record->{$morphName}->name; } + return (string) ($record->{$morphName.'_id'} ?? ''); }) ->searchable(), - + ]; foreach (AddressRelationConfig::pivotColumns() as $column) { diff --git a/packages/address/tests/Feature/FilamentAddressTest.php b/packages/address/tests/Feature/FilamentAddressTest.php index 02d6da8ec5..9b7ab407d0 100644 --- a/packages/address/tests/Feature/FilamentAddressTest.php +++ b/packages/address/tests/Feature/FilamentAddressTest.php @@ -9,7 +9,7 @@ use Moox\Address\Resources\AddressResource; use Moox\DevTools\Models\TestUser; - use function Pest\Livewire\livewire; +use function Pest\Livewire\livewire; beforeEach(function (): void { $this->actingAs(TestUser::query()->create([ diff --git a/packages/company/src/Models/Company.php b/packages/company/src/Models/Company.php index 1b93908fba..b232c81699 100644 --- a/packages/company/src/Models/Company.php +++ b/packages/company/src/Models/Company.php @@ -21,6 +21,7 @@ class Company extends BaseItemModel { /** @use HasFactory */ use HasFactory; + use HasModelTaxonomy; use HasMorphPivotRelations; use HasUuids; diff --git a/packages/company/src/Resources/Company/RelationManagers/ChildrenRelationManager.php b/packages/company/src/Resources/Company/RelationManagers/ChildrenRelationManager.php index b42c15887e..20899fbe31 100644 --- a/packages/company/src/Resources/Company/RelationManagers/ChildrenRelationManager.php +++ b/packages/company/src/Resources/Company/RelationManagers/ChildrenRelationManager.php @@ -11,6 +11,7 @@ use Filament\Tables\Columns\IconColumn; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; +use Illuminate\Database\Eloquent\Model; use Moox\Company\Models\Company; use Moox\Company\Resources\CompanyResource; @@ -18,7 +19,7 @@ class ChildrenRelationManager extends RelationManager { protected static string $relationship = 'children'; - public static function getTitle(\Illuminate\Database\Eloquent\Model $ownerRecord, string $pageClass): string + public static function getTitle(Model $ownerRecord, string $pageClass): string { return (string) config('company.relations.children.label', __('company::fields.children')); } diff --git a/packages/company/src/Resources/CompanyResource.php b/packages/company/src/Resources/CompanyResource.php index 42b8e23337..9dd5a6308f 100644 --- a/packages/company/src/Resources/CompanyResource.php +++ b/packages/company/src/Resources/CompanyResource.php @@ -9,6 +9,8 @@ use Filament\Forms\Components\Textarea; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; +use Filament\Resources\RelationManagers\RelationGroup; +use Filament\Resources\RelationManagers\RelationManagerConfiguration; use Filament\Schemas\Components\Grid; use Filament\Schemas\Components\Section; use Filament\Schemas\Schema; @@ -318,7 +320,7 @@ protected static function configOptions(string $configKey): array } /** - * @return array + * @return array */ protected static function getDeclaredRelations(): array { diff --git a/packages/company/tests/Feature/FilamentCompanyTest.php b/packages/company/tests/Feature/FilamentCompanyTest.php index 9dde66f445..2e9c9471e2 100644 --- a/packages/company/tests/Feature/FilamentCompanyTest.php +++ b/packages/company/tests/Feature/FilamentCompanyTest.php @@ -2,17 +2,16 @@ declare(strict_types=1); +use Illuminate\Support\Facades\Session; use Moox\Company\Models\Company; use Moox\Company\Resources\Company\Pages\CreateCompany; use Moox\Company\Resources\Company\Pages\EditCompany; use Moox\Company\Resources\Company\Pages\ListCompanies; use Moox\DevTools\Models\TestUser; -use Illuminate\Support\Facades\Session; use function Pest\Livewire\livewire; beforeEach(function (): void { - Session::start(); $this->actingAs(TestUser::query()->create([ 'name' => 'Test User', @@ -75,5 +74,4 @@ ->assertHasNoFormErrors(); expect($company->fresh()->display_name)->toBe('New Name'); - }); diff --git a/packages/company/tests/TestCase.php b/packages/company/tests/TestCase.php index a3c14c9853..faf91effac 100644 --- a/packages/company/tests/TestCase.php +++ b/packages/company/tests/TestCase.php @@ -4,31 +4,57 @@ namespace Moox\Company\Tests; +use BladeUI\Heroicons\BladeHeroiconsServiceProvider; +use BladeUI\Icons\BladeIconsServiceProvider; +use Codeat3\BladeGoogleMaterialDesignIcons\BladeGoogleMaterialDesignIconsServiceProvider; +use Filament\Actions\ActionsServiceProvider; use Filament\Facades\Filament; +use Filament\FilamentServiceProvider; +use Filament\Forms\FormsServiceProvider; use Filament\Http\Middleware\Authenticate; use Filament\Http\Middleware\AuthenticateSession; use Filament\Http\Middleware\DisableBladeIconComponents; use Filament\Http\Middleware\DispatchServingFilamentEvent; +use Filament\Infolists\InfolistsServiceProvider; +use Filament\Notifications\NotificationsServiceProvider; use Filament\Pages\Dashboard; use Filament\Panel; +use Filament\Schemas\SchemasServiceProvider; use Filament\Support\Colors\Color; +use Filament\Support\SupportServiceProvider; +use Filament\Tables\TablesServiceProvider; +use Filament\Widgets\WidgetsServiceProvider; +use Illuminate\Auth\AuthServiceProvider; +use Illuminate\Cookie\CookieServiceProvider; use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse; use Illuminate\Cookie\Middleware\EncryptCookies; +use Illuminate\Database\DatabaseServiceProvider; use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; +use Illuminate\Encryption\EncryptionServiceProvider; +use Illuminate\Filesystem\FilesystemServiceProvider; use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Pagination\PaginationServiceProvider; use Illuminate\Routing\Middleware\SubstituteBindings; use Illuminate\Session\Middleware\StartSession; +use Illuminate\Session\SessionServiceProvider; use Illuminate\Support\Facades\Schema; use Illuminate\Support\MessageBag; use Illuminate\Support\ViewErrorBag; +use Illuminate\Translation\TranslationServiceProvider; +use Illuminate\Validation\ValidationServiceProvider; use Illuminate\View\Middleware\ShareErrorsFromSession; +use Illuminate\View\ViewServiceProvider; +use Livewire\LivewireServiceProvider; +use Moox\Company\CompanyServiceProvider; use Moox\Company\Plugins\CompanyPlugin; +use Moox\Core\CoreServiceProvider; use Moox\DevTools\Models\TestUser; use Orchestra\Testbench\Attributes\WithMigration; use Orchestra\Testbench\TestCase as Orchestra; use Pest\Livewire\InteractsWithLivewire; +use RyanChandler\BladeCaptureDirective\BladeCaptureDirectiveServiceProvider; #[WithMigration('laravel', 'cache', 'queue')] #[WithMigration('session')] @@ -115,34 +141,33 @@ protected function setUpFilamentPanel(): void protected function getPackageProviders($app): array { return [ - \BladeUI\Icons\BladeIconsServiceProvider::class, - \BladeUI\Heroicons\BladeHeroiconsServiceProvider::class, - \Filament\Actions\ActionsServiceProvider::class, - \Codeat3\BladeGoogleMaterialDesignIcons\BladeGoogleMaterialDesignIconsServiceProvider::class, - \Filament\FilamentServiceProvider::class, - \Filament\Forms\FormsServiceProvider::class, - \Filament\Infolists\InfolistsServiceProvider::class, - \Filament\Notifications\NotificationsServiceProvider::class, - \Filament\Schemas\SchemasServiceProvider::class, - \Filament\Support\SupportServiceProvider::class, - \Filament\Tables\TablesServiceProvider::class, - \Filament\Widgets\WidgetsServiceProvider::class, - \Illuminate\Auth\AuthServiceProvider::class, - \Illuminate\Cookie\CookieServiceProvider::class, - \Illuminate\Database\DatabaseServiceProvider::class, - \Illuminate\Encryption\EncryptionServiceProvider::class, - \Illuminate\Filesystem\FilesystemServiceProvider::class, - \Illuminate\Pagination\PaginationServiceProvider::class, - \Illuminate\Session\SessionServiceProvider::class, - \Illuminate\Translation\TranslationServiceProvider::class, - \Illuminate\Validation\ValidationServiceProvider::class, - \Illuminate\View\ViewServiceProvider::class, - \Livewire\LivewireServiceProvider::class, - \Moox\Company\CompanyServiceProvider::class, - \Moox\Core\CoreServiceProvider::class, - \RyanChandler\BladeCaptureDirective\BladeCaptureDirectiveServiceProvider::class, - - + BladeIconsServiceProvider::class, + BladeHeroiconsServiceProvider::class, + ActionsServiceProvider::class, + BladeGoogleMaterialDesignIconsServiceProvider::class, + FilamentServiceProvider::class, + FormsServiceProvider::class, + InfolistsServiceProvider::class, + NotificationsServiceProvider::class, + SchemasServiceProvider::class, + SupportServiceProvider::class, + TablesServiceProvider::class, + WidgetsServiceProvider::class, + AuthServiceProvider::class, + CookieServiceProvider::class, + DatabaseServiceProvider::class, + EncryptionServiceProvider::class, + FilesystemServiceProvider::class, + PaginationServiceProvider::class, + SessionServiceProvider::class, + TranslationServiceProvider::class, + ValidationServiceProvider::class, + ViewServiceProvider::class, + LivewireServiceProvider::class, + CompanyServiceProvider::class, + CoreServiceProvider::class, + BladeCaptureDirectiveServiceProvider::class, + ]; } @@ -181,7 +206,6 @@ public function up(): void } })->up(); } - } protected function loadPackageMigrations(): void diff --git a/packages/company/tests/bootstrap.php b/packages/company/tests/bootstrap.php index fa65b019bf..1584b826a4 100644 --- a/packages/company/tests/bootstrap.php +++ b/packages/company/tests/bootstrap.php @@ -1,8 +1,9 @@ morphRelationConfigKey; $relationship = $this->morphPivotService()->getRelationshipMethodName($key); @@ -182,7 +182,7 @@ public function getDefaultActionUrl(Action $action): ?string protected function makeTable(): Table { $table = $this->makeBaseTable() - ->relationship(fn (): Relation | Builder => $this->getRelationship()) + ->relationship(fn (): Relation|Builder => $this->getRelationship()) ->modifyQueryUsing($this->modifyQueryWithActiveTab(...)) ->queryStringIdentifier(Str::lcfirst(class_basename(static::class))); diff --git a/packages/core/src/Services/MorphPivotRelationService.php b/packages/core/src/Services/MorphPivotRelationService.php index 322c81d7d4..7d2dc12828 100644 --- a/packages/core/src/Services/MorphPivotRelationService.php +++ b/packages/core/src/Services/MorphPivotRelationService.php @@ -220,9 +220,6 @@ public function getPrimaryColumn(string $relation): string return (string) ($this->getPrimaryConfig($relation)['column'] ?? 'id'); } - /** - * @return mixed - */ public function getPrimaryValue(string $relation, mixed $default = true): mixed { return $this->getPrimaryConfig($relation)['value'] ?? $default; From 471548ebd55417989ac75aeba7c6d22f2725984b Mon Sep 17 00:00:00 2001 From: Kim Speer <93331309+Kim-the-Diamond@users.noreply.github.com> Date: Tue, 26 May 2026 17:37:56 +0200 Subject: [PATCH 5/8] fix some stan errors --- packages/address/config/address.php | 4 ---- .../database/Factories/AddressFactory.php | 4 ++-- .../address/src/Frontend/AddressFrontend.php | 10 +++++----- packages/address/src/Models/Address.php | 16 +++++++++++++++- packages/address/src/Models/Addressable.php | 5 +++++ .../HandlesDuplicateAddressValidation.php | 19 ------------------- .../Resources/Address/Pages/CreateAddress.php | 11 +++++++++++ .../Resources/Address/Pages/EditAddress.php | 11 +++++++++++ .../AddressablesRelationManager.php | 2 +- 9 files changed, 50 insertions(+), 32 deletions(-) diff --git a/packages/address/config/address.php b/packages/address/config/address.php index 9aa6147f71..680e026906 100644 --- a/packages/address/config/address.php +++ b/packages/address/config/address.php @@ -4,7 +4,6 @@ use Moox\Address\Models\Addressable; use Moox\Category\Resources\CategoryResource; use Moox\Media\Resources\MediaResource; -use Moox\News\Moox\Entities\News\News\NewsResource; use Moox\Tag\Resources\TagResource; use Moox\User\Models\User; use Moox\User\Resources\UserResource; @@ -78,9 +77,6 @@ 'scopes' => [ 'allowed' => [ - 'news' => [ - 'resource' => NewsResource::class, - ], 'media' => [ 'resource' => MediaResource::class, ], diff --git a/packages/address/database/Factories/AddressFactory.php b/packages/address/database/Factories/AddressFactory.php index ffde9a26bd..0bd645ec51 100644 --- a/packages/address/database/Factories/AddressFactory.php +++ b/packages/address/database/Factories/AddressFactory.php @@ -28,10 +28,10 @@ public function definition(): array ]), 'name' => fake()->company(), 'street' => fake()->streetName().' '.fake()->buildingNumber(), - 'street2' => fake()->optional(0.15)->secondaryAddress(), + 'street2' => fake()->optional(0.15)->streetAddress(), 'postal_code' => fake()->postcode(), 'city' => fake()->city(), - 'state' => fake()->optional(0.4)->state(), + 'state' => fake()->optional(0.4)->randomElement(['BE', 'BY', 'HH', 'NW', 'HE']), 'country_code' => fake()->countryCode(), 'is_primary' => false, 'data' => null, diff --git a/packages/address/src/Frontend/AddressFrontend.php b/packages/address/src/Frontend/AddressFrontend.php index 5d797ce3e2..1ad7e9e4cd 100644 --- a/packages/address/src/Frontend/AddressFrontend.php +++ b/packages/address/src/Frontend/AddressFrontend.php @@ -1,18 +1,18 @@ |null $data + */ class Address extends BaseItemModel { /** @use HasFactory */ @@ -79,7 +91,7 @@ protected static function booted(): void public function findDuplicate(): ?self { - $query = static::query()->withFingerprint(AddressFingerprint::fromAddress($this)); + $query = static::withFingerprint(AddressFingerprint::fromAddress($this)); if ($this->exists) { $query->whereKeyNot($this->getKey()); @@ -89,7 +101,9 @@ public function findDuplicate(): ?self } /** + * @param Builder
$query * @param array $fingerprint + * @return Builder
*/ public function scopeWithFingerprint(Builder $query, array $fingerprint): Builder { diff --git a/packages/address/src/Models/Addressable.php b/packages/address/src/Models/Addressable.php index 0983d91cd2..d620206589 100644 --- a/packages/address/src/Models/Addressable.php +++ b/packages/address/src/Models/Addressable.php @@ -9,6 +9,11 @@ use Illuminate\Database\Eloquent\Relations\MorphPivot; use Illuminate\Database\Eloquent\Relations\MorphTo; +/** + * @property bool $billing_address + * @property bool $postal_address + * @property bool $delivery_address + */ class Addressable extends MorphPivot { protected $table = 'addressables'; diff --git a/packages/address/src/Resources/Address/Pages/Concerns/HandlesDuplicateAddressValidation.php b/packages/address/src/Resources/Address/Pages/Concerns/HandlesDuplicateAddressValidation.php index 4920fd025a..2ffe2d61fe 100644 --- a/packages/address/src/Resources/Address/Pages/Concerns/HandlesDuplicateAddressValidation.php +++ b/packages/address/src/Resources/Address/Pages/Concerns/HandlesDuplicateAddressValidation.php @@ -4,30 +4,11 @@ namespace Moox\Address\Resources\Address\Pages\Concerns; -use Illuminate\Database\Eloquent\Model; use Illuminate\Validation\ValidationException; use Moox\Address\Exceptions\DuplicateAddressException; trait HandlesDuplicateAddressValidation { - protected function handleRecordCreation(array $data): Model - { - try { - return parent::handleRecordCreation($data); - } catch (DuplicateAddressException $exception) { - throw $this->duplicateAddressExceptionForForm($exception); - } - } - - protected function handleRecordUpdate(Model $record, array $data): Model - { - try { - return parent::handleRecordUpdate($record, $data); - } catch (DuplicateAddressException $exception) { - throw $this->duplicateAddressExceptionForForm($exception); - } - } - protected function duplicateAddressExceptionForForm(DuplicateAddressException $exception): ValidationException { $statePath = $this->form->getStatePath(); diff --git a/packages/address/src/Resources/Address/Pages/CreateAddress.php b/packages/address/src/Resources/Address/Pages/CreateAddress.php index ebc9394a40..4a42e888f0 100644 --- a/packages/address/src/Resources/Address/Pages/CreateAddress.php +++ b/packages/address/src/Resources/Address/Pages/CreateAddress.php @@ -4,6 +4,8 @@ namespace Moox\Address\Resources\Address\Pages; +use Illuminate\Database\Eloquent\Model; +use Moox\Address\Exceptions\DuplicateAddressException; use Moox\Address\Resources\Address\Pages\Concerns\HandlesDuplicateAddressValidation; use Moox\Address\Resources\Address\Pages\Concerns\InitializesValidationBag; use Moox\Address\Resources\AddressResource; @@ -15,4 +17,13 @@ class CreateAddress extends BaseCreateRecord use InitializesValidationBag; protected static string $resource = AddressResource::class; + + protected function handleRecordCreation(array $data): Model + { + try { + return parent::handleRecordCreation($data); + } catch (DuplicateAddressException $exception) { + throw $this->duplicateAddressExceptionForForm($exception); + } + } } diff --git a/packages/address/src/Resources/Address/Pages/EditAddress.php b/packages/address/src/Resources/Address/Pages/EditAddress.php index 89ffcf4ef7..788df9556f 100644 --- a/packages/address/src/Resources/Address/Pages/EditAddress.php +++ b/packages/address/src/Resources/Address/Pages/EditAddress.php @@ -4,6 +4,8 @@ namespace Moox\Address\Resources\Address\Pages; +use Illuminate\Database\Eloquent\Model; +use Moox\Address\Exceptions\DuplicateAddressException; use Moox\Address\Resources\Address\Pages\Concerns\HandlesDuplicateAddressValidation; use Moox\Address\Resources\Address\Pages\Concerns\InitializesValidationBag; use Moox\Address\Resources\AddressResource; @@ -15,4 +17,13 @@ class EditAddress extends BaseEditRecord use InitializesValidationBag; protected static string $resource = AddressResource::class; + + protected function handleRecordUpdate(Model $record, array $data): Model + { + try { + return parent::handleRecordUpdate($record, $data); + } catch (DuplicateAddressException $exception) { + throw $this->duplicateAddressExceptionForForm($exception); + } + } } diff --git a/packages/address/src/Resources/Address/RelationManagers/AddressablesRelationManager.php b/packages/address/src/Resources/Address/RelationManagers/AddressablesRelationManager.php index 50cefe02ed..bf3dfbd8fe 100644 --- a/packages/address/src/Resources/Address/RelationManagers/AddressablesRelationManager.php +++ b/packages/address/src/Resources/Address/RelationManagers/AddressablesRelationManager.php @@ -45,7 +45,7 @@ public function form(Schema $schema): Schema ->label(__('address::fields.owner')) ->types( collect($ownerTypes) - ->map(fn (string $label, string $class): Type => Type::make($class)->title($label)) + ->map(fn (string $label, string $class): Type => Type::make($class)->label($label)) ->values() ->all() ) From f6fc4324fefc570831afd1998ecb64a891490ecd Mon Sep 17 00:00:00 2001 From: Kim Speer <93331309+Kim-the-Diamond@users.noreply.github.com> Date: Wed, 27 May 2026 09:44:35 +0200 Subject: [PATCH 6/8] wip stan errors --- .../address/src/Concerns/HasAddresses.php | 35 ------------------- .../address/src/Frontend/AddressFrontend.php | 18 ---------- packages/address/src/Models/Address.php | 9 +++-- .../address/src/Resources/AddressResource.php | 4 +-- .../company/src/Frontend/CompanyFrontend.php | 20 ----------- packages/company/src/Models/Company.php | 2 +- .../company/src/Resources/CompanyResource.php | 4 ++- 7 files changed, 11 insertions(+), 81 deletions(-) delete mode 100644 packages/address/src/Concerns/HasAddresses.php delete mode 100644 packages/address/src/Frontend/AddressFrontend.php delete mode 100644 packages/company/src/Frontend/CompanyFrontend.php diff --git a/packages/address/src/Concerns/HasAddresses.php b/packages/address/src/Concerns/HasAddresses.php deleted file mode 100644 index ad32de8757..0000000000 --- a/packages/address/src/Concerns/HasAddresses.php +++ /dev/null @@ -1,35 +0,0 @@ - - */ - public function addresses(): MorphToMany - { - $morphName = (string) (AddressRelationConfig::addressables()['morph_name'] ?? 'addressable'); - $pivotTable = AddressRelationConfig::pivotTable(); - - return $this->morphToMany( - Address::class, - $morphName, - $pivotTable, - "{$morphName}_id", - 'address_id', - 'id', - 'id', - ) - ->using(Addressable::class) - ->withPivot(array_keys(AddressRelationConfig::pivotColumns())) - ->withTimestamps(); - } -} diff --git a/packages/address/src/Frontend/AddressFrontend.php b/packages/address/src/Frontend/AddressFrontend.php deleted file mode 100644 index 1ad7e9e4cd..0000000000 --- a/packages/address/src/Frontend/AddressFrontend.php +++ /dev/null @@ -1,18 +0,0 @@ -scopeWithFingerprint( + static::query(), + AddressFingerprint::fromAddress($this), + ); if ($this->exists) { $query->whereKeyNot($this->getKey()); @@ -101,9 +104,9 @@ public function findDuplicate(): ?self } /** - * @param Builder
$query + * @param Builder $query * @param array $fingerprint - * @return Builder
+ * @return Builder */ public function scopeWithFingerprint(Builder $query, array $fingerprint): Builder { diff --git a/packages/address/src/Resources/AddressResource.php b/packages/address/src/Resources/AddressResource.php index 1779f0b3f6..666db18785 100644 --- a/packages/address/src/Resources/AddressResource.php +++ b/packages/address/src/Resources/AddressResource.php @@ -14,7 +14,6 @@ use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Filters\TernaryFilter; use Filament\Tables\Table; -use Illuminate\Database\Eloquent\Model; use Moox\Address\Models\Address; use Moox\Address\Resources\Address\Pages\CreateAddress; use Moox\Address\Resources\Address\Pages\EditAddress; @@ -239,7 +238,6 @@ protected static function getDistinctFilterOptions(string $column): array protected static function getCountryFilterOptions(): array { if (class_exists(StaticCountry::class)) { - /** @var class-string $model */ $model = StaticCountry::class; return $model::query() @@ -247,7 +245,7 @@ protected static function getCountryFilterOptions(): array ->orderBy('common_name') ->get(['alpha2', 'common_name']) ->mapWithKeys(fn ($country): array => [ - strtoupper((string) $country->alpha2) => sprintf('%s %s', $country->name, strtoupper((string) $country->alpha2)), + strtoupper((string) $country->alpha2) => sprintf('%s %s', $country->common_name, strtoupper((string) $country->alpha2)), ]) ->all(); diff --git a/packages/company/src/Frontend/CompanyFrontend.php b/packages/company/src/Frontend/CompanyFrontend.php deleted file mode 100644 index 45fd9a5c78..0000000000 --- a/packages/company/src/Frontend/CompanyFrontend.php +++ /dev/null @@ -1,20 +0,0 @@ -parent_id = null; } - if ($company->default_currency_code !== null) { + if ($company->default_currency_code !== '') { $company->default_currency_code = strtoupper(trim($company->default_currency_code)); } }); diff --git a/packages/company/src/Resources/CompanyResource.php b/packages/company/src/Resources/CompanyResource.php index 9dd5a6308f..4f74d5f787 100644 --- a/packages/company/src/Resources/CompanyResource.php +++ b/packages/company/src/Resources/CompanyResource.php @@ -235,12 +235,13 @@ public static function table(Table $table): Table ->label(__('company::fields.company_type')) ->badge() ->color( - fn (string $state): string => match ($state) { + fn (?string $state): string => match ($state) { 'customer' => 'success', 'supplier' => 'warning', 'partner' => 'primary', 'prospect' => 'warning', 'internal' => 'gray', + default => 'gray', } ) ->sortable(), @@ -254,6 +255,7 @@ public static function table(Table $table): Table 'inactive' => 'warning', 'approved' => 'success', 'archived' => 'danger', + default => 'gray', } ) ->sortable(), From d9a2a86ce2a21608fce802f3f247a878ab5b3c31 Mon Sep 17 00:00:00 2001 From: Kim Speer <93331309+Kim-the-Diamond@users.noreply.github.com> Date: Wed, 27 May 2026 10:02:26 +0200 Subject: [PATCH 7/8] Update README.md --- packages/company/README.md | 321 ++++++++++++++++++++++++++++++++++++- 1 file changed, 313 insertions(+), 8 deletions(-) diff --git a/packages/company/README.md b/packages/company/README.md index febcd196d9..8e24e7cf42 100644 --- a/packages/company/README.md +++ b/packages/company/README.md @@ -2,15 +2,25 @@ # Moox Company -ERP-style company entity (customers, suppliers, subsidiaries). No payment fields, no `employee_id`, no default-address foreign keys — addresses and commercial terms use pivots (`addressables`, `employee_assignments`, `commercial_term_assignments`). +ERP entity for company master data: customers, suppliers, partners, subsidiaries, and internal organizational units. The package follows the Moox greenfield approach: **no payment fields**, **no `employee_id`**, **no default-address foreign keys** — addresses, employee assignments, and commercial terms are linked via morph pivots (`addressables`, `employee_assignments`, `commercial_term_assignments`). ## Features -- UUID primary key, soft deletes, JSON `data` -- Parent / subsidiary hierarchy (`parent_id`) -- Config-driven statuses, company types, Filament tabs, scopes -- `address` / `addresses` relations via config + `moox/address` pivot config -- Filament resource, factory, Pest tests +- UUID primary key, soft deletes, flexible JSON `data` field +- Hierarchy via `parent_id` (parent company / subsidiary) +- Config-driven statuses, company types, Filament tabs, and scopes +- Addresses via `moox/address` and the `addressables` pivot (billing, postal, delivery roles) +- Filament resource with list tabs, filters, and a relation manager for subsidiaries +- Factory with states (`customer`, `supplier`, `withParent`, …) and Pest tests + +## Requirements + +| Package | Purpose | +|------------|-----------------------------------------------| +| `moox/core` | Base model, Filament resource, morph pivots | +| `moox/data` | Moox data integration | + +For addresses in the admin and API you also need **`moox/address`**, wired in your app config (see [Addresses](#addresses-mooxaddress)). ## Installation @@ -19,13 +29,308 @@ composer require moox/company php artisan moox:install ``` -## Factory +The migration creates the `companies` table. Optionally publish configuration: + +```bash +php artisan vendor:publish --tag=company-config +``` + +## Registering with Filament + +The package registers via `CompanyPlugin`. In your panel provider (e.g. `MooxPanelProvider`): + +```php +use Moox\Company\Plugins\CompanyPlugin; + +$panel->plugins([ + CompanyPlugin::make(), +]); +``` + +`CompanyPlugin` uses `ChildResourceRegistrar` to register the resource together with tabs, scopes, and morph relations from `config('company.resources.company')`. + +## The Company Model + +Class: `Moox\Company\Models\Company` +Extends `Moox\Core\Entities\Items\Item\BaseItemModel`. + +### Traits + +| Trait | Purpose | +|-------|---------| +| `HasUuids` | String UUID as `id` | +| `SoftDeletes` | Soft delete | +| `HasModelTaxonomy` | Taxonomies from `config('company.taxonomies')` | +| `HasMorphPivotRelations` | Dynamic morph-pivot relations (e.g. addresses) | + +### Fields + +#### Identity & type + +| Field | Type | Description | +|-------|------|-------------| +| `status` | string(30) | e.g. `draft`, `active`, `inactive`, `approved`, `archived` (from config) | +| `name` | string(120) | Short / internal name | +| `display_name` | string(120) | Display name in UI and selects | +| `legal_name` | string(120) | Legal name | +| `company_type` | string(30) | `customer`, `supplier`, `partner`, `prospect`, `internal` | +| `note` | text | Free text | +| `search_terms` | text | Additional search terms | +| `external_reference` | string(100) | External ID (ERP, CRM, …) | + +#### Hierarchy + +| Field | Type | Description | +|-------|------|-------------| +| `parent_id` | uuid (FK) | Parent company; `null` = no parent | +| `is_fully_owned_subsidiary` | boolean | Fully owned by the group | + +On save, `parent_id` equal to the record’s own id is cleared to `null`. + +#### Contact & tax + +| Field | Type | Description | +|-------|------|-------------| +| `phone`, `fax` | string(30) | Phone / fax | +| `email` | string(100) | Email | +| `url` | string(255) | Website | +| `tax_number`, `vat_number` | string(30) | Tax / VAT ID | +| `has_no_vat_number` | boolean | Disables the VAT field in the form | + +#### Settings + +| Field | Type | Description | +|-------|------|-------------| +| `default_currency_code` | char(3) | ISO currency, normalized to uppercase (default: `EUR`) | +| `partner_type`, `partner_id` | int | Optional reference to an external partner system | +| `language_id` | FK | `static_languages` | +| `localization_id` | FK | `localizations` | +| `sort` | int | Manual sort order | +| `is_active` | boolean | Active flag | +| `approved_at` | datetime | Approval timestamp | +| `approved_by_type`, `approved_by_id` | morph | Who approved the record | +| `no_marketing_action` | boolean | No marketing | +| `no_marketing_action_reason` | string(255) | Reason (visible when marketing is off) | +| `data` | json | Arbitrary extra data | + +Validation rules live in `Moox\Company\Support\CompanyRules` and are used in the Filament resource. + +### Methods + +- **`displayLabel()`** — Display text: `display_name` → `name` → `legal_name` → UUID +- **`getResourceName()`** — Slug `company` for Moox Core + +### Eloquent relationships + +```php +$company->parent; // BelongsTo +$company->children; // HasMany +$company->approvedBy; // MorphTo + +$company->addresses(); // MorphToMany via addressables pivot (all) +$company->address(); // “Primary” address(es) per pivot config +``` + +Taxonomy methods are resolved dynamically from `config('company.taxonomies')` (e.g. `$company->tags()`). + +## Usage + +### Creating a company + +```php +use Moox\Company\Models\Company; + +$customer = Company::create([ + 'status' => 'active', + 'name' => 'Acme GmbH', + 'display_name' => 'Acme GmbH', + 'company_type' => 'customer', + 'default_currency_code' => 'EUR', + 'is_active' => true, +]); + +echo $customer->displayLabel(); // "Acme GmbH" +``` + +### Factory ```php Company::factory()->customer()->create(); +Company::factory()->supplier()->draft()->create(); +Company::factory()->inactive()->create(); + +$parent = Company::factory()->create(); Company::factory()->withParent($parent)->create(); ``` +### Corporate hierarchy + +```php +$holding = Company::factory()->create(['company_type' => 'internal']); +$subsidiary = Company::factory()->withParent($holding)->create(); + +$holding->children; // Collection of subsidiaries +$subsidiary->parent; // Holding company +``` + +### Queries + +```php +Company::query() + ->where('company_type', 'supplier') + ->where('is_active', true) + ->get(); + +Company::query()->where('status', 'approved')->get(); +``` + +## Addresses (`moox/address`) + +Addresses are **not** stored as columns on `companies`. They are linked through the `addressables` pivot table: + +| Pivot column | Meaning | +|--------------|---------| +| `billing_address` | Billing address | +| `postal_address` | Postal address | +| `delivery_address` | Delivery address | + +### App configuration + +In **`config/company.php`** (after publish), set `morph_relations.addressables` with model and pivot — for example in this app: + +```php +'morph_relations' => [ + 'addressables' => [ + 'model' => \Moox\Address\Models\Address::class, + 'pivot_model' => \Moox\Address\Models\Addressable::class, + // ... + ], +], +``` + +In **`config/address.php`**, register the company as an allowed owner: + +```php +'owner_types' => [ + \Moox\Company\Models\Company::class => 'Company', +], +``` + +### Assigning an address (Eloquent) + +```php +$company->addresses()->attach($address->id, [ + 'billing_address' => true, + 'postal_address' => true, + 'delivery_address' => false, +]); + +$company->address; // Collection of address(es) marked as primary +``` + +In Filament, address management appears as a **morph-pivot relation manager** (Moox Core) once `moox/address` is installed and configured. + +## Filament admin + +Resource: `Moox\Company\Resources\CompanyResource` + +| Page | Route (relative) | Description | +|------|------------------|-------------| +| List | `/` | Table with tabs, filters, bulk actions | +| Create | `/create` | Form; `?parent_id=` pre-fills the parent company | +| Edit | `/{record}/edit` | Master data | +| View | `/{record}` | Read-only view | + +### List tabs + +Tabs come from `config('company.resources.company.tabs')`. Typical app defaults include: + +- **All** — not soft-deleted +- **Suppliers** / **Customers** — filter on `company_type` +- **Active** — `is_active = true` +- **Deleted** — soft-deleted records + +### Subsidiaries + +`ChildrenRelationManager` lists `children` of the current company. “Create” opens the create page with `parent_id` pre-filled: + +```php +CompanyResource::getUrl('create', ['parent_id' => $company->getKey()]); +``` + +### Scopes (cross-entity) + +Under `resources.company.scopes` you can attach other Moox resources (News, Media, Tags, User, …) to companies — see your published `config/company.php`. + ## Configuration -Publish and edit `config/company.php` for statuses, company types, navigation group, tabs, and relation labels. +File: `config/company.php` + +| Key | Description | +|-----|-------------| +| `readonly` | Make the resource read-only | +| `statuses` | Allowed status values (validation + selects) | +| `company_types` | Allowed company types | +| `default_currency_code` | Default in the form | +| `resources.company` | Labels, tabs, scopes | +| `relations` | Labels for parent / children | +| `morph_relations` | Pivot definitions (addresses, …) | +| `taxonomies` | Taxonomy integration | +| `navigation_group` | Filament navigation group (e.g. `Portal`) | + +Changes to `statuses` or `company_types` are picked up automatically in `CompanyRules` and Filament selects/filters. + +## Architecture overview + +```mermaid +flowchart TB + subgraph admin [Filament Admin] + CR[CompanyResource] + CRM[ChildrenRelationManager] + MPR[Morph Pivot: Addresses] + end + + subgraph data [Database] + C[(companies)] + A[(addressables)] + AD[(addresses)] + end + + CR --> C + CRM --> C + MPR --> A + C -->|parent_id| C + C -->|morph| A + A --> AD +``` + +**Intentionally not in this package:** payment data, fixed `employee_id`, `default_billing_address_id`, etc. That keeps the master-data model lean and avoids legacy one-to-one foreign keys. + +## Running tests + +From the package directory: + +```bash +composer test +``` + +Or from the monorepo root: + +```bash +php vendor/bin/pest --configuration=packages/company/phpunit.xml packages/company/tests +``` + +## Translations + +- Entity titles: `resources/lang/{locale}/company.php` +- Field labels: `resources/lang/{locale}/fields.php` (DE and EN included) + +## See also + +- [Moox Address](../address/README.md) — address model and `addressables` pivot +- [Moox documentation](https://moox.org/docs/company) +- [Moox installation](https://github.com/mooxphp/moox/blob/main/docs/Installation.md) + +## License + +MIT — see [Moox License](https://github.com/mooxphp/moox/blob/main/LICENSE.md). From f971769fdb83361ca633a2b538d80152e7d8646e Mon Sep 17 00:00:00 2001 From: Kim Speer <93331309+Kim-the-Diamond@users.noreply.github.com> Date: Wed, 27 May 2026 11:43:53 +0200 Subject: [PATCH 8/8] Update README.md --- packages/company/README.md | 25 ------------------------- 1 file changed, 25 deletions(-) diff --git a/packages/company/README.md b/packages/company/README.md index 8e24e7cf42..0d9861d5c7 100644 --- a/packages/company/README.md +++ b/packages/company/README.md @@ -280,31 +280,6 @@ File: `config/company.php` Changes to `statuses` or `company_types` are picked up automatically in `CompanyRules` and Filament selects/filters. -## Architecture overview - -```mermaid -flowchart TB - subgraph admin [Filament Admin] - CR[CompanyResource] - CRM[ChildrenRelationManager] - MPR[Morph Pivot: Addresses] - end - - subgraph data [Database] - C[(companies)] - A[(addressables)] - AD[(addresses)] - end - - CR --> C - CRM --> C - MPR --> A - C -->|parent_id| C - C -->|morph| A - A --> AD -``` - -**Intentionally not in this package:** payment data, fixed `employee_id`, `default_billing_address_id`, etc. That keeps the master-data model lean and avoids legacy one-to-one foreign keys. ## Running tests