Files
Vasyka 3da1f5412a feat: shop UX polish — password reset / order email / multi-image / customer admin
Shop password reset:
- Configured 'shop_customers' password broker on the existing
  password_reset_tokens table
- ShopCustomer::sendPasswordResetNotification overrides Laravel default to
  send a ShopPasswordResetMail with a tenant-subdomain reset URL
- Routes /shop/password/forgot, /shop/password/email, /shop/password/reset/{token}
  + ShopAuthController showForgotPassword/sendResetLink/showResetPassword/
  resetPassword. Forgot view stays generic ("if it exists, we sent…") to avoid
  email enumeration. Login view links to "Am uitat parola".

Order confirmation email:
- ShopOrderConfirmationMail + nicely formatted HTML email template
- ShopOrderNotifier::placed now also emails customer_email (best-effort,
  warning-only logged on failure) alongside existing Telegram + staff push

Multiple images per Part:
- Part media collection switched from singleFile to multiple (max 8 in form)
- imageUrls() helper for galleries; imageUrl() still returns first for cards
- PartResource form: reorderable multi-upload
- Shop part detail: vertical thumbnails switch the main image via vanilla JS

ShopCustomerResource (tenant Filament, "Magazin" nav group):
- List with name/phone/email/client_id/orders_count/last_login_at
- Edit (no password field exposed)
- "Trimite reset parolă" action uses the new broker
- OrdersRelationManager shows the customer's orders read-only

Tests (7 new):
- forgot sends mail; forgot doesn't disclose unknown email; reset with valid
  token changes password; bad token rejected; order email when customer_email
  set; email skipped without it; Part has imageUrls() collection

Full suite: 130 passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 06:14:45 +00:00

281 lines
14 KiB
PHP

<?php
namespace App\Filament\Tenant\Resources;
use App\Filament\Tenant\Resources\PartResource\Pages;
use App\Filament\Tenant\Resources\PartResource\RelationManagers;
use App\Models\Tenant\Part;
use App\Models\Tenant\Supplier;
use Filament\Actions;
use Filament\Forms;
use Filament\Resources\Resource;
use Filament\Schemas;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
class PartResource extends Resource
{
protected static ?string $model = Part::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-cube';
protected static ?string $navigationLabel = 'Depozit';
protected static string|\UnitEnum|null $navigationGroup = 'Depozit';
protected static ?string $modelLabel = 'piesă';
protected static ?string $pluralModelLabel = 'piese';
protected static ?int $navigationSort = 40;
public static function getGloballySearchableAttributes(): array
{
return ['name', 'article', 'brand', 'category', 'barcode'];
}
public static function getGlobalSearchResultDetails(\Illuminate\Database\Eloquent\Model $record): array
{
return [
'Stoc' => (int) $record->stock . ' ' . ($record->unit ?? 'buc'),
'Preț' => number_format((float) $record->sell_price, 2),
];
}
public static function getNavigationBadge(): ?string
{
$low = static::getModel()::query()
->where('is_active', true)
->whereColumn('qty', '<=', 'min_qty')
->count();
return $low > 0 ? (string) $low : null;
}
public static function getNavigationBadgeColor(): ?string
{
return 'warning';
}
public static function form(Schema $schema): Schema
{
return $schema->components([
Schemas\Components\Section::make('Identificare')
->columns(3)
->schema([
Forms\Components\TextInput::make('name')->label('Denumire')->required()->columnSpan(3)->maxLength(200),
Forms\Components\TextInput::make('article')->label('Cod articol')->maxLength(64),
Forms\Components\TextInput::make('brand')->maxLength(64),
Forms\Components\Select::make('category')
->label('Categorie')
->options(array_combine(Part::CATEGORIES, Part::CATEGORIES))
->searchable(),
Forms\Components\TextInput::make('barcode')->label('Cod bare')->maxLength(64),
Forms\Components\TextInput::make('location')->label('Locație rack/bin')->maxLength(64),
]),
Schemas\Components\Section::make('Stoc')
->columns(4)
->schema([
Forms\Components\TextInput::make('qty')->label('Cantitate')->numeric()->default(0)->required(),
Forms\Components\TextInput::make('unit')->label('UM')->default('buc')->maxLength(16),
Forms\Components\TextInput::make('min_qty')->label('Minim')->numeric()->default(0),
Forms\Components\Toggle::make('is_active')->label('Activ')->default(true),
Forms\Components\Toggle::make('is_published')
->label('Publicat în magazin')
->helperText('Apare în magazinul online public.')
->default(false),
]),
Schemas\Components\Section::make('Prețuri')
->columns(2)
->schema([
Forms\Components\TextInput::make('buy_price')->label('Preț achiziție')->numeric()->default(0),
Forms\Components\TextInput::make('sell_price')->label('Preț vânzare')->numeric()->default(0),
]),
Schemas\Components\Section::make('Furnizor preferat')
->columns(1)
->schema([
Forms\Components\Select::make('preferred_supplier_id')
->label('Furnizor')
->options(fn () => Supplier::pluck('name', 'id'))
->searchable(),
]),
Schemas\Components\Section::make('Imagine')
->collapsible()
->schema([
\Filament\Forms\Components\SpatieMediaLibraryFileUpload::make('image')
->label('Foto piesă')
->collection('image')
->multiple()
->reorderable()
->image()
->imageEditor()
->maxFiles(8)
->maxSize(2048)
->columnSpanFull()
->helperText('Galerie de până la 8 imagini. Prima e afișată în catalog. Max 2 MB / imagine.'),
]),
Forms\Components\Textarea::make('notes')->label('Observații')->columnSpanFull()->rows(2),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
\Filament\Tables\Columns\SpatieMediaLibraryImageColumn::make('image')
->label('')
->collection('image')
->circular()
->size(32),
Tables\Columns\TextColumn::make('name')->searchable()->sortable()->wrap(),
Tables\Columns\TextColumn::make('article')->label('Cod')->searchable()->copyable()->placeholder('—'),
Tables\Columns\TextColumn::make('brand')->placeholder('—'),
Tables\Columns\TextColumn::make('category')->badge()->placeholder('—'),
Tables\Columns\TextColumn::make('qty')
->label('Stoc')
->numeric(decimalPlaces: 2)
->alignRight()
->color(fn ($state, $record) => $record->qty <= 0 ? 'danger' : ($record->qty <= $record->min_qty ? 'warning' : null))
->weight(fn ($state, $record) => $record->qty <= $record->min_qty ? 'bold' : null),
Tables\Columns\TextColumn::make('qty_reserved')
->label('Rezervat')
->numeric(decimalPlaces: 2)
->alignRight()
->color(fn ($state) => (float) $state > 0 ? 'info' : null)
->toggleable(),
Tables\Columns\TextColumn::make('unit')->label('UM'),
Tables\Columns\TextColumn::make('location')->label('Loc.')->placeholder('—'),
Tables\Columns\TextColumn::make('sell_price')->label('Preț vz.')->money('MDL')->alignRight(),
Tables\Columns\IconColumn::make('is_published')->label('Magazin')->boolean()->toggleable(),
Tables\Columns\TextColumn::make('preferredSupplier.name')->label('Furnizor')->placeholder('—')->toggleable(),
])
->filters([
Tables\Filters\SelectFilter::make('category')
->options(array_combine(Part::CATEGORIES, Part::CATEGORIES)),
Tables\Filters\Filter::make('low_stock')
->label('Stoc minim')
->query(fn ($q) => $q->whereColumn('qty', '<=', 'min_qty')),
Tables\Filters\Filter::make('out_of_stock')
->label('Lipsă')
->query(fn ($q) => $q->where('qty', '<=', 0)),
])
->actions([
Actions\Action::make('qr')
->label('QR')
->icon('heroicon-m-qr-code')
->color('gray')
->modalHeading(fn (Part $r) => 'QR pentru ' . $r->name)
->modalSubmitAction(false)
->modalCancelActionLabel('Închide')
->modalContent(function (Part $r) {
$payload = 'PART:' . ($r->article ?: $r->id);
$svg = (new \chillerlan\QRCode\QRCode(new \chillerlan\QRCode\QROptions([
'outputType' => \chillerlan\QRCode\QRCode::OUTPUT_MARKUP_SVG,
'eccLevel' => \chillerlan\QRCode\QRCode::ECC_M,
'scale' => 8,
'imageBase64' => false,
'addQuietzone' => true,
])))->render($payload);
return view('filament.tenant.part-qr', [
'part' => $r, 'svg' => $svg, 'payload' => $payload,
]);
}),
Actions\Action::make('ai_price')
->label('AI: preț recomandat')
->icon('heroicon-m-sparkles')
->color('primary')
->modalHeading(fn (Part $r) => "AI: preț pentru {$r->name}")
->modalSubmitAction(false)
->modalCancelActionLabel('Închide')
->modalContent(function (Part $r) {
[$reply, $meta] = app(\App\Services\Ai\AiAssistantService::class)
->suggestPrice($r);
return view('filament.tenant.ai-reply', ['reply' => $reply, 'meta' => $meta]);
}),
Actions\Action::make('receive')
->label('Recepție')
->icon('heroicon-m-arrow-down-tray')
->color('success')
->schema([
Forms\Components\TextInput::make('qty')->label('Cantitate')->numeric()->required()->minValue(0.001),
Forms\Components\TextInput::make('buy_price')->label('Preț unitar')->numeric()->required(),
Forms\Components\Select::make('supplier_id')
->label('Furnizor')
->options(fn () => \App\Models\Tenant\Supplier::pluck('name', 'id')),
Forms\Components\Select::make('warehouse_id')
->label('Depozit')
->options(fn () => \App\Models\Tenant\Warehouse::where('is_active', true)->pluck('name', 'id'))
->default(fn () => \App\Models\Tenant\Warehouse::where('is_default', true)->value('id')),
Forms\Components\TextInput::make('batch_ref')->label('Ref. lot/factură')->maxLength(64),
])
->action(function (Part $record, array $data) {
$warehouse = $data['warehouse_id']
? \App\Models\Tenant\Warehouse::find($data['warehouse_id'])
: null;
$supplier = $data['supplier_id']
? \App\Models\Tenant\Supplier::find($data['supplier_id'])
: null;
app(\App\Services\Warehouse\WarehouseService::class)->receive(
part: $record,
qty: (float) $data['qty'],
buyPrice: (float) $data['buy_price'],
warehouse: $warehouse,
supplier: $supplier,
batchRef: $data['batch_ref'] ?? null,
);
\Filament\Notifications\Notification::make()
->title('Stoc adăugat')
->success()
->send();
}),
Actions\EditAction::make(),
Actions\DeleteAction::make(),
])
->bulkActions([
Actions\BulkAction::make('print_labels')
->label('Tipărește etichete QR')
->icon('heroicon-m-printer')
->color('gray')
->action(function ($records) {
$ids = collect($records)->pluck('id')->implode(',');
return redirect()->away('/parts/labels?ids=' . $ids);
})
->deselectRecordsAfterCompletion(),
Actions\BulkAction::make('publish')
->label('Publică în magazin')
->icon('heroicon-m-globe-alt')
->color('success')
->action(fn ($records) => collect($records)->each->update(['is_published' => true]))
->deselectRecordsAfterCompletion(),
Actions\BulkAction::make('unpublish')
->label('Scoate din magazin')
->icon('heroicon-m-eye-slash')
->color('gray')
->action(fn ($records) => collect($records)->each->update(['is_published' => false]))
->deselectRecordsAfterCompletion(),
])
->emptyStateHeading('Depozit gol')
->emptyStateDescription('Adaugă piese manual, sau folosește Achiziții ca să le adaugi prin recepție de la furnizor (cu prețuri și stoc auto). Procentaj poate seta automat prețul de vânzare.')
->emptyStateIcon('heroicon-o-cube')
->defaultSort('name');
}
public static function getRelations(): array
{
return [
RelationManagers\BatchesRelationManager::class,
RelationManagers\PriceHistoryRelationManager::class,
RelationManagers\CrossRefsRelationManager::class,
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListParts::route('/'),
'create' => Pages\CreatePart::route('/create'),
'edit' => Pages\EditPart::route('/{record}/edit'),
];
}
}