2026-04-03 13:55:17 +00:00
|
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
|
|
namespace App\Filament\Pages;
|
|
|
|
|
|
|
|
|
|
|
|
use Carbon\Carbon;
|
|
|
|
|
|
use Filament\Actions\Action as FilamentAction;
|
|
|
|
|
|
use Filament\Forms\Components\TextInput;
|
|
|
|
|
|
use Filament\Notifications\Notification;
|
|
|
|
|
|
use Filament\Pages\Page;
|
|
|
|
|
|
use Filament\Support\Enums\FontFamily;
|
|
|
|
|
|
use Filament\Support\Enums\FontWeight;
|
|
|
|
|
|
use Filament\Tables;
|
|
|
|
|
|
use Filament\Tables\Concerns\InteractsWithTable;
|
|
|
|
|
|
use Filament\Tables\Contracts\HasTable;
|
|
|
|
|
|
use Filament\Tables\Grouping\Group;
|
|
|
|
|
|
use Filament\Tables\Table;
|
|
|
|
|
|
use Illuminate\Pagination\LengthAwarePaginator;
|
|
|
|
|
|
use Illuminate\Support\Facades\Http;
|
|
|
|
|
|
use Morilog\Jalali\Jalalian;
|
|
|
|
|
|
|
|
|
|
|
|
class SmsLogsPage extends Page implements HasTable
|
|
|
|
|
|
{
|
|
|
|
|
|
use InteractsWithTable;
|
|
|
|
|
|
|
|
|
|
|
|
protected string $view = 'filament.pages.sms-logs';
|
|
|
|
|
|
|
|
|
|
|
|
protected static ?int $navigationSort = 10;
|
|
|
|
|
|
|
|
|
|
|
|
public static function canAccess(): bool
|
|
|
|
|
|
{
|
|
|
|
|
|
return auth()->user()->can('View:SmsLogsPage');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public static function getNavigationIcon(): \BackedEnum|string|null
|
|
|
|
|
|
{
|
|
|
|
|
|
return 'heroicon-o-chat-bubble-left-ellipsis';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public static function getNavigationGroup(): ?string
|
|
|
|
|
|
{
|
|
|
|
|
|
return __('navigation.groups.settings');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public static function getNavigationLabel(): string
|
|
|
|
|
|
{
|
|
|
|
|
|
return __('navigation.sms_logs.label');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public function getTitle(): string
|
|
|
|
|
|
{
|
|
|
|
|
|
return __('navigation.sms_logs.title');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public array $stats = ['patients' => 0, 'total' => 0, 'sent' => 0, 'pending' => 0, 'failed' => 0];
|
|
|
|
|
|
|
|
|
|
|
|
public function table(Table $table): Table
|
|
|
|
|
|
{
|
|
|
|
|
|
return $table
|
|
|
|
|
|
->records(fn (array $filters, ?string $search, int $page, int $recordsPerPage) =>
|
|
|
|
|
|
$this->fetchRecords($filters, $search, $page, $recordsPerPage)
|
|
|
|
|
|
)
|
|
|
|
|
|
->columns([
|
|
|
|
|
|
|
|
|
|
|
|
Tables\Columns\TextColumn::make('message_type')
|
|
|
|
|
|
->label('نوع پیام')
|
|
|
|
|
|
->getStateUsing(fn (array $record): string => $record['message_type'] ?? '')
|
|
|
|
|
|
->formatStateUsing(fn (string $state): string => match ($state) {
|
|
|
|
|
|
'initial' => 'پیام اولیه',
|
|
|
|
|
|
'reminder_7days' => 'یادآوری ۷ روز',
|
|
|
|
|
|
'reminder_3days' => 'یادآوری ۳ روز',
|
|
|
|
|
|
'reminder_after_registration' => 'یادآوری پس از ثبت',
|
|
|
|
|
|
'day_of_surgery' => 'روز عمل',
|
|
|
|
|
|
default => $state,
|
|
|
|
|
|
})
|
|
|
|
|
|
->badge()
|
|
|
|
|
|
->color(fn (string $state): string => match ($state) {
|
|
|
|
|
|
'initial' => 'info',
|
|
|
|
|
|
'day_of_surgery' => 'success',
|
|
|
|
|
|
'reminder_3days' => 'pink',
|
|
|
|
|
|
default => 'warning',
|
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
|
|
Tables\Columns\TextColumn::make('status')
|
|
|
|
|
|
->label('وضعیت')
|
|
|
|
|
|
->getStateUsing(fn (array $record): string => $record['status'] ?? '')
|
|
|
|
|
|
->formatStateUsing(fn (string $state): string => match ($state) {
|
|
|
|
|
|
'sent' => 'ارسال شده',
|
|
|
|
|
|
'pending' => 'در انتظار',
|
|
|
|
|
|
'failed' => 'ناموفق',
|
|
|
|
|
|
default => $state,
|
|
|
|
|
|
})
|
|
|
|
|
|
->badge()
|
|
|
|
|
|
->color(fn (string $state): string => match ($state) {
|
|
|
|
|
|
'sent' => 'success',
|
|
|
|
|
|
'pending' => 'warning',
|
|
|
|
|
|
'failed' => 'danger',
|
|
|
|
|
|
default => 'gray',
|
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
|
|
Tables\Columns\TextColumn::make('scheduled_at')
|
|
|
|
|
|
->label('زمانبندی شده')
|
|
|
|
|
|
->getStateUsing(fn (array $record): string => $this->toJalali($record['scheduled_at'] ?? null)),
|
|
|
|
|
|
|
|
|
|
|
|
Tables\Columns\TextColumn::make('sent_at')
|
|
|
|
|
|
->label('زمان ارسال')
|
|
|
|
|
|
->getStateUsing(fn (array $record): string => $this->toJalali($record['sent_at'] ?? null)),
|
|
|
|
|
|
|
|
|
|
|
|
])
|
|
|
|
|
|
->groups([
|
|
|
|
|
|
Group::make('patient_key')
|
|
|
|
|
|
->titlePrefixedWithLabel(false)
|
|
|
|
|
|
->getTitleFromRecordUsing(fn (array $record): string => $record['patient_name'] ?? '-')
|
|
|
|
|
|
->getDescriptionFromRecordUsing(fn (array $record): string =>
|
|
|
|
|
|
($record['phone_number'] ?? '') .
|
|
|
|
|
|
($record['surgery_date']
|
|
|
|
|
|
? ' | تاریخ عمل: ' . $this->toJalali($record['surgery_date'], 'Y/m/d')
|
|
|
|
|
|
: '')
|
|
|
|
|
|
)
|
|
|
|
|
|
->collapsible(),
|
|
|
|
|
|
])
|
|
|
|
|
|
->defaultGroup('patient_key')
|
|
|
|
|
|
->collapsedGroupsByDefault()
|
|
|
|
|
|
->groupingSettingsHidden()
|
|
|
|
|
|
->filters([
|
|
|
|
|
|
Tables\Filters\SelectFilter::make('status')
|
|
|
|
|
|
->label('وضعیت')
|
|
|
|
|
|
->options([
|
|
|
|
|
|
'sent' => 'ارسال شده',
|
|
|
|
|
|
'pending' => 'در انتظار',
|
|
|
|
|
|
'failed' => 'ناموفق',
|
|
|
|
|
|
]),
|
|
|
|
|
|
|
|
|
|
|
|
Tables\Filters\Filter::make('patient_name')
|
|
|
|
|
|
->label('نام بیمار')
|
|
|
|
|
|
->form([
|
|
|
|
|
|
TextInput::make('patient_name')
|
|
|
|
|
|
->label('نام بیمار')
|
|
|
|
|
|
->placeholder('جستجو...'),
|
|
|
|
|
|
])
|
|
|
|
|
|
->indicateUsing(fn (array $data): ?string => filled($data['patient_name'] ?? null)
|
|
|
|
|
|
? 'نام: ' . $data['patient_name']
|
|
|
|
|
|
: null),
|
|
|
|
|
|
|
|
|
|
|
|
Tables\Filters\Filter::make('phone_number')
|
|
|
|
|
|
->label('شماره موبایل')
|
|
|
|
|
|
->form([
|
|
|
|
|
|
TextInput::make('phone_number')
|
|
|
|
|
|
->label('شماره موبایل')
|
|
|
|
|
|
->placeholder('09...'),
|
|
|
|
|
|
])
|
|
|
|
|
|
->indicateUsing(fn (array $data): ?string => filled($data['phone_number'] ?? null)
|
|
|
|
|
|
? 'موبایل: ' . $data['phone_number']
|
|
|
|
|
|
: null),
|
|
|
|
|
|
])
|
|
|
|
|
|
->recordActions([
|
|
|
|
|
|
FilamentAction::make('delete_message')
|
|
|
|
|
|
->label('حذف پیام')
|
|
|
|
|
|
->icon('heroicon-o-trash')
|
|
|
|
|
|
->color('danger')
|
|
|
|
|
|
->requiresConfirmation()
|
|
|
|
|
|
->modalHeading('حذف پیام')
|
|
|
|
|
|
->modalDescription('آیا از حذف این پیام مطمئن هستید؟')
|
|
|
|
|
|
->modalSubmitActionLabel('بله، حذف شود')
|
|
|
|
|
|
->action(fn (array $record) => $this->deleteSingleMessage((int) $record['id'])),
|
|
|
|
|
|
|
|
|
|
|
|
FilamentAction::make('delete_patient')
|
|
|
|
|
|
->label('حذف همه پیامهای بیمار')
|
|
|
|
|
|
->icon('heroicon-o-user-minus')
|
|
|
|
|
|
->color('danger')
|
|
|
|
|
|
->outlined()
|
|
|
|
|
|
->requiresConfirmation()
|
|
|
|
|
|
->modalHeading('حذف همه پیامهای بیمار')
|
|
|
|
|
|
->modalDescription('آیا از حذف تمام پیامهای این بیمار مطمئن هستید؟')
|
|
|
|
|
|
->modalSubmitActionLabel('بله، حذف شود')
|
|
|
|
|
|
->action(fn (array $record) => $this->deletePatientMessages(
|
|
|
|
|
|
$record['phone_number'],
|
|
|
|
|
|
$record['surgery_date']
|
|
|
|
|
|
)),
|
|
|
|
|
|
])
|
|
|
|
|
|
->paginationPageOptions([5, 10, 25, 50]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
protected function fetchRecords(
|
|
|
|
|
|
array $filters,
|
|
|
|
|
|
?string $search,
|
|
|
|
|
|
int $page,
|
|
|
|
|
|
int $recordsPerPage,
|
|
|
|
|
|
): LengthAwarePaginator {
|
|
|
|
|
|
|
|
|
|
|
|
$query = array_filter([
|
|
|
|
|
|
'status' => $filters['status']['value'] ?? null,
|
|
|
|
|
|
'patientName' => $filters['patient_name']['patient_name'] ?? $search,
|
|
|
|
|
|
'phoneNumber' => $filters['phone_number']['phone_number'] ?? null,
|
|
|
|
|
|
'limit' => 500,
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
$response = Http::timeout(15)
|
|
|
|
|
|
->withHeader('X-API-Key', \App\Models\OsurgInitial::val('sms_api_key', config('sms.api_key')))
|
|
|
|
|
|
->get(\App\Models\OsurgInitial::val('sms_api_url', config('sms.api_url')) . '/sms-logs-grouped', $query);
|
|
|
|
|
|
|
|
|
|
|
|
if ($response->failed() || ! ($response->json('success') ?? false)) {
|
|
|
|
|
|
\Illuminate\Support\Facades\Log::warning('SmsLogsPage: API returned failure', [
|
|
|
|
|
|
'status' => $response->status(),
|
|
|
|
|
|
'body' => $response->body(),
|
|
|
|
|
|
'api_url' => \App\Models\OsurgInitial::val('sms_api_url', config('sms.api_url')),
|
|
|
|
|
|
]);
|
|
|
|
|
|
Notification::make()
|
|
|
|
|
|
->title('خطا در دریافت لاگهای پیامک')
|
|
|
|
|
|
->body('کد: ' . $response->status() . ' | ' . ($response->json('message') ?? $response->body()))
|
|
|
|
|
|
->danger()
|
|
|
|
|
|
->send();
|
|
|
|
|
|
return new LengthAwarePaginator([], 0, $recordsPerPage, $page);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$grouped = $response->json('data') ?? [];
|
|
|
|
|
|
|
|
|
|
|
|
$this->recalcStats($grouped);
|
|
|
|
|
|
|
|
|
|
|
|
$totalPatients = count($grouped);
|
|
|
|
|
|
$patientsOnPage = array_slice($grouped, ($page - 1) * $recordsPerPage, $recordsPerPage);
|
|
|
|
|
|
|
|
|
|
|
|
$rows = [];
|
|
|
|
|
|
foreach ($patientsOnPage as $patient) {
|
|
|
|
|
|
$patientKey = ($patient['phone_number'] ?? '') . '|' . ($patient['surgery_date'] ?? '');
|
|
|
|
|
|
|
|
|
|
|
|
foreach ($patient['messages'] ?? [] as $msg) {
|
|
|
|
|
|
$rows[] = [
|
|
|
|
|
|
'id' => $msg['id'],
|
|
|
|
|
|
'patient_key' => $patientKey,
|
|
|
|
|
|
'patient_name' => $patient['patient_name'] ?? '-',
|
|
|
|
|
|
'phone_number' => $patient['phone_number'] ?? '',
|
|
|
|
|
|
'surgery_date' => $patient['surgery_date'] ?? '',
|
|
|
|
|
|
'message_type' => $msg['message_type'] ?? '',
|
|
|
|
|
|
'status' => $msg['status'] ?? '',
|
|
|
|
|
|
'scheduled_at' => $msg['scheduled_at'] ?? null,
|
|
|
|
|
|
'sent_at' => $msg['sent_at'] ?? null,
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return new LengthAwarePaginator(
|
|
|
|
|
|
items: $rows,
|
|
|
|
|
|
total: $totalPatients,
|
|
|
|
|
|
perPage: $recordsPerPage,
|
|
|
|
|
|
currentPage: $page,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
} catch (\Throwable $e) {
|
|
|
|
|
|
\Illuminate\Support\Facades\Log::error('SmsLogsPage: API exception', [
|
|
|
|
|
|
'error' => $e->getMessage(),
|
|
|
|
|
|
'api_url' => \App\Models\OsurgInitial::val('sms_api_url', config('sms.api_url')),
|
|
|
|
|
|
]);
|
|
|
|
|
|
Notification::make()
|
|
|
|
|
|
->title('خطا در اتصال به سرویس پیامک')
|
|
|
|
|
|
->body($e->getMessage())
|
|
|
|
|
|
->danger()
|
|
|
|
|
|
->send();
|
|
|
|
|
|
return new LengthAwarePaginator([], 0, $recordsPerPage, $page);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public function deleteSingleMessage(int $id): void
|
|
|
|
|
|
{
|
|
|
|
|
|
try {
|
|
|
|
|
|
$response = Http::timeout(10)
|
|
|
|
|
|
->withHeader('X-API-Key', \App\Models\OsurgInitial::val('sms_api_key', config('sms.api_key')))
|
|
|
|
|
|
->delete(\App\Models\OsurgInitial::val('sms_api_url', config('sms.api_url')) . '/sms-logs/' . $id);
|
|
|
|
|
|
|
|
|
|
|
|
if ($response->json('success') ?? false) {
|
|
|
|
|
|
Notification::make()->title('پیام حذف شد')->success()->send();
|
|
|
|
|
|
$this->resetTable();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
Notification::make()->title($response->json('message') ?? 'خطا در حذف پیام')->danger()->send();
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (\Throwable $e) {
|
|
|
|
|
|
Notification::make()->title('خطا: ' . $e->getMessage())->danger()->send();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public function deletePatientMessages(string $phoneNumber, string $surgeryDate): void
|
|
|
|
|
|
{
|
|
|
|
|
|
try {
|
|
|
|
|
|
$response = Http::timeout(10)
|
|
|
|
|
|
->withHeader('X-API-Key', \App\Models\OsurgInitial::val('sms_api_key', config('sms.api_key')))
|
|
|
|
|
|
->post(\App\Models\OsurgInitial::val('sms_api_url', config('sms.api_url')) . '/sms-logs/delete-patient', [
|
|
|
|
|
|
'phoneNumber' => $phoneNumber,
|
|
|
|
|
|
'surgeryDate' => $surgeryDate,
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
if ($response->json('success') ?? false) {
|
|
|
|
|
|
Notification::make()->title('همه پیامهای بیمار حذف شد')->success()->send();
|
|
|
|
|
|
$this->resetTable();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
Notification::make()->title($response->json('message') ?? 'خطا در حذف پیامها')->danger()->send();
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (\Throwable $e) {
|
|
|
|
|
|
Notification::make()->title('خطا: ' . $e->getMessage())->danger()->send();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-17 16:45:38 +00:00
|
|
|
|
public static function cancelAppointmentMessages(string $phoneNumber, string $localSurgeryDate): void
|
|
|
|
|
|
{
|
|
|
|
|
|
try {
|
|
|
|
|
|
$apiKey = \App\Models\OsurgInitial::val('sms_api_key', config('sms.api_key'));
|
|
|
|
|
|
$apiUrl = \App\Models\OsurgInitial::val('sms_api_url', config('sms.api_url'));
|
|
|
|
|
|
$localDate = \Carbon\Carbon::parse($localSurgeryDate)->format('Y-m-d');
|
|
|
|
|
|
|
|
|
|
|
|
$grouped = Http::timeout(10)
|
|
|
|
|
|
->withHeader('X-API-Key', $apiKey)
|
|
|
|
|
|
->get($apiUrl . '/sms-logs-grouped', [
|
|
|
|
|
|
'phoneNumber' => $phoneNumber,
|
|
|
|
|
|
'limit' => 200,
|
|
|
|
|
|
])
|
|
|
|
|
|
->json('data') ?? [];
|
|
|
|
|
|
|
|
|
|
|
|
$serverSurgeryDate = null;
|
|
|
|
|
|
foreach ($grouped as $group) {
|
|
|
|
|
|
if (($group['phone_number'] ?? '') !== $phoneNumber) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
$groupDate = $group['surgery_date'] ?? '';
|
|
|
|
|
|
if (! $groupDate) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (\Carbon\Carbon::parse($groupDate)->format('Y-m-d') === $localDate) {
|
|
|
|
|
|
$serverSurgeryDate = $groupDate;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (\Throwable) {}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (! $serverSurgeryDate) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Http::timeout(10)
|
|
|
|
|
|
->withHeader('X-API-Key', $apiKey)
|
|
|
|
|
|
->asJson()
|
|
|
|
|
|
->post($apiUrl . '/sms-logs/delete-patient', [
|
|
|
|
|
|
'phoneNumber' => $phoneNumber,
|
|
|
|
|
|
'surgeryDate' => $serverSurgeryDate,
|
|
|
|
|
|
]);
|
|
|
|
|
|
} catch (\Throwable) {
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-03 13:55:17 +00:00
|
|
|
|
public function toJalali(?string $dateStr, string $format = 'Y/m/d H:i'): string
|
|
|
|
|
|
{
|
|
|
|
|
|
if (! $dateStr) {
|
|
|
|
|
|
return '-';
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
|
|
|
return Jalalian::fromCarbon(
|
|
|
|
|
|
Carbon::parse($dateStr, 'UTC')->setTimezone('Asia/Tehran')
|
|
|
|
|
|
)->format($format);
|
|
|
|
|
|
} catch (\Throwable) {
|
|
|
|
|
|
return $dateStr;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private function recalcStats(array $grouped): void
|
|
|
|
|
|
{
|
|
|
|
|
|
$total = $sent = $pending = $failed = 0;
|
|
|
|
|
|
|
|
|
|
|
|
foreach ($grouped as $p) {
|
|
|
|
|
|
$total += $p['stats']['total'] ?? 0;
|
|
|
|
|
|
$sent += $p['stats']['sent'] ?? 0;
|
|
|
|
|
|
$pending += $p['stats']['pending'] ?? 0;
|
|
|
|
|
|
$failed += $p['stats']['failed'] ?? 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$this->stats = [
|
|
|
|
|
|
'patients' => count($grouped),
|
|
|
|
|
|
'total' => $total,
|
|
|
|
|
|
'sent' => $sent,
|
|
|
|
|
|
'pending' => $pending,
|
|
|
|
|
|
'failed' => $failed,
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|