feat: Implement contact management system with email reply functionality and database migration

This commit is contained in:
Yoga Pangestu 2026-01-06 09:37:55 +07:00
parent 0382570c23
commit 3ae2054ebd
8 changed files with 469 additions and 0 deletions

View File

@ -54,6 +54,19 @@ public function form(Schema $schema): Schema
->required()
->maxLength(255),
TextInput::make('site_email')
->label('Email Website')
->placeholder('diskominfo@purwakartakab.go.id')
->email()
->required()
->maxLength(255),
TextInput::make('site_phone')
->label('Nomor Telepon')
->placeholder('(0264) 200222')
->required()
->maxLength(255),
RichEditor::make('about_us')
->label('Tentang Kami')
->placeholder('...')

View File

@ -0,0 +1,199 @@
<?php
namespace App\Filament\Resources\Manage\Contacts;
use App\Filament\Actions\DefaultBulkActions;
use App\Filament\Resources\Manage\Contacts\Pages\ManageContacts;
use App\Mail\ContactReplyMail;
use App\Models\Contact;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteAction;
use Filament\Actions\ViewAction;
use Filament\Forms\Components\Textarea;
use Filament\Infolists\Components\TextEntry;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Support\Facades\Mail;
use UnitEnum;
class ContactResource extends Resource
{
protected static ?string $model = Contact::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::Envelope;
protected static string|UnitEnum|null $navigationGroup = 'Kelola';
protected static ?string $navigationLabel = 'Kontak';
protected static ?string $recordTitleAttribute = 'subject';
protected static ?string $modelLabel = 'Kontak';
protected static ?string $slug = 'manage/contacts';
public static function getNavigationBadge(): ?string
{
return static::getModel()::where('replied_at', null)->count();
}
public static function form(Schema $schema): Schema
{
return $schema
->components([
// Form usually not needed as we use View/Reply
]);
}
public static function infolist(Schema $schema): Schema
{
return $schema
->schema([
Section::make('Informasi Pengirim')
->schema([
Grid::make(4)
->schema([
TextEntry::make('name')
->label('Nama'),
TextEntry::make('email')
->label('Email'),
TextEntry::make('subject')
->label('Subyek'),
TextEntry::make('created_at')
->label('Diterima pada')
->date('l, d F Y H:i'),
]),
])
->columnSpanFull(),
Section::make('Pesan')
->schema([
TextEntry::make('message')
->label('Isi Pesan')
->columnSpanFull(),
])
->columnSpanFull(),
Section::make('Balasan')
->schema([
TextEntry::make('repliedBy.name')
->label('Dibalas Oleh')
->placeholder('Belum dibalas'),
TextEntry::make('replied_at')
->label('Dibalas pada')
->date('l, d F Y H:i')
->placeholder('Belum dibalas'),
TextEntry::make('reply_message')
->label('Pesan Balasan')
->placeholder('Belum dibalas'),
])
->columnSpanFull()
->visible(fn ($record) => $record->replied_at !== null),
])
->columns(2);
}
public static function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->label('Nama')
->searchable(),
TextColumn::make('email')
->label('Email')
->searchable(),
TextColumn::make('subject')
->label('Subyek')
->limit(30),
IconColumn::make('replied_at')
->label('Dibalas')
->boolean()
->getStateUsing(fn ($record) => $record->replied_at !== null),
TextColumn::make('repliedBy.name')
->label('Dibalas Oleh')
->badge()
->placeholder('Belum dibalas')
->sortable(),
TextColumn::make('created_at')
->label('Diterima pada')
->date('l, d F Y H:i')
->sortable(),
])
->filters([
//
])
->recordActions([
ViewAction::make()
->label('Lihat'),
Action::make('reply')
->label('Balas')
->icon(Heroicon::ChatBubbleLeftRight)
->color('success')
->modalHeading('Balas Pesan Kontak')
->modalDescription('Pesan balasan tidak bisa diubah setelah dikirim.')
->schema([
Textarea::make('reply_message')
->label('Pesan Balasan')
->placeholder(fn ($record) => "Halo {$record->name}, ...")
->required()
->rows(5),
])
->action(function (Contact $record, array $data) {
Mail::to($record->email)->send(new ContactReplyMail($record, $data['reply_message']));
$record->update([
'reply_message' => $data['reply_message'],
'replied_at' => now(),
'replied_by' => auth()->id(),
]);
Notification::make()
->title('Balasan terkirim')
->success()
->send();
})
->visible(fn ($record) => $record->replied_at === null),
DeleteAction::make()
->label('Hapus'),
])
->toolbarActions([
BulkActionGroup::make([
...DefaultBulkActions::make('Kontak'),
]),
])
->emptyStateIcon(Heroicon::Envelope)
->emptyStateDescription('Belum ada kontak atau pesan masuk.')
->defaultSort('created_at', 'desc')
->deferFilters(false)
->reorderable('sort_order');
}
public static function getPages(): array
{
return [
'index' => ManageContacts::route('/'),
];
}
}

View File

@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\Manage\Contacts\Pages;
use App\Filament\Resources\Manage\Contacts\ContactResource;
use Filament\Resources\Pages\ManageRecords;
class ManageContacts extends ManageRecords
{
protected static string $resource = ContactResource::class;
}

View File

@ -0,0 +1,64 @@
<?php
namespace App\Mail;
use App\Settings\GeneralSettings;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class ContactReplyMail extends Mailable
{
use Queueable, SerializesModels;
/**
* Create a new message instance.
*/
public function __construct(
public $contact,
public $replyMessage,
) {}
/**
* Get the message envelope.
*/
public function envelope(): Envelope
{
return new Envelope(
subject: 'Balasan: '.($this->contact->subject ?? 'Pesan Kontak'),
);
}
/**
* Get the message content definition.
*/
public function content(): Content
{
$settings = app(GeneralSettings::class);
$logoPath = $settings->site_logo
? storage_path('app/public/'.$settings->site_logo)
: public_path('assets/img/logo/icon-with-text.png');
return new Content(
view: 'emails.contact-reply',
with: [
'name' => $this->contact->name,
'replyMessage' => $this->replyMessage,
'site_name' => $settings->site_name,
'logoPath' => $logoPath,
],
);
}
/**
* Get the attachments for the message.
*
* @return array<int, \Illuminate\Mail\Mailables\Attachment>
*/
public function attachments(): array
{
return [];
}
}

24
app/Models/Contact.php Normal file
View File

@ -0,0 +1,24 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Contact extends Model
{
protected $fillable = [
'name',
'email',
'subject',
'message',
'reply_message',
'replied_at',
'replied_by',
];
public function repliedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'replied_by');
}
}

View File

@ -57,6 +57,11 @@ public function company(): HasOne
return $this->hasOne(Company::class);
}
public function contacts(): HasMany
{
return $this->hasMany(Contact::class, 'replied_by');
}
public function news(): HasMany
{
return $this->hasMany(News::class, 'author_id');

View File

@ -0,0 +1,37 @@
<?php
use App\Models\User;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('contacts', function (Blueprint $table) {
$table->id();
$table->string('name', 100);
$table->string('email', 254);
$table->string('subject')->nullable();
$table->text('message');
$table->text('reply_message')->nullable();
$table->timestamp('replied_at')->nullable();
$table->foreignIdFor(User::class, 'replied_by')->nullable()->constrained()->cascadeOnDelete();
$table->timestamp('created_at')->useCurrent();
$table->timestamp('updated_at')->nullable()->useCurrentOnUpdate();
$table->softDeletes();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('contacts');
}
};

View File

@ -0,0 +1,116 @@
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="x-apple-disable-message-reformatting">
<title></title>
<link href="https://fonts.googleapis.com/css?family=Roboto:400,600" rel="stylesheet" type="text/css">
<style>
html,
body {
margin: 0 auto !important;
padding: 0 !important;
height: 100% !important;
width: 100% !important;
font-family: 'Roboto', sans-serif !important;
font-size: 14px;
margin-bottom: 10px;
line-height: 24px;
color: #8094ae;
font-weight: 400;
}
* {
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;
margin: 0;
padding: 0;
}
table,
td {
mso-table-lspace: 0pt !important;
mso-table-rspace: 0pt !important;
}
table {
border-spacing: 0 !important;
border-collapse: collapse !important;
table-layout: fixed !important;
margin: 0 auto !important;
}
table table table {
table-layout: auto;
}
a {
text-decoration: none;
}
img {
-ms-interpolation-mode: bicubic;
}
</style>
</head>
<body width="100%"
style="margin: 0; padding: 0 !important; mso-line-height-rule: exactly; background-color: #f5f6fa;">
<center style="width: 100%; background-color: #f5f6fa;">
<table width="100%" border="0" cellpadding="0" cellspacing="0" bgcolor="#f5f6fa">
<tr>
<td style="padding: 40px 0;">
<table style="width:100%;max-width:620px;margin:0 auto;">
<tbody>
<tr>
<td align="center" style="padding-bottom:25px;">
<a href="{{ url('/') }}">
<img src="{{ $message->embed($logoPath) }}" alt="{{ $site_name }}"
style="height:40px; display:block; margin:0 auto;">
</a>
<p style="font-size:14px; padding-top:12px; text-align:center;">
Sistem Informasi Media Komunikasi.
</p>
</td>
</tr>
</tbody>
</table>
<table style="width:100%;max-width:620px;margin:0 auto;background-color:#ffffff;">
<tbody>
<tr>
<td style="padding: 30px 30px 20px">
<p style="margin-bottom: 10px;">Hello {{ $name }},</p>
<p style="margin-bottom: 10px;">{!! nl2br(e($replyMessage)) !!}</p>
<p style="margin-top: 25px; margin-bottom: 15px;">---- <br> Regards <br>
{{ $site_name }}</p>
</td>
</tr>
</tbody>
</table>
<table style="width:100%;max-width:620px;margin:0 auto;">
<tbody>
<tr>
<td style="text-align: center; padding:25px 20px 0;">
<p style="font-size: 13px;">&copy; {{ date('Y') }}
{{ $site_name }}. Seluruh Hak Cipta
Dilindungi.
</p>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</table>
</center>
</body>
</html>