schema([ Forms\Components\Group::make() ->schema([ Forms\Components\Section::make() ->schema([ Forms\Components\TextInput::make('name') ->required() ->maxLength(255) ->live(onBlur: true) ->afterStateUpdated(fn(Get $get, Set $set) => self::updateSKUs($get, $set)), Forms\Components\Select::make('product_type_id') ->label('Product Type: ') ->default(ProductType::where('name', 'Simple')->first()->id) ->selectablePlaceholder(false) ->relationship('type','name') ->preload() ->live() ->afterStateUpdated(function ($state, Set $set, Get $get) { $attributeId = $get('attribute_id'); $productTypeName = ProductType::find($state)?->name; if ($productTypeName === 'Simple') { $defaultItems = [['sku' => null, 'price' => null, 'attributeValues' => null, 'min_portions' => null, 'max_portions' => null]]; $set('skus', $defaultItems); } elseif ($productTypeName === 'Bulk') { $defaultItems = [['sku' => null, 'price' => null, 'attributeValues' => null, 'min_portions' => null, 'max_portions' => null, 'bulkPricingLevels' => [['pricing_level' => '', 'min_portions' => null, 'max_portions' => null, 'price' => null, 'hours_in_advance' => null]]]]; $set('skus', $defaultItems); } elseif ($productTypeName === 'Variable') { if (!$attributeId) { $onlyAttribute = Attribute::first(); if (Attribute::count() === 1) { $attributeId = $onlyAttribute->id; $set('attribute_id', $attributeId); } } if ($attributeId) { $attributeValueIds = AttributeValue::where('attribute_id', $attributeId)->pluck('id')->toArray(); $defaultItems = collect($attributeValueIds)->map(function ($id) { return ['sku' => null, 'price' => null, 'attributeValues' => $id, 'min_portions' => null, 'max_portions' => null]; })->toArray(); $set('skus', $defaultItems); } } self::updateSKUs($get, $set); }) ->required(), Forms\Components\Select::make('attribute_id') ->label('Attribute') ->selectablePlaceholder(fn () => Attribute::count() > 1) // Pre-select the only available attribute if there is only one. ->relationship('attributes','name') ->preload() ->live() ->afterStateUpdated(function (Set $set, $state, Get $get) { $productTypeName = ProductType::find($get('product_type_id'))?->name; if ($productTypeName === 'Variable' && $state) { $attributeValueIds = AttributeValue::where('attribute_id', $state)->pluck('id'); $defaultItems = collect($attributeValueIds)->map(function ($id) { return ['sku' => null, 'price' => null, 'attributeValues' => $id]; })->toArray(); $set('skus', $defaultItems); } else { $defaultItems = [['sku' => null, 'price' => null, 'attributeValues' => null]]; $set('skus', $defaultItems); } }) ->required() ->hidden(fn (Get $get) => ProductType::find($get('product_type_id'))?->name != 'Variable'), Forms\Components\Section::make(fn (Get $get) => ProductType::find($get('product_type_id'))?->name == 'Variable' ? 'Variations' : '') ->schema([ Forms\Components\Repeater::make('skus') ->label(false) ->disabled(fn (Get $get) => ProductType::find($get('product_type_id'))?->name == 'Variable' && !$get('attribute_id')) ->relationship() ->addable(false) ->deletable(false) ->schema([ Forms\Components\TextInput::make('sku') ->label('SKU') ->required() ->unique(ignoreRecord: true) ->disabled() ->dehydrated() ->live(), Forms\Components\Hidden::make('price'), Forms\Components\TextInput::make('price') ->label(fn (Get $get) => ProductType::find($get('../../product_type_id'))?->name != 'Bulk' ? 'Price' : 'Price per portion') ->numeric() ->hidden(fn (Get $get) => ProductType::find($get('../../product_type_id'))?->name === 'Bulk') ->disabled(fn (Get $get) => ProductType::find($get('../../product_type_id'))?->name === 'Bulk') ->dehydrated() ->rules(['regex:/^\d{1,6}(\.\d{0,2})?$/']) ->required(), TableRepeater::make('bulkPricingLevels') ->headers([ Header::make('pricing_level') ->markAsRequired() ->label('Pricing level'), Header::make('min_portions') ->markAsRequired() ->label('Min portions'), Header::make('max_portions') ->markAsRequired() ->label('Max portions'), Header::make('price') ->markAsRequired() ->label('Price'), Header::make('hours_in_advance') ->markAsRequired() ->label('Hours in advance'), ]) ->label('Bulk Pricing Levels') ->defaultItems(4) ->hidden(fn (Get $get) => ProductType::find($get('../../product_type_id'))?->name != 'Bulk') ->relationship() ->addActionLabel('Add bulk pricing level') ->schema([ Forms\Components\TextInput::make('pricing_level') ->label('Pricing Level') ->disabled() ->dehydrated() ->default(''), /* Forms\Components\Hidden::make('pricing_level'), */ Forms\Components\TextInput::make('min_portions') ->label('Min Portions') ->integer() ->required() ->live(onBlur: true) ->afterStateUpdated(function (Get $get, Set $set, $state) { $minPortions = $state; $maxPortions = $get('max_portions'); //\Log::info("updating min portions", ['minPortions' => $minPortions, 'maxPortions' => $maxPortions]); if ($minPortions && $maxPortions) { $set('pricing_level', "{$minPortions}-{$maxPortions} portions"); } else { $set('pricing_level', ''); } }), Forms\Components\TextInput::make('max_portions') ->label('Max Portions') ->integer() ->required() ->live(onBlur: true) ->afterStateUpdated(function (Get $get, Set $set, $state) { $minPortions = $get('min_portions'); $maxPortions = $state; //\Log::info("updating max portions", ['minPortions' => $minPortions, 'maxPortions' => $maxPortions]); if ($minPortions && $maxPortions) { $set('pricing_level', "{$minPortions}-{$maxPortions} portions"); } else { $set('pricing_level', ''); } }), Forms\Components\TextInput::make('price') ->label('Price per Portion') ->numeric() ->hidden(fn (Get $get) => ProductType::find($get('../../product_type_id'))?->name === 'Bulk') ->required() ->live() ->afterStateUpdated(function (Get $get, Set $set, $state) { $pricingLevels = collect($get('../../bulkPricingLevels')); if ($pricingLevels->isNotEmpty() && $pricingLevels->first()['price'] === $state) { $set('../../price', $state); } }), Forms\Components\TextInput::make('hours_in_advance') ->label('Hours in Advance') ->integer() ->required(), ]), Forms\Components\Select::make('attributeValues') ->relationship('attributeValues', 'name') ->hidden() ->saveRelationshipsWhenHidden(), Forms\Components\Section::make('Image') ->schema([ SpatieMediaLibraryFileUpload::make('media') ->collection('product-sku-images') ->hiddenLabel(), ]) ->collapsible(), ]) ->columnSpan('full') ->itemLabel(function (array $state): ?string { $attributeValueId = is_array($state['attributeValues']) ? reset($state['attributeValues']) : $state['attributeValues']; $attributeValue = AttributeValue::find($attributeValueId); return $attributeValue ? $attributeValue->name : null; }), ]), Forms\Components\MarkdownEditor::make('description') ->columnSpan('full'), ]) ->columns(3), ]) ->columnSpan(['lg' => 2]), Forms\Components\Group::make() ->schema([ Forms\Components\Section::make('Associations') ->schema([ Forms\Components\Select::make('category_id') ->relationship('category', 'name') ->preload() ->native(false) ->live() ->debounce(600) ->required() ->getOptionLabelFromRecordUsing(function (Model $record) { $fullName = $record->name; $parent = $record->parent; while ($parent) { $fullName = "{$parent->name} > {$fullName}"; $parent = $parent->parent; } return $fullName; }) ->createOptionForm([ Forms\Components\TextInput::make('name') ->required() ->maxLength(255) ->unique(ignoreRecord: true), Forms\Components\Hidden::make('type') ->default('product') ->required(), Forms\Components\Select::make('parent_id') ->label('Parent Category') ->relationship('parent', 'name') ->nullable() ->createOptionForm([ Forms\Components\TextInput::make('name') ->required() ->maxLength(255) ->unique(ignoreRecord: true), Forms\Components\Hidden::make('type') ->default('product') ->required(), ]) ->createOptionAction(function (Action $action) { return $action ->modalHeading('Create parent category') ->modalSubmitActionLabel('Create parent category') ->modalWidth('lg'); }), ]) ->editOptionForm([ Forms\Components\TextInput::make('name') ->required() ->maxLength(255) ->unique(ignoreRecord: true), Forms\Components\TextInput::make('type') ->default('product') ->required() ->maxLength(255), Forms\Components\Select::make('parent_id') ->label('Parent Category') ->relationship('parent', 'name') ->nullable(), ]) ->createOptionAction(function (Action $action) { return $action ->modalHeading('Create category') ->modalSubmitActionLabel('Create category') ->modalWidth('lg'); }) ->afterStateUpdated(fn(Get $get, Set $set) => self::updateSKUs($get, $set)), Forms\Components\Select::make('meal_types') ->relationship( name: 'mealTypes', titleAttribute: 'name', modifyQueryUsing: fn (Builder $query) => $query->orderBy('order_deadline', 'asc'), ) ->multiple() ->required() ->getOptionLabelFromRecordUsing(fn (Model $record) => ucfirst($record->name)) ->preload(), ]), ]) ->columnSpan(['lg' => 1]), ]) ->columns(3); } public static function getAvailablePeriodsQuery(): Builder { $today = Carbon::today(); return AvailabilityPeriod::query() ->where('end_date', '>=', $today) ->orderBy('start_date') ->orderBy('end_date'); } public static function getAvailablePeriods(): array { return self::getAvailablePeriodsQuery() ->get() ->mapWithKeys(function (AvailabilityPeriod $period) { return [$period->getKey() => self::formatAvailabilityPeriodLabel($period)]; }) ->toArray(); } public static function formatAvailabilityPeriodLabel(AvailabilityPeriod $period): string { $formatted = self::formatHumanReadableDateRange($period->start_date, $period->end_date); return $period->name ? "{$period->name} ({$formatted})" : $formatted; } public static function formatHumanReadableDateRange($startDate, $endDate): string { $start = Carbon::parse($startDate); $end = Carbon::parse($endDate); $startDay = $start->isoFormat('Do'); $endDay = $end->isoFormat('Do'); $startMonth = Lang::get("availability.admin.month." . strtolower($start->format('F'))); $endMonth = Lang::get("availability.admin.month." . strtolower($end->format('F'))); if ($startMonth == $endMonth) { return "{$startDay} to {$endDay} of {$startMonth}"; } return "{$startDay} of {$startMonth} to {$endDay} of {$endMonth}"; } public static function table(Table $table): Table { return $table ->columns([ Tables\Columns\SpatieMediaLibraryImageColumn::make('skus.product-sku-image') ->label('Image') ->square() //->circular() /* ->stacked() ->ring(2) */ ->collection('product-sku-images'), Tables\Columns\TextColumn::make('name') ->label('Name') ->searchable() ->sortable(), Tables\Columns\TextColumn::make('skus.price') ->label('Price') ->money('CHF') ->listWithLineBreaks() ->icon(function ($state, $record): ?string { $sku = $record->skus->firstWhere('price', $state); if ($sku && $sku->hasAttributeValue('small')) { return 'tabler-square-rounded-letter-s'; } else if ($sku && $sku->hasAttributeValue('large')) { return 'tabler-square-rounded-letter-l'; } return null; // No icon for other cases }) ->iconColor('primary'), Tables\Columns\TextColumn::make('category.name') ->label('Category') ->formatStateUsing(function ($state, $record) { $category = $record->category; if (!$category) { return '-'; } $fullName = $category->name; $parent = $category->parent; while ($parent) { $fullName = "{$parent->name} > {$fullName}"; $parent = $parent->parent; } return $fullName; }) ->searchable(), Tables\Columns\TextColumn::make('mealTypes.name') ->badge() ->listWithLineBreaks() ->formatStateUsing(fn (string $state): string => ucfirst($state)) ->colors([ 'warning' => 'breakfast', 'success' => 'lunch', 'info' => 'dinner', ]) ->icons([ 'feathericon-sunrise' => 'breakfast', 'feathericon-sun' => 'lunch', 'feathericon-moon' => 'dinner', ]), Tables\Columns\ToggleColumn::make('is_visible') ->label('Is Visible'), Tables\Columns\ToggleColumn::make('is_always_available') ->label('Always Available') ->updateStateUsing(function ($state, $record) { $productUpdater = app(UpdatesProducts::class); $data = [ 'availability' => [ 'is_always_available' => $state, ] ]; \Log::info("Updating product availability with: ", ['data' => $data]); $productUpdater->update($record, $data); return $record->is_always_available; }), ]) ->filters([ QueryBuilder::make() ->constraints([ TextConstraint::make('name'), TextConstraint::make('slug'), TextConstraint::make('sku') ->label('SKU (Stock Keeping Unit)'), TextConstraint::make('barcode') ->label('Barcode (ISBN, UPC, GTIN, etc.)'), TextConstraint::make('description'), NumberConstraint::make('old_price') ->label('Compare at price') ->icon('heroicon-m-currency-dollar'), NumberConstraint::make('price') ->icon('heroicon-m-currency-dollar'), NumberConstraint::make('cost') ->label('Cost per item') ->icon('heroicon-m-currency-dollar'), NumberConstraint::make('security_stock'), BooleanConstraint::make('is_visible') ->label('Visibility'), BooleanConstraint::make('featured'), BooleanConstraint::make('backorder'), BooleanConstraint::make('requires_shipping') ->icon('heroicon-m-truck'), DateConstraint::make('published_at'), ]) ->constraintPickerColumns(2), ], layout: Tables\Enums\FiltersLayout::AboveContentCollapsible) ->deferFilters() ->actions([ Tables\Actions\EditAction::make(), ]) ->groupedBulkActions([ Tables\Actions\DeleteBulkAction::make(), ]); } public static function updateSKUs(Get $get, Set $set): void { /** @var class-string $modelClass */ $modelClass = static::$model; $name = $get('name'); $categoryId = $get('category_id'); $skus = $get('skus'); \Log::info(""); // Check if both name and category_id are set if (empty($name) || empty($categoryId)) { return; // Exit early if either name or category_id is not set } // Debugging statement if (!$categoryId) { throw new \Exception("Category ID is not set."); } foreach ($skus as &$sku) { $attributeValueId = is_array($sku['attributeValues']) ? reset($sku['attributeValues']) : $sku['attributeValues']; $uniqueSku = $modelClass::generateSKU($name, $categoryId, $attributeValueId); $sku['sku'] = $uniqueSku; } $set('skus', $skus); } public static function getRelations(): array { return [ RelationManagers\AvailabilityPeriodsRelationManager::class, ]; } public static function getWidgets(): array { return [ //ProductStats::class, ]; } public static function getPages(): array { return [ 'index' => Pages\ListProducts::route('/'), 'create' => Pages\CreateProduct::route('/create'), 'edit' => Pages\EditProduct::route('/{record}/edit'), ]; } public static function getGloballySearchableAttributes(): array { return ['name']; } public static function getGlobalSearchResultDetails(Model $record): array { /** @var Product $record */ return [ 'Brand' => optional($record->brand)->name, ]; } /** @return Builder */ public static function getGlobalSearchEloquentQuery(): Builder { return parent::getGlobalSearchEloquentQuery()->with(['brand']); } }