feat: implement proposal management system including database schema, Filament resources, and client-facing preview views

This commit is contained in:
Ümit Tunç
2026-05-25 07:05:57 +03:00
parent 15ba98e14d
commit c5a33f4d18
14 changed files with 2226 additions and 0 deletions
@@ -0,0 +1,16 @@
<?php
namespace App\Filament\Admin\Resources\Proposals\Pages;
use App\Filament\Admin\Resources\Proposals\ProposalResource;
use Filament\Resources\Pages\CreateRecord;
class CreateProposal extends CreateRecord
{
protected static string $resource = ProposalResource::class;
public function getTitle(): string
{
return __('proposal.create');
}
}
@@ -0,0 +1,16 @@
<?php
namespace App\Filament\Admin\Resources\Proposals\Pages;
use App\Filament\Admin\Resources\Proposals\ProposalResource;
use Filament\Resources\Pages\EditRecord;
class EditProposal extends EditRecord
{
protected static string $resource = ProposalResource::class;
public function getTitle(): string
{
return __('proposal.edit');
}
}
@@ -0,0 +1,25 @@
<?php
namespace App\Filament\Admin\Resources\Proposals\Pages;
use App\Filament\Admin\Resources\Proposals\ProposalResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListProposals extends ListRecords
{
protected static string $resource = ProposalResource::class;
public function getTitle(): string
{
return __('proposal.title');
}
protected function getHeaderActions(): array
{
return [
CreateAction::make()
->label(__('proposal.create')),
];
}
}
@@ -0,0 +1,72 @@
<?php
namespace App\Filament\Admin\Resources\Proposals;
use App\Filament\Admin\Resources\Proposals\Pages\CreateProposal;
use App\Filament\Admin\Resources\Proposals\Pages\EditProposal;
use App\Filament\Admin\Resources\Proposals\Pages\ListProposals;
use App\Filament\Admin\Resources\Proposals\Schemas\ProposalForm;
use App\Filament\Admin\Resources\Proposals\Tables\ProposalsTable;
use App\Models\Proposal;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\SoftDeletingScope;
class ProposalResource extends Resource
{
protected static ?string $model = Proposal::class;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-document-text';
public static function getNavigationLabel(): string
{
return __('proposal.navigation_label');
}
public static function getModelLabel(): string
{
return __('proposal.model_label');
}
public static function getPluralModelLabel(): string
{
return __('proposal.plural_model_label');
}
public static function form(Schema $schema): Schema
{
return ProposalForm::configure($schema);
}
public static function table(Table $table): Table
{
return ProposalsTable::configure($table);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => ListProposals::route('/'),
'create' => CreateProposal::route('/create'),
'edit' => EditProposal::route('/{record}/edit'),
];
}
public static function getRecordRouteBindingEloquentQuery(): Builder
{
return parent::getRecordRouteBindingEloquentQuery()
->withoutGlobalScopes([
SoftDeletingScope::class,
]);
}
}
@@ -0,0 +1,140 @@
<?php
namespace App\Filament\Admin\Resources\Proposals\Schemas;
use Filament\Forms\Components\Checkbox;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
class ProposalForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->columns(3)
->schema([
// Sol kolon - Ana içerik (2 sütun genişliğinde)
Section::make(__('proposal.content_section'))
->schema([
TextInput::make('title')
->label(__('proposal.title_field'))
->required()
->maxLength(255)
->live(onBlur: true)
->afterStateUpdated(function (string $operation, $state, callable $set) {
if ($operation !== 'create') {
return;
}
$set('slug', \Str::slug($state));
}),
TextInput::make('slug')
->label(__('proposal.slug_field'))
->required()
->maxLength(255)
->unique(ignoreRecord: true)
->rules(['alpha_dash'])
->helperText(__('proposal.slug_helper')),
Textarea::make('content')
->label(__('proposal.content_field'))
->required()
->rows(18)
->extraInputAttributes(['style' => 'font-family: monospace; font-size: 0.9rem; line-height: 1.5;'])
->columnSpanFull(),
Textarea::make('client_feedback')
->label('Müşteri Geri Bildirimi / Revizyon Notu')
->rows(3)
->disabled()
->dehydrated(false)
->placeholder('Henüz geri bildirim bırakılmadı.')
->visible(fn ($record) => $record !== null && !empty($record->client_feedback))
->columnSpanFull(),
])
->columnSpan(2)
->collapsible(false),
// Sağ kolon - Durum, Fiyat, Müşteri ve Tasarım (1 sütun genişliğinde)
Section::make(__('proposal.settings_section'))
->schema([
Select::make('status')
->label(__('proposal.status_field'))
->options([
'draft' => __('proposal.status_draft'),
'sent' => __('proposal.status_sent'),
'accepted' => __('proposal.status_accepted'),
'rejected' => __('proposal.status_rejected'),
'revised' => __('proposal.status_revised'),
])
->default('draft')
->required()
->columnSpanFull(),
TextInput::make('client_name')
->label(__('proposal.client_name_field'))
->required()
->maxLength(255)
->columnSpanFull(),
TextInput::make('client_email')
->label(__('proposal.client_email_field'))
->email()
->maxLength(255)
->columnSpanFull(),
TextInput::make('total_price')
->label(__('proposal.total_price_field'))
->numeric()
->prefix(fn ($get) => match ($get('currency')) {
'USD' => '$',
'EUR' => '€',
default => '₺',
})
->columnSpan(2),
Select::make('currency')
->label(__('proposal.currency_field'))
->options([
'TRY' => 'TL (₺)',
'USD' => 'USD ($)',
'EUR' => 'EUR (€)',
])
->default('TRY')
->required()
->live()
->columnSpan(1),
DatePicker::make('valid_until')
->label(__('proposal.valid_until_field'))
->native(false)
->displayFormat('d.m.Y')
->columnSpanFull(),
Select::make('meta.accent_color')
->label(__('proposal.accent_color_field'))
->options([
'indigo' => 'Premium Indigo (Mor/Mavi)',
'emerald' => 'Emerald Medical (Zümrüt Yeşil)',
'cyberpunk' => 'Cyberpunk Neon (Pembe/Mavi)',
'coral' => 'Coral Corporate (Mercan Kırmızı)',
'amber' => 'Amber Executive (Kehribar/Altın)',
])
->default('indigo')
->columnSpanFull(),
Checkbox::make('meta.show_calculator')
->label(__('proposal.show_calculator_field'))
->default(true)
->columnSpanFull(),
])
->columns(3)
->columnSpan(1)
->collapsible(false),
]);
}
}
@@ -0,0 +1,111 @@
<?php
namespace App\Filament\Admin\Resources\Proposals\Tables;
use Filament\Tables\Actions\BulkActionGroup;
use Filament\Tables\Actions\DeleteBulkAction;
use Filament\Tables\Actions\EditAction;
use Filament\Tables\Actions\Action;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
class ProposalsTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('title')
->label(__('proposal.table_title'))
->searchable()
->sortable()
->limit(40),
TextColumn::make('client_name')
->label(__('proposal.table_client'))
->searchable()
->sortable()
->limit(30),
TextColumn::make('total_price')
->label(__('proposal.table_price'))
->money(fn ($record) => $record->currency)
->sortable(),
TextColumn::make('status')
->label(__('proposal.table_status'))
->badge()
->color(fn (string $state): string => match ($state) {
'draft' => 'gray',
'sent' => 'info',
'accepted' => 'success',
'rejected' => 'danger',
'revised' => 'warning',
default => 'gray',
})
->formatStateUsing(fn (string $state): string => match ($state) {
'draft' => __('proposal.status_draft'),
'sent' => __('proposal.status_sent'),
'accepted' => __('proposal.status_accepted'),
'rejected' => __('proposal.status_rejected'),
'revised' => __('proposal.status_revised'),
default => $state,
}),
TextColumn::make('slug')
->label(__('proposal.table_slug'))
->searchable()
->copyable()
->copyMessage(__('proposal.link_copied'))
->icon('heroicon-o-document-duplicate')
->limit(25),
TextColumn::make('valid_until')
->label(__('proposal.table_valid_until'))
->date('d.m.Y')
->sortable(),
TextColumn::make('views_count')
->label(__('proposal.table_views'))
->integer()
->sortable()
->alignCenter(),
TextColumn::make('created_at')
->label(__('proposal.table_created_at'))
->dateTime('d.m.Y H:i')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
SelectFilter::make('status')
->label(__('proposal.status_field'))
->options([
'draft' => __('proposal.status_draft'),
'sent' => __('proposal.status_sent'),
'accepted' => __('proposal.status_accepted'),
'rejected' => __('proposal.status_rejected'),
'revised' => __('proposal.status_revised'),
]),
])
->recordActions([
EditAction::make()
->label(__('proposal.edit')),
])
->actions([
Action::make('view_proposal')
->label(__('proposal.preview'))
->icon('heroicon-o-eye')
->url(fn ($record) => route('proposals.show', $record->slug))
->openUrlInNewTab(),
])
->bulkActions([
BulkActionGroup::make([
DeleteBulkAction::make()
->label(__('proposal.delete')),
]),
])
->defaultSort('created_at', 'desc');
}
}
@@ -0,0 +1,70 @@
<?php
namespace App\Http\Controllers;
use App\Models\Proposal;
use Illuminate\Http\Request;
class ProposalController extends Controller
{
/**
* Display the public proposal presentation page.
*/
public function show(string $slug)
{
$proposal = Proposal::where('slug', $slug)->firstOrFail();
// Increment views count cleanly
$proposal->increment('views_count');
return view('proposals.show', compact('proposal'));
}
/**
* Handle client interaction actions (approve or revision/feedback).
*/
public function action(Request $request, string $slug)
{
$proposal = Proposal::where('slug', $slug)->firstOrFail();
$actionType = $request->input('action_type');
if ($actionType === 'approve') {
$request->validate([
'name' => 'required|string|max:255',
]);
$proposal->update([
'status' => 'accepted',
'accepted_name' => $request->input('name'),
'accepted_at' => now(),
]);
return response()->json([
'success' => true,
'message' => 'Teklif başarıyla onaylandı! Katılımınız ve iş birliğiniz için teşekkür ederiz.',
]);
}
if ($actionType === 'feedback') {
$request->validate([
'feedback' => 'required|string|max:5000',
]);
$proposal->update([
'status' => 'revised',
'client_feedback' => $request->input('feedback'),
]);
return response()->json([
'success' => true,
'message' => 'Revizyon ve görüşleriniz başarıyla iletildi. En kısa sürede güncellenerek sizinle paylaşılacaktır.',
]);
}
return response()->json([
'success' => false,
'message' => 'Geçersiz işlem tipi.',
], 400);
}
}
+70
View File
@@ -0,0 +1,70 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;
class Proposal extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = [
'uuid',
'slug',
'title',
'client_name',
'client_email',
'content',
'total_price',
'currency',
'status',
'valid_until',
'client_feedback',
'accepted_name',
'accepted_at',
'views_count',
'meta',
'created_by',
];
protected $casts = [
'valid_until' => 'date',
'accepted_at' => 'datetime',
'meta' => 'array',
'views_count' => 'integer',
'total_price' => 'decimal:2',
];
protected static function boot()
{
parent::boot();
static::creating(function ($proposal) {
if (empty($proposal->uuid)) {
$proposal->uuid = (string) Str::uuid();
}
if (empty($proposal->created_by) && auth()->check()) {
$proposal->created_by = auth()->id();
}
});
}
/**
* Get the user who created the proposal.
*/
public function creator()
{
return $this->belongsTo(User::class, 'created_by');
}
/**
* Get the public url for the proposal.
*/
public function getUrlAttribute()
{
return route('proposals.show', $this->slug);
}
}