Compare commits

...

88 Commits
main ... master

Author SHA1 Message Date
8e3798b32b minor 2025-02-01 13:44:10 +06:00
7f5d594a24 seeding - images, rules 2025-02-01 13:41:03 +06:00
05b615fc75 misc 2025-02-01 13:01:05 +06:00
6a1dd35da5 phone number fix 2025-02-01 12:55:29 +06:00
ef2dd84cfb seeding from CSV 2025-02-01 12:46:34 +06:00
8565d35fe9 enhanced array trim 2025-02-01 12:46:23 +06:00
7e1d141338 seed 2025-02-01 12:46:11 +06:00
fcfe1278f7 FIX - array-trim 2025-02-01 12:25:37 +06:00
3ad9e8633e wip 2025-02-01 12:25:30 +06:00
10c96a54c4 users 2025-02-01 11:48:34 +06:00
0da8e2bc34 misc 2025-01-31 01:09:48 +06:00
049d59d39c wip - workflow steps 2025-01-31 00:42:58 +06:00
1f7764481c heroicon 2025-01-31 00:12:24 +06:00
4462bb1cfb minor 2025-01-30 21:29:33 +06:00
ef908dd4bd resource 2025-01-30 19:52:19 +06:00
19a501c2e2 FIX #41 - history indication 2025-01-30 19:41:41 +06:00
49b4341227 resource update 2025-01-30 19:30:15 +06:00
a7228b8a61 syntax 2025-01-30 01:40:17 +06:00
fac10cdc25 UI organization 2025-01-30 01:40:08 +06:00
d4c6e58e30 age - dob calculation 2025-01-30 01:35:07 +06:00
9e969df68b improved study parsing 2025-01-30 01:03:06 +06:00
a02adfc685 helper 2025-01-30 00:46:50 +06:00
ce28bb1422 minor 2025-01-30 00:39:19 +06:00
0fce30bd18 FIX - accepted 2025-01-30 00:39:14 +06:00
7a28b892f3 wip 2025-01-30 00:28:48 +06:00
60f32a6468 misc 2025-01-30 00:17:28 +06:00
4e2c1837ee wip 2025-01-30 00:09:14 +06:00
ea341ca8f4 fix relations 2025-01-29 23:53:11 +06:00
9f60cfb32e wip 2025-01-29 23:53:03 +06:00
97481e8473 Merge branch 'merge-statuses' 2025-01-29 23:30:39 +06:00
802f13c702 FIX #34 - merge 2025-01-29 23:30:22 +06:00
3a775c4f21 clean 2025-01-29 23:29:53 +06:00
927c51e16d minor 2025-01-29 23:01:14 +06:00
45d692968e version 2025-01-29 22:50:40 +06:00
72b1bbc0e7 minor 2025-01-29 22:26:44 +06:00
27c3cd5d8d wip dept-org 2025-01-29 22:20:34 +06:00
7f5e916d80 datatable 2025-01-29 22:12:47 +06:00
8853528b0a remove unneeded 2025-01-29 22:08:24 +06:00
3d5cceb125 FIX - value 2025-01-29 22:08:13 +06:00
db97aef03c footer version 2025-01-29 22:08:00 +06:00
ba705bb98b removed report_status 2025-01-29 21:47:50 +06:00
9e2315b89b sync 2025-01-29 21:28:39 +06:00
bdf1ad1ca3 workflow_level 2025-01-29 21:26:37 +06:00
5292865bef report workflow_level 2025-01-29 21:25:28 +06:00
d1d6f0d7af workflow_level 2025-01-29 21:24:45 +06:00
3f2a38faa6 workflow_level 2025-01-29 21:24:41 +06:00
ef170b5357 workflow_level 2025-01-29 21:20:42 +06:00
b32ebecfaa wf 2025-01-29 21:17:26 +06:00
15d8ebf3c7 removed report_status 2025-01-29 21:17:12 +06:00
6bffd31f2f FIX - role name 2025-01-29 21:05:29 +06:00
e9ada6f2b3 minor 2025-01-29 18:14:08 +06:00
a0834c511e country hints 2025-01-29 18:12:45 +06:00
93842ba548 labels 2025-01-29 18:07:20 +06:00
dc2d48cdb0 edit coutnries list 2025-01-29 18:06:27 +06:00
a2304d0245 removed notes button 2025-01-29 17:46:35 +06:00
9ceb1c3a34 FIX #40 - dob crash 2025-01-29 17:36:42 +06:00
da16e247c4 FIX #39 - unassign bug 2025-01-29 17:31:32 +06:00
d343d105fc filament - dicom server 2025-01-29 15:56:33 +06:00
0bed01ff68 enhancements 2025-01-29 15:39:57 +06:00
db4a6901a4 minor 2025-01-29 14:54:05 +06:00
3ea8245f85 filament user 2025-01-29 14:53:59 +06:00
96d7d4048e fix department model 2025-01-29 10:45:52 +06:00
ed6e462f2c timezones 2025-01-29 10:45:41 +06:00
235bc0b405 fixed User model 2025-01-29 09:37:15 +06:00
2a2370b3a5 roles 2025-01-29 09:37:03 +06:00
70b82cd410 readme 2025-01-29 08:34:04 +06:00
103df6436d error handling 2025-01-28 23:39:57 +06:00
90dce16aea pacs servers 2025-01-28 23:21:15 +06:00
7455709731 FIX - limit scope to dicom server 2025-01-28 22:24:54 +06:00
658adbb6be removed uuid from details table 2025-01-28 22:24:20 +06:00
e20f7aa703 minor 2025-01-28 22:20:17 +06:00
d08cdd6997 minor 2025-01-28 22:11:12 +06:00
3b9f677804 dicom DDL 2025-01-28 22:04:40 +06:00
9c6dddb7a0 FIX / UPDATE changes 2025-01-28 22:04:26 +06:00
44acb63d3a readme 2025-01-27 19:28:35 +06:00
Masroor Ehsan
4a79935e4c
Merge pull request #36 from masroore/main
new rules engine
2025-01-27 19:25:51 +06:00
1b5faca946 fix rules 2025-01-27 19:18:15 +06:00
d6432ac054 FIX #35 - new rules engine 2025-01-27 19:16:02 +06:00
cd5a95e53c rules fix 2025-01-27 19:10:16 +06:00
fcb0dc17fd matching 2025-01-27 19:10:08 +06:00
7e150c972b FIX non existant expression 2025-01-27 18:50:03 +06:00
57b24f5876 misc 2025-01-27 18:49:53 +06:00
8648d1ae1e seeder 2025-01-27 18:34:03 +06:00
9d13f0570c snake_cased dicom tags 2025-01-27 17:52:22 +06:00
b2726911e7 dicom headers contain header name 2025-01-27 17:52:08 +06:00
ce9669b556 wip rules engine 2025-01-27 17:36:22 +06:00
21b24fe542 wip new rules engine 2025-01-27 17:34:59 +06:00
Masroor Ehsan
1e0c62ce92
Merge pull request #33 from masroore/main
wip - bookmarks
2025-01-26 00:10:30 +06:00
88 changed files with 2345 additions and 614 deletions

View File

@ -1,4 +1,5 @@
APP_NAME=Laravel
APP_NAME="PixelBridge"
APP_VERSION="25.01-0.1.0"
APP_ENV=local
APP_KEY=
APP_DEBUG=true

View File

@ -1,8 +1,8 @@
# RadFusion PACS
# PixelBridge RIS
## Introduction
RadFusion PACS is a comprehensive Picture Archiving and Communication System (PACS) designed to streamline the
PixelBridge RIS is a comprehensive RIS (Radiology Information System) designed to streamline the
management and viewing of medical imaging studies.
## Requirements
@ -17,8 +17,8 @@ ## Installation
1. **Clone the repository:**
```sh
git clone https://github.com/masroore/radfusion-pacs.git
cd radfusion-pacs
git clone https://github.com/masroore/pixelbridge-pacs.git
cd pixelbridge-pacs
```
2. **Install dependencies:**

View File

@ -2,12 +2,13 @@
namespace App\DAL;
use App\Domain\ACL\Role;
use App\Services\UserService;
use Illuminate\Support\Facades\DB;
final readonly class Radiologists
{
public static function worklist_stats(int $days, int $report_status)
public static function worklist_stats(int $days, int $workflow_level)
{
$sql = <<<'SQL'
SELECT
@ -28,12 +29,12 @@ public static function worklist_stats(int $days, int $report_status)
WHERE
--st.received_at :: DATE >= NOW() - INTERVAL '3 DAY'
st.received_at::DATE >= NOW() - INTERVAL '%d DAY'
AND st.report_status < %d
AND st.workflow_level < %d
AND st.read_at IS NULL
GROUP BY
sa.user_id) AS cte ON cte.user_id = users."id"
WHERE
roles."name" = 'radiologist'
roles."name" = '%s'
AND users.is_active = TRUE
GROUP BY
users."id",
@ -42,11 +43,26 @@ public static function worklist_stats(int $days, int $report_status)
users.display_name
SQL;
$rows = DB::select(sprintf($sql, $days, $report_status));
$rows = DB::select(sprintf($sql, $days, $workflow_level, Role::Radiologist->value));
foreach ($rows as $row) {
$row->last_seen = UserService::getLastSeen((int) $row->id);
}
return $rows;
}
public static function activeRads(): array
{
return cache()
->remember('active_rads',
now()->addMinutes(5),
fn () => DB::table('users')
->join('model_has_roles', 'users.id', '=', 'model_has_roles.model_id')
->join('roles', 'model_has_roles.role_id', '=', 'roles.id')
->where('roles.name', Role::Radiologist->value)
->where('users.is_active', true)
->orderBy('users.display_name')
->pluck('users.display_name', 'users.id')
->toArray());
}
}

View File

@ -2,7 +2,6 @@
namespace App\DAL\Studies;
use App\Domain\Report\ReportStatus;
use App\Domain\Study\WorkflowLevel;
use Illuminate\Contracts\Database\Eloquent\Builder;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
@ -11,9 +10,7 @@ interface IUserStudyLister
{
public function setRadiologist(int $radiologist_id): self;
public function setWorkflowLevel(WorkflowLevel $status): self;
public function setReportStatus(ReportStatus $status): self;
public function setWorkflowLevel(WorkflowLevel $level): self;
public function setPerPage(int $size): self;

View File

@ -2,7 +2,6 @@
namespace App\DAL\Studies;
use App\Domain\Report\ReportStatus;
use App\Domain\Study\WorkflowLevel;
use App\Models\Study;
use Carbon\Carbon;
@ -21,8 +20,6 @@ abstract class WorklistBase implements IUserStudyLister
private ?WorkflowLevel $workflowLevel = null;
private ?ReportStatus $reportStatus = null;
private ?bool $locked = null;
private ?bool $archived = null;
@ -41,7 +38,7 @@ abstract class WorklistBase implements IUserStudyLister
protected static function reportCompleteQuery(Builder $query): Builder
{
return $query->where('report_status', '=', ReportStatus::Approved->value);
return $query->where('workflow_level', '=', WorkflowLevel::Published->value);
}
public function setRadiologist(int $radiologist_id): self
@ -51,16 +48,9 @@ public function setRadiologist(int $radiologist_id): self
return $this;
}
public function setWorkflowLevel(WorkflowLevel $status): self
public function setWorkflowLevel(WorkflowLevel $level): self
{
$this->workflowLevel = $status;
return $this;
}
public function setReportStatus(ReportStatus $status): self
{
$this->reportStatus = $status;
$this->workflowLevel = $level;
return $this;
}
@ -81,7 +71,6 @@ public function get(?int $user_id = null): LengthAwarePaginator
$query = $this->applySearch($query);
$query = $this->applyRadiologist($query);
$query = $this->applyWorkflowLevel($query);
$query = $this->applyReportStatus($query);
$query = $this->applyArchived($query);
$query = $this->applyLocked($query);
$query = $this->applyDateFilters($query);
@ -236,15 +225,6 @@ private function applyWorkflowLevel(Builder $query): Builder
return $query;
}
private function applyReportStatus(Builder $query): Builder
{
if ($this->reportStatus != null) {
$query = $query->where('report_status', '=', $this->reportStatus->value);
}
return $query;
}
private function getPageSize(?int $user_id = null): int
{
return $this->perPage ?? user_per_page($user_id);

View File

@ -3,11 +3,12 @@
namespace App\DataTables;
use App\DAL\Studies\WorklistFactory;
use App\Domain\Report\ReportStatus;
use App\Domain\Study\WorkflowLevel;
use App\Models\Study;
use App\Services\ACL\WorklistButton;
use App\Services\ACL\WorklistColumn;
use App\Services\ACL\WorklistGuard;
use App\Services\GeoLocation;
use Carbon\Carbon;
use Carbon\CarbonImmutable;
use Closure;
@ -46,7 +47,7 @@ public function dataTable(QueryBuilder $query): EloquentDataTable
$dataTable = new EloquentDataTable($query);
$rawColumns = [
'priority_icon',
'report_status_led',
'workflow_level_led',
];
foreach ($this->renderCustomColumns() as $column => $content) {
$dataTable->addColumn($column, $content);
@ -147,11 +148,11 @@ public function getColumns(): array
->title('');
break;
case WorklistColumn::ReportStatus:
case WorklistColumn::WorkflowLevel:
$columns[] = Column::make($col->value)
->searchable(false)
->hidden();
$columns[] = Column::make('report_status_led')
$columns[] = Column::make('workflow_level_led')
->searchable(false)
->orderable(false)
->addClass('text-center p-0 ps-2')
@ -329,10 +330,10 @@ private function filterStatus(QueryBuilder $query, ?string $status): void
switch ($status) {
case 'unread':
$query->where('report_status', '<', ReportStatus::Finalized->value);
$query->where('workflow_level', '<', WorkflowLevel::Finalized->value);
break;
case 'read':
$query->where('report_status', '>=', ReportStatus::Finalized->value);
$query->where('workflow_level', '>=', WorkflowLevel::Finalized->value);
break;
case 'progress':
$query->whereNotNull('locked_at');
@ -514,8 +515,12 @@ private function renderCustomColumns(): array
break;
case WorklistColumn::DicomServer:
$columns[$col->value] = function (Study $study) {
return sprintf('<span class="fi fi-%s msg-icon me-1"></span>%s',
$country = sprintf('%s (%s)', GeoLocation::name($study->dicomServer->geo_code), $study->dicomServer->geo_code);
return sprintf('<span class="fi fi-%s msg-icon me-1" data-bs-toggle="tooltip" data-bs-placement="top" data-bs-original-title="%s" title="%s"></span>%s',
strtolower($study->dicomServer->geo_code),
$country,
$country,
$study->dicomServer->server_name);
};
break;
@ -622,7 +627,7 @@ private function generateActionButtons(Study $study): string
foreach (WorklistGuard::worklistButtons($study) as $button) {
switch ($button) {
case WorklistButton::StudyMetadata:
$btns[] = $this->renderImageLink($study->hash, 'info.png', 'show-study', 'Info');
$btns[] = $this->renderImageLink($study->hash, $study->hasHistory() ? 'info-green.png' : 'info.png', 'show-study', 'Info');
break;
case WorklistButton::Assign:
$btns[] = $this->renderImageLink($study->hash, 'assign.png', 'show-assign', 'Assign');

View File

@ -6,13 +6,14 @@ enum Role: string
{
case Guest = 'guest';
case Patient = 'patient';
case Clinician = 'clinician';
case Technician = 'technician';
case Radiologist = 'radiologist';
case Associate = 'associate';
case SystemAgent = 'system_agent';
case SiteAdmin = 'site_admin';
case Clinician = 'clinic';
case Technician = 'tech';
case Radiologist = 'rad';
case Reviewer = 'review';
case Associate = 'assoc';
case SystemAgent = 'sys';
case SiteAdmin = 'site-admin';
case Clerk = 'clerk';
case Transcriptionist = 'transcriptionist';
case Transcriptionist = 'trans';
case Admin = 'admin';
}

View File

@ -1,14 +0,0 @@
<?php
namespace App\Domain\Report;
enum ReportStatus: int
{
case Unread = 0;
case Dictated = 10;
case Transcribed = 20;
case Preliminary = 30;
case Finalized = 40;
case Approved = 90;
case Cancelled = 199;
}

View File

@ -4,9 +4,36 @@
enum WorkflowLevel: int
{
case Pending = 0;
case Unassigned = 10;
case Assigned = 20;
case ReadInProgress = 30;
case ReadCompleted = 40;
case Received = 10;
case Unassigned = 20;
case Assigned = 30;
case Dictated = 40;
case Transcribed = 50;
case Repetition = 60;
case ReadInProgress = 70;
case DraftAvailable = 80;
case Finalized = 90;
case UnderReview = 100;
case Published = 110;
case Archived = 200;
case Cancelled = 240;
public function describe(): string
{
return match ($this) {
self::Received => 'Study Received',
self::Unassigned => 'Unassigned study',
self::Assigned => 'Pending read',
self::Dictated => 'Dictated',
self::Transcribed => 'Transcribed',
self::Repetition => 'Repeat interpretation',
self::ReadInProgress => 'Read In Progress',
self::DraftAvailable => 'Draft available',
self::Finalized => 'Report finalized',
self::UnderReview => 'Under Review',
self::Published => 'Report published',
self::Archived => 'Study archived',
self::Cancelled => 'Cancelled',
};
}
}

View File

@ -0,0 +1,93 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\DepartmentResource\Pages;
use App\Models\Department;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Support\Str;
use Ramsey\Uuid\Uuid;
class DepartmentResource extends Resource
{
protected static ?string $model = Department::class;
protected static ?string $navigationIcon = 'heroicon-o-building-storefront';
public static function form(Form $form): Form
{
return $form
->schema([
TextInput::make('guid')
->label('Unique ID')
->default(sprintf('DEP-%s', Str::of(Uuid::uuid4())->lower()))
->disabled()
->dehydrated()
->required()
->maxLength(40)
->unique(ignoreRecord: true),
Toggle::make('is_active')
->required(),
Select::make('organization_id')
->relationship('organization', 'name')
->required(),
TextInput::make('name')
->required()
->maxLength(255),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
IconColumn::make('is_active')->boolean(),
TextColumn::make('name')->searchable(),
TextColumn::make('organization.name')->sortable(),
TextColumn::make('guid')->searchable(),
TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('updated_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
//
])
->actions([
Tables\Actions\EditAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListDepartments::route('/'),
'create' => Pages\CreateDepartment::route('/create'),
'edit' => Pages\EditDepartment::route('/{record}/edit'),
];
}
}

View File

@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\DepartmentResource\Pages;
use App\Filament\Resources\DepartmentResource;
use Filament\Resources\Pages\CreateRecord;
class CreateDepartment extends CreateRecord
{
protected static string $resource = DepartmentResource::class;
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\DepartmentResource\Pages;
use App\Filament\Resources\DepartmentResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditDepartment extends EditRecord
{
protected static string $resource = DepartmentResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make(),
];
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\DepartmentResource\Pages;
use App\Filament\Resources\DepartmentResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListDepartments extends ListRecords
{
protected static string $resource = DepartmentResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}

View File

@ -0,0 +1,126 @@
<?php
namespace App\Filament\Resources;
use App\DAL\Radiologists;
use App\Filament\Resources\DicomRoutingRuleResource\Pages;
use App\Models\DicomRoutingRule;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class DicomRoutingRuleResource extends Resource
{
protected static ?string $model = DicomRoutingRule::class;
protected static ?string $modelLabel = 'Routing Rules';
protected static ?string $navigationIcon = 'heroicon-o-academic-cap';
public static function form(Form $form): Form
{
return $form
->schema([
Toggle::make('is_active')
->required(),
Select::make('organization_id')
->label('Organization')
->relationship('organization', 'name')
->required(),
Select::make('department_id')
->label('Department')
->relationship('department', 'name'),
Select::make('user_id')
->label('Radiologist')
->relationship('radiologist', 'display_name')
->options(Radiologists::activeRads()),
Select::make('assignment_panel_id')
->label('Panel')
->relationship('panel', 'name'),
TextInput::make('name')
->maxLength(255),
Textarea::make('condition')
->columnSpanFull()
->required(),
TextInput::make('priority')
->required()
->numeric()
->default(0),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
IconColumn::make('is_active')
->label('')
->boolean(),
TextColumn::make('name')
->searchable(),
TextColumn::make('condition')
->label('Rule')
->limit(20)
->searchable(),
TextColumn::make('priority')
->numeric()
->sortable(),
TextColumn::make('organization.name')
->label('Org')
->numeric()
->sortable(),
TextColumn::make('department.name')
->label('Dept')
->numeric()
->sortable(),
TextColumn::make('user_id')
->label('Rad')
->numeric()
->sortable(),
TextColumn::make('assignment_panel_id')
->label('Panel')
->numeric()
->sortable(),
TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('updated_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
//
])
->actions([
Tables\Actions\EditAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListDicomRoutingRules::route('/'),
'create' => Pages\CreateDicomRoutingRule::route('/create'),
'edit' => Pages\EditDicomRoutingRule::route('/{record}/edit'),
];
}
}

View File

@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\DicomRoutingRuleResource\Pages;
use App\Filament\Resources\DicomRoutingRuleResource;
use Filament\Resources\Pages\CreateRecord;
class CreateDicomRoutingRule extends CreateRecord
{
protected static string $resource = DicomRoutingRuleResource::class;
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\DicomRoutingRuleResource\Pages;
use App\Filament\Resources\DicomRoutingRuleResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditDicomRoutingRule extends EditRecord
{
protected static string $resource = DicomRoutingRuleResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make(),
];
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\DicomRoutingRuleResource\Pages;
use App\Filament\Resources\DicomRoutingRuleResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListDicomRoutingRules extends ListRecords
{
protected static string $resource = DicomRoutingRuleResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}

View File

@ -0,0 +1,150 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\DicomServerResource\Pages;
use App\Models\Department;
use App\Models\DicomServer;
use App\Services\GeoLocation;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Form;
use Filament\Forms\Get;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class DicomServerResource extends Resource
{
protected static ?string $model = DicomServer::class;
protected static ?string $navigationIcon = 'heroicon-o-server-stack';
public static function form(Form $form): Form
{
return $form
->schema([
Toggle::make('is_active')
->label('Active?')
->required(),
TextInput::make('server_name')
->required()
->unique(ignoreRecord: true)
->maxLength(255),
Select::make('geo_code')
->required()
->label('GEO Location')
->options(GeoLocation::select()),
TextInput::make('host')
->required()
->maxLength(255),
TextInput::make('http_port')
->required()
->numeric(),
TextInput::make('dicom_port')
->required()
->numeric(),
TextInput::make('rest_api_endpoint')
->required()
->maxLength(255),
TextInput::make('ae_title')
->maxLength(24),
TextInput::make('username')
->maxLength(40),
TextInput::make('password')
->maxLength(40),
TextInput::make('wado_path')
->maxLength(255),
TextInput::make('stone_viewer_path')
->maxLength(255),
TextInput::make('ohif_viewer_path')
->maxLength(255),
TextInput::make('meddream_viewer_path')
->maxLength(255),
Select::make('organization_id')
->label('Organization')
->live()
->relationship('organization', 'name'),
Select::make('department_id')
->label('Department')
->options(function (Get $get) {
$organization_id = $get('organization_id');
$result = [];
if ($organization_id != null) {
$result = Department::active()->organization($organization_id)->pluck('name', 'id')->toArray();
}
return $result;
}),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
IconColumn::make('is_active')
->label('')
->boolean(),
TextColumn::make('server_name')
->label('Name')
->searchable(),
TextColumn::make('geo_code')
->label('GEO')
->searchable(),
TextColumn::make('host')
->searchable(),
TextColumn::make('http_port')
->label('WADO'),
TextColumn::make('dicom_port')
->label('DICOM'),
TextColumn::make('ae_title')
->label('AET')
->searchable(),
TextColumn::make('rest_api_endpoint')
->label('REST')
->url(fn (DicomServer $srv) => $srv->rest_api_endpoint, shouldOpenInNewTab: true)
->searchable(),
TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('updated_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
//
])
->actions([
Tables\Actions\ViewAction::make(),
Tables\Actions\EditAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListDicomServers::route('/'),
'create' => Pages\CreateDicomServer::route('/create'),
'view' => Pages\ViewDicomServer::route('/{record}'),
'edit' => Pages\EditDicomServer::route('/{record}/edit'),
];
}
}

View File

@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\DicomServerResource\Pages;
use App\Filament\Resources\DicomServerResource;
use Filament\Resources\Pages\CreateRecord;
class CreateDicomServer extends CreateRecord
{
protected static string $resource = DicomServerResource::class;
}

View File

@ -0,0 +1,20 @@
<?php
namespace App\Filament\Resources\DicomServerResource\Pages;
use App\Filament\Resources\DicomServerResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditDicomServer extends EditRecord
{
protected static string $resource = DicomServerResource::class;
protected function getHeaderActions(): array
{
return [
Actions\ViewAction::make(),
Actions\DeleteAction::make(),
];
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\DicomServerResource\Pages;
use App\Filament\Resources\DicomServerResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListDicomServers extends ListRecords
{
protected static string $resource = DicomServerResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\DicomServerResource\Pages;
use App\Filament\Resources\DicomServerResource;
use Filament\Actions;
use Filament\Resources\Pages\ViewRecord;
class ViewDicomServer extends ViewRecord
{
protected static string $resource = DicomServerResource::class;
protected function getHeaderActions(): array
{
return [
Actions\EditAction::make(),
];
}
}

View File

@ -0,0 +1,96 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\OrganizationResource\Pages;
use App\Models\Organization;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables\Actions\BulkActionGroup;
use Filament\Tables\Actions\DeleteBulkAction;
use Filament\Tables\Actions\EditAction;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Support\Str;
use Ramsey\Uuid\Uuid;
class OrganizationResource extends Resource
{
protected static ?string $model = Organization::class;
protected static ?string $navigationIcon = 'heroicon-o-building-office-2';
public static function form(Form $form): Form
{
return $form
->schema([
TextInput::make('guid')
->label('Unique ID')
->default(sprintf('ORG-%s', Str::of(Uuid::uuid4())->lower()))
->disabled()
->dehydrated()
->required()
->maxLength(40)
->unique(ignoreRecord: true),
Toggle::make('is_active')
->required(),
TextInput::make('name')
->required()
->unique(ignoreRecord: true)
->maxLength(255),
TextArea::make('address')
->maxLength(255),
TextInput::make('logo_path')
->maxLength(255),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
IconColumn::make('is_active')->boolean(),
TextColumn::make('name')->searchable(),
TextColumn::make('guid')->searchable(),
TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('updated_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
//
])
->actions([
EditAction::make(),
])
->bulkActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListOrganizations::route('/'),
'create' => Pages\CreateOrganization::route('/create'),
'edit' => Pages\EditOrganization::route('/{record}/edit'),
];
}
}

View File

@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\OrganizationResource\Pages;
use App\Filament\Resources\OrganizationResource;
use Filament\Resources\Pages\CreateRecord;
class CreateOrganization extends CreateRecord
{
protected static string $resource = OrganizationResource::class;
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\OrganizationResource\Pages;
use App\Filament\Resources\OrganizationResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditOrganization extends EditRecord
{
protected static string $resource = OrganizationResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make(),
];
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\OrganizationResource\Pages;
use App\Filament\Resources\OrganizationResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListOrganizations extends ListRecords
{
protected static string $resource = OrganizationResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}

View File

@ -0,0 +1,195 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\UserResource\Pages;
use App\Models\Department;
use App\Models\User;
use App\Services\ACL\RoleService;
use App\Services\TimezoneList;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Form;
use Filament\Forms\Get;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Actions\BulkActionGroup;
use Filament\Tables\Actions\EditAction;
use Filament\Tables\Actions\ViewAction;
use Filament\Tables\Columns\ImageColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
use Ramsey\Uuid\Uuid;
class UserResource extends Resource
{
protected static ?string $model = User::class;
protected static ?string $navigationIcon = 'heroicon-o-users';
public static function form(Form $form): Form
{
$isCreate = $form->getOperation() === 'create';
return $form
->schema([
TextInput::make('guid')
->label('Unique ID')
->default(sprintf('USR-%s', Str::of(Uuid::uuid4())->lower()))
->disabled()
->dehydrated()
->required()
->maxLength(40)
->unique(ignoreRecord: true),
Toggle::make('is_active')
->required(),
TextInput::make('prefix')
->maxLength(80),
TextInput::make('first_name')
->required()
->maxLength(120),
TextInput::make('last_name')
->maxLength(120),
TextInput::make('display_name')
->required()
->maxLength(160),
TextInput::make('username')
->unique(ignoreRecord: true)
->required()
->maxLength(24),
TextInput::make('password')
->password()
->revealable()
->dehydrated(fn ($state) => filled($state))
->dehydrateStateUsing(fn ($state) => Hash::make($state))
->required(fn (string $context): bool => $context === 'create')
->maxLength(32),
TextInput::make('phone')
->unique(ignoreRecord: true)
->tel()
->telRegex('/^\+?[1-9]\d{8,14}$/')
->maxLength(80),
TextInput::make('email')
->unique(ignoreRecord: true)
->email()
->maxLength(80),
DateTimePicker::make('email_verified_at'),
TextInput::make('profile_photo_path')
->maxLength(255),
FileUpload::make('signature_image_path')
->disk('public')
->visibility('public')
->directory('signatures')
->preserveFilenames()
->getUploadedFileNameForStorageUsing(
function (TemporaryUploadedFile $file) {
return Str::of(pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME))
->slug()
->prepend(now()->timestamp . '_')
->append('.' . $file->getClientOriginalExtension());
}
)
->image(),
Textarea::make('signature_text')
->columnSpanFull(),
Select::make('organization_id')
->label('Organization')
->live()
->relationship('organization', 'name'),
Select::make('department_id')
->label('Department')
->options(function (Get $get) {
$organization_id = $get('organization_id');
$result = [];
if ($organization_id != null) {
$result = Department::active()->organization($organization_id)->pluck('name', 'id')->toArray();
}
return $result;
}),
Select::make('roles')
->relationship('roles', 'name')
->multiple()
->required(fn (string $context): bool => $context === 'create')
->searchable()
// ->options(RoleService::select())
->afterStateUpdated(function ($state, User $user) {
$user->assignRole($state);
}),
Select::make('timezone')
->options(
(new TimezoneList)->splitGroup(true)->toArray(false)
)
->required()
->default('Asia/Dhaka'),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\IconColumn::make('is_active')
->label('Active?')
->boolean(),
TextColumn::make('display_name')
->label('Name')
->searchable(),
TextColumn::make('username')
->searchable(),
TextColumn::make('email')
->searchable(),
// ImageColumn::make('signature_image_path'),
TextColumn::make('organization.name')
->label('Org')
->badge()
->sortable(),
TextColumn::make('department.name')
->label('Dept')
->sortable(),
TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('guid')
->limit(16)
->searchable(),
])
->filters([
//
])
->actions([
ViewAction::make(),
EditAction::make(),
])
->bulkActions([
BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListUsers::route('/'),
'create' => Pages\CreateUser::route('/create'),
'view' => Pages\ViewUser::route('/{record}'),
'edit' => Pages\EditUser::route('/{record}/edit'),
];
}
}

View File

@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource;
use Filament\Resources\Pages\CreateRecord;
class CreateUser extends CreateRecord
{
protected static string $resource = UserResource::class;
}

View File

@ -0,0 +1,20 @@
<?php
namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditUser extends EditRecord
{
protected static string $resource = UserResource::class;
protected function getHeaderActions(): array
{
return [
Actions\ViewAction::make(),
Actions\DeleteAction::make(),
];
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListUsers extends ListRecords
{
protected static string $resource = UserResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource;
use Filament\Actions;
use Filament\Resources\Pages\ViewRecord;
class ViewUser extends ViewRecord
{
protected static string $resource = UserResource::class;
protected function getHeaderActions(): array
{
return [
Actions\EditAction::make(),
];
}
}

View File

@ -5,12 +5,11 @@
use App\DAL\Radiologists;
use App\Domain\ACL\Permission;
use App\Domain\ACL\Role;
use App\Domain\Report\ReportStatus;
use App\Domain\Study\WorkflowLevel;
use App\Http\Controllers\HashedStudyControllerBase;
use App\Http\Requests\AssignPhysicianRequest;
use App\Models\User;
use App\Services\AuditTrail\Activity;
use Carbon\Carbon;
class AssignmentController extends HashedStudyControllerBase
{
@ -18,13 +17,17 @@ public function show()
{
abort_unless(me()->may(Permission::AssignRadiologist), 403);
$study = $this->getStudy('assignedPhysicians');
$rads = User::active()->role(Role::Radiologist)->get(['id', 'display_name', 'profile_photo_path', 'first_name', 'last_name', 'created_at']);
$stats = Radiologists::worklist_stats(3, ReportStatus::Finalized->value);
$rads = User::active()
->role(Role::Radiologist)
->get(['id', 'display_name', 'profile_photo_path', 'first_name', 'last_name', 'created_at'])
->each(fn ($rad) => $rad->info = ['workload' => '', 'last_seen' => '']);
$stats = Radiologists::worklist_stats(3, WorkflowLevel::Finalized->value);
foreach ($stats as $rad) {
$found = $rads->where('id', $rad->id)->first();
if ($found) {
$found->info['workload'] = $rad->workload;
$found->info['last_seen'] = ($rad->last_seen ?? Carbon::now()->addHours(-random_int(1, 36)))->diffForHumans();
$found->info['last_seen'] = $rad->last_seen?->diffForHumans() ?? '-';
}
}
@ -35,16 +38,21 @@ public function remove(AssignPhysicianRequest $request)
{
abort_unless(me()->may(Permission::AssignRadiologist), 403);
$study = $this->getStudy('assignedPhysicians');
$user = User::active()->findOrFail($request->input('rad_id'));
$rad = User::active()->findOrFail($request->input('rad_id'));
if ($study->isAssigned($user)) {
$study->assignedPhysicians()->detach($user->id);
if ($study->assignedPhysicians->count() === 0) {
$study->update(['assigned_at' => null]);
if ($study->isAssigned($rad)) {
$study->assignedPhysicians()->detach($rad->id);
if ($study->assignedPhysicians()->count() === 0) {
$study->update([
'workflow_level' => WorkflowLevel::Unassigned->value,
'assigned_at' => null,
]);
}
audit()
->did(Activity::Unassign_Physician)
->notes($user->display_name)
->notes($rad->display_name)
->on($study)
->log();
}
@ -59,7 +67,10 @@ public function save(AssignPhysicianRequest $request)
$rad = User::active()->findOrFail($request->input('rad_id'));
$study->assignedPhysicians()->attach($rad->id);
$study->update(['assigned_at' => now()]);
$study->update([
'workflow_level' => WorkflowLevel::Assigned->value,
'assigned_at' => now(),
]);
audit()
->did(Activity::Assign_Physician)
->on($study)

View File

@ -2,13 +2,13 @@
namespace App\Http\Controllers\Staff;
use App\Http\Controllers\HashidControllerBase;
use App\Http\Controllers\HashedStudyControllerBase;
use App\Models\Study;
use App\Services\AuditTrail\Activity;
use Closure;
use Illuminate\Support\Str;
class DicomViewerController extends HashidControllerBase
class DicomViewerController extends HashedStudyControllerBase
{
public function stone()
{
@ -22,8 +22,7 @@ public function ohif()
private function loadViewer(Closure $callback)
{
$this->decodeKeys();
$study = Study::findOrFail($this->key);
$study = $this->getStudy();
$url = $callback($study);
abort_if(blank($url), 404);
$title = Str::limit($study->getPatientDemographic(), 40);

View File

@ -9,12 +9,13 @@
use App\Models\StudyDetails;
use App\Services\AuditTrail\Activity;
use App\Services\SessionHelper;
use Illuminate\Support\Facades\Cache;
class HistoryController extends HashidControllerBase
{
public function view()
{
abort_unless(auth()->user()->may(Permission::StudyHistoryView), 403);
abort_unless(may(Permission::StudyHistoryView), 403);
$this->decodeKeys();
$details = StudyDetails::historyOnly($this->key);
$study = Study::findOrFail($this->key);
@ -25,7 +26,7 @@ public function view()
public function edit()
{
// SessionHelper::setIntendedUrl();
abort_unless(auth()->user()->may(Permission::StudyHistoryEdit), 403);
abort_unless(may(Permission::StudyHistoryEdit), 403);
$this->decodeKeys();
$details = StudyDetails::historyOnly($this->key);
$study = Study::findOrFail($this->key);
@ -35,12 +36,13 @@ public function edit()
public function save(StudyHistoryRequest $request)
{
abort_unless(auth()->user()->may(Permission::StudyHistoryEdit), 403);
abort_unless(may(Permission::StudyHistoryEdit), 403);
$this->decodeKeys();
$details = StudyDetails::historyOnly($this->key);
$payload = array_trim_strings($request->validated());
unset($payload['study_id']);
$details->update($payload);
Cache::forget("study.has_history.{$this->key}");
audit()
->did(Activity::Study_History_Edit)

View File

@ -3,7 +3,7 @@
namespace App\Http\Controllers\Staff;
use App\Domain\ACL\Permission;
use App\Domain\Report\ReportStatus;
use App\Domain\Study\WorkflowLevel;
use App\Http\Controllers\HashedStudyControllerBase;
use App\Http\Requests\StudyMetadataUpdateRequest;
use App\Models\Study;
@ -32,7 +32,7 @@ public function edit()
public function save(StudyMetadataUpdateRequest $request)
{
abort_unless(auth()->user()->may(Permission::StudyMetadataEdit), 403);
abort_unless(may(Permission::StudyMetadataEdit), 403);
$study = $this->getStudy();
if ($study->isReportReady()) {
return $this->lockedNotice();
@ -43,7 +43,7 @@ public function save(StudyMetadataUpdateRequest $request)
$payload['patient_sex'] = strtoupper($payload['patient_sex']);
if ($request->has('cancel_read')) {
// lock the study if report is not needed
$payload['report_status'] = ReportStatus::Cancelled->value;
$payload['workflow_level'] = WorkflowLevel::Cancelled->value;
$payload['locked_at'] = now();
unset($payload['cancel_read']);
}

View File

@ -2,7 +2,7 @@
namespace App\Http\Controllers\Staff;
use App\Domain\Report\ReportStatus;
use App\Domain\Study\WorkflowLevel;
use App\Http\Controllers\HashidControllerBase;
use App\Http\Requests\StoreReportRequest;
use App\Models\Study;
@ -39,10 +39,10 @@ public function save(StoreReportRequest $request)
ReportManager::ensureEditAccess();
$this->decodeKeys();
$manager = ReportManager::make($this->key);
$reportStatus = ReportStatus::from($request->integer('report_status'));
$report = $manager->createReport(request('content'), $reportStatus);
$workflow_level = WorkflowLevel::from($request->integer('report_status'));
$report = $manager->createReport(request('content'), $workflow_level);
if ($reportStatus->value === ReportStatus::Finalized->value) {
if ($workflow_level->value === WorkflowLevel::Finalized->value) {
$manager->finalizeReport($report);
}

View File

@ -10,8 +10,8 @@ class SyncOrthancController extends Controller
{
public function __invoke()
{
foreach (DicomServer::active()->get() as $host) {
(new StudiesSync($host))->execute();
foreach (DicomServer::active()->get() as $pacs) {
(new StudiesSync($pacs))->execute();
}
return redirect()->route('staff.worklist.index');

View File

@ -2,7 +2,7 @@
namespace App\Http\Requests;
use App\Domain\Report\ReportStatus;
use App\Domain\Study\WorkflowLevel;
use App\Models\Study;
use App\Rules\ExistsByHash;
use Illuminate\Foundation\Http\FormRequest;
@ -22,7 +22,7 @@ public function rules(): array
'content' => 'required',
'report_status' => [
'required',
Rule::enum(ReportStatus::class)->only([ReportStatus::Preliminary, ReportStatus::Finalized, ReportStatus::Approved]),
Rule::enum(WorkflowLevel::class)->only([WorkflowLevel::DraftAvailable, WorkflowLevel::Finalized, WorkflowLevel::Published]),
],
];
}

View File

@ -21,7 +21,7 @@ public function rules(): array
'referring_physician_name' => ['nullable'],
'institution_name' => ['nullable'],
'priority' => ['required', 'integer'],
'cancel_read' => ['nullable', 'boolean'],
'cancel_read' => ['accepted'],
/*
'referring_physician_id' => ['nullable', 'exists:users,id'],

View File

@ -3,17 +3,12 @@
namespace App\Models;
use App\Models\Traits\Active;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use App\Models\Traits\HasOrganization;
class Department extends Model
class Department extends BaseModel
{
use Active;
public function organization(): BelongsTo
{
return $this->belongsTo(Organization::class);
}
use HasOrganization;
protected function casts(): array
{

View File

@ -2,21 +2,13 @@
namespace App\Models;
use App\Domain\Rule\MatchCondition;
use App\Models\Traits\Active;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
class DicomRoutingRule extends BaseModel
{
use Active;
public function conditions(): HasMany
{
return $this->hasMany(DicomRuleCondition::class);
}
public function organization(): BelongsTo
{
return $this->belongsTo(Organization::class);
@ -27,21 +19,20 @@ public function department(): BelongsTo
return $this->belongsTo(Department::class);
}
public function panel(): HasOne
public function panel(): BelongsTo
{
return $this->hasOne(AssignmentPanel::class);
return $this->belongsTo(AssignmentPanel::class, 'assignment_panel_id');
}
public function radiologist(): HasOne
public function radiologist(): BelongsTo
{
return $this->hasOne(User::class);
return $this->belongsTo(User::class, 'user_id');
}
protected function casts(): array
{
return [
'is_active' => 'boolean',
'match_condition' => MatchCondition::class,
];
}
}

View File

@ -1,22 +0,0 @@
<?php
namespace App\Models;
use App\Domain\Rule\MatchMode;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class DicomRuleCondition extends BaseModel
{
public function rule(): BelongsTo
{
return $this->belongsTo(DicomRoutingRule::class);
}
protected function casts(): array
{
return [
'case_sensitive' => 'boolean',
'match_mode' => MatchMode::class,
];
}
}

View File

@ -10,12 +10,12 @@ class DicomServer extends BaseModel
{
use Active;
public function institute(): BelongsTo
public function organization(): BelongsTo
{
return $this->belongsTo(Organization::class);
}
public function facility(): BelongsTo
public function department(): BelongsTo
{
return $this->belongsTo(Department::class);
}

View File

@ -3,7 +3,6 @@
namespace App\Models;
use App\Domain\ACL\Permission;
use App\Domain\Report\ReportStatus;
use App\Domain\Study\Priority;
use App\Domain\Study\WorkflowLevel;
use App\Models\Traits\HasDepartment;
@ -12,12 +11,13 @@
use App\Services\Pacs\PacsUrlGen;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Concerns\HasTimestamps;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
@ -28,7 +28,6 @@ class Study extends BaseModel implements HasMedia
use HasDepartment;
use HashableId;
use HasOrganization;
use HasTimestamps;
use InteractsWithMedia;
public const string MEDIA_COLLECTION = 'attachments';
@ -175,26 +174,28 @@ public function isArchived(): bool
return $this->archived_at !== null;
}
public function getReportStatusLedAttribute(): string
public function getWorkflowLevelLedAttribute(): string
{
$color = match ($this->report_status) {
ReportStatus::Unread => 'bg-white',
// ReportStatus::Opened => 'bg-secondary',
ReportStatus::Preliminary => 'bg-info',
ReportStatus::Finalized => 'bg-primary',
ReportStatus::Approved => 'bg-success',
default => 'bg-light',
};
// <i class="fa-solid fa-spinner"></i>
$icon = match ($this->report_status) {
ReportStatus::Unread => 'spinner text-muted',
ReportStatus::Preliminary => 'pen-to-square',
ReportStatus::Finalized => 'badge-check',
ReportStatus::Approved => 'shield-check',
default => 'spinner text-muted',
$icon = match ($this->workflow_level) {
WorkflowLevel::Received => 'download-2-line',
WorkflowLevel::Unassigned => 'checkbox-indeterminate-line',
WorkflowLevel::Assigned => 'hourglass-2-line',
WorkflowLevel::Dictated => 'speak-line',
WorkflowLevel::Transcribed => 'message-2-line',
WorkflowLevel::Repetition => 'repeat-line',
WorkflowLevel::ReadInProgress => 'battery-charge-line',
WorkflowLevel::DraftAvailable => 'draft-line',
WorkflowLevel::Finalized => 'folder-chart-line',
WorkflowLevel::UnderReview => 'eye-line',
WorkflowLevel::Published => 'graduation-cap-line',
WorkflowLevel::Archived => 'archive-line',
WorkflowLevel::Cancelled => 'close-circle-line',
default => 'cog-6-tooth',
};
return sprintf('<span class="badge badge-center rounded-pill %s"><i class="fa-solid fa-%s"></i></span>', $color, $icon);
$descr = $this->workflow_level->describe();
return sprintf('<span class="text-gray" data-bs-toggle="tooltip" data-bs-placement="top" data-bs-original-title="%s" title="%s"><i class="ri-%s"></i></span>', $descr, $descr, $icon);
}
public function getArchiveLink(): ?string
@ -315,7 +316,7 @@ public function toArray(): array
'reader_name' => $this->readingPhysician?->display_name,
// 'assigned_physician_name' => $this->assignedPhysician?->display_name,
'reader_photo' => $this->readingPhysician?->profile_photo_url,
'report_status_led' => $this->getReportStatusLedAttribute(),
'workflow_level_led' => $this->getWorkflowLevelLedAttribute(),
'priority_icon' => $this->getPriorityIcon(),
'sex_age' => $this->sexAge(),
'num_instances' => $this->numInstances(),
@ -369,14 +370,14 @@ public function isUnlocked(): bool
return $this->locked_at === null;
}
public function lockStudy(User|int|null $user = null, ?WorkflowLevel $status = null): void
public function lockStudy(User|int|null $user = null, ?WorkflowLevel $level = null): void
{
$params = [
'locking_physician_id' => me($user)->id,
'locked_at' => now(),
];
if ($status) {
$params['workflow_level'] = $status->value;
if ($level !== null) {
$params['workflow_level'] = $level->value;
}
$this->update($params);
@ -456,18 +457,18 @@ public function canObtainLock(User|int|null $user = null): bool
return $this->isUnlocked() || $this->isLockedBy($user);
}
public function setReportStatus(ReportStatus $status, User|int|null $user = null): void
public function setReportWorkflowLevel(WorkflowLevel $level, User|int|null $user = null): void
{
$user_id = me($user)->id;
$params = ['report_status' => $status->value];
$params = ['workflow_level' => $level->value];
switch ($status) {
case ReportStatus::Finalized:
switch ($level) {
case WorkflowLevel::Finalized:
$params['reading_physician_id'] = $user_id;
$params['read_at'] = now();
break;
case ReportStatus::Approved:
case WorkflowLevel::Published:
if ($this->reading_physician_id === null) {
$params['reading_physician_id'] = $user_id;
$params['read_at'] = now();
@ -483,18 +484,18 @@ public function setReportStatus(ReportStatus $status, User|int|null $user = null
public function canEditReport(): bool
{
// todo: check if the study disallows reporting
return $this->isActive() && $this->reportStatusBefore(ReportStatus::Finalized);
return $this->isActive() && $this->reportStatusBefore(WorkflowLevel::Finalized);
}
public function reportStatusBefore(ReportStatus $checkpoint): bool
public function reportStatusBefore(WorkflowLevel $checkpoint): bool
{
return $this->report_status->value < $checkpoint->value;
return $this->workflow_level->value < $checkpoint->value;
}
public function canEditMetadata(): bool
{
return $this->isActive() && $this->reportStatusBefore(ReportStatus::Finalized);
return $this->isActive() && $this->reportStatusBefore(WorkflowLevel::Finalized);
}
public function canAssignRad(): bool
@ -503,7 +504,7 @@ public function canAssignRad(): bool
return false;
}
return $this->isActive() && $this->reportStatusBefore(ReportStatus::Preliminary);
return $this->isActive() && $this->reportStatusBefore(WorkflowLevel::DraftAvailable);
}
public function hasReports(): bool
@ -513,13 +514,13 @@ public function hasReports(): bool
public function isReportReady(): bool
{
return ($this->report_status->value == ReportStatus::Finalized->value) ||
($this->report_status->value == ReportStatus::Approved->value);
return ($this->workflow_level->value == WorkflowLevel::Finalized->value) ||
($this->workflow_level->value == WorkflowLevel::Published->value);
}
public function isStudyComplete(): bool
{
return $this->report_status->value >= ReportStatus::Finalized->value;
return $this->workflow_level->value >= WorkflowLevel::Finalized->value;
}
public function bookmarkedByUsers()
@ -531,7 +532,6 @@ protected function casts(): array
{
return [
'workflow_level' => WorkflowLevel::class,
'report_status' => ReportStatus::class,
'priority' => Priority::class,
'archived_at' => 'immutable_datetime',
'approved_at' => 'immutable_datetime',
@ -543,4 +543,16 @@ protected function casts(): array
'patient_birthdate' => 'immutable_date',
];
}
public function hasHistory(): bool
{
return Cache::remember("study.has_history.{$this->id}",
now()->addMinutes(15),
function () {
return DB::table('study_details')
->where('study_id', $this->id)
->whereNotNull('clinical_history')
->exists();
});
}
}

View File

@ -3,7 +3,7 @@
namespace App\Models;
use App\Domain\Report\ExportFormat;
use App\Domain\Report\ReportStatus;
use App\Domain\Study\WorkflowLevel;
use App\Services\Report\ReportStorage;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Concerns\HasTimestamps;
@ -43,19 +43,19 @@ public function scopeForStudy(Builder $query, Study|int $study): Builder
return $query;
}
public function setStatus(ReportStatus $status, User|int|null $user = null): void
public function setWorkflowLevel(WorkflowLevel $level, User|int|null $user = null): void
{
$user_id = me($user)->id;
$params = ['report_status' => $status->value];
switch ($status) {
case ReportStatus::Dictated:
$params = ['workflow_level' => $level->value];
switch ($level) {
case WorkflowLevel::Dictated:
$params['dictated_by_id'] = $user_id;
break;
case ReportStatus::Preliminary:
case ReportStatus::Finalized:
case WorkflowLevel::DraftAvailable:
case WorkflowLevel::Finalized:
$params['read_by_id'] = $user_id;
break;
case ReportStatus::Approved:
case WorkflowLevel::Published:
$params['approved_by_id'] = $user_id;
break;
}
@ -96,12 +96,12 @@ public function getContent(): ?string
public function eligibleForExport(): bool
{
return $this->report_status->value >= ReportStatus::Finalized->value;
return $this->workflow_level->value >= WorkflowLevel::Finalized->value;
}
public function canRemove(User|int|null $user = null): bool
{
if ($this->report_status->value < ReportStatus::Finalized->value) {
if ($this->workflow_level->value < WorkflowLevel::Finalized->value) {
if ($this->read_by_id === me($user)->id) {
return true;
}
@ -117,7 +117,7 @@ public function canRemove(User|int|null $user = null): bool
public function canEdit(User|int|null $user = null): bool
{
if ($this->report_status->value < ReportStatus::Finalized->value &&
if ($this->workflow_level->value < WorkflowLevel::Finalized->value &&
$this->study->canEditReport() &&
$this->read_by_id === me($user)->id) {
return true;
@ -128,7 +128,7 @@ public function canEdit(User|int|null $user = null): bool
public function isFinalized(): bool
{
return $this->report_status->value >= ReportStatus::Finalized->value;
return $this->workflow_level->value >= WorkflowLevel::Finalized->value;
}
/**
@ -137,7 +137,7 @@ public function isFinalized(): bool
protected function casts(): array
{
return [
'report_status' => ReportStatus::class,
'workflow_level' => WorkflowLevel::class,
];
}
}

View File

@ -5,14 +5,15 @@
use App\Domain\ACL\Permission;
use App\Domain\ACL\Role;
use App\Models\Traits\Active;
use App\Models\Traits\HasDepartment;
use App\Models\Traits\HashableId;
use App\Models\Traits\HasOrganization;
use App\Services\UserService;
use Carbon\Carbon;
use Database\Factories\UserFactory;
use Filament\Models\Contracts\FilamentUser;
use Filament\Panel;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
use Illuminate\Database\Eloquent\Relations\HasOne;
@ -30,36 +31,18 @@ class User extends Authenticatable implements FilamentUser
{
use Active;
use HasApiTokens;
use HasDepartment, HasOrganization;
/** @use HasFactory<UserFactory> */
use HasFactory;
use HashableId;
use HasProfilePhoto;
use HasRoles;
use Notifiable;
use TwoFactorAuthenticatable;
// use TwoFactorAuthenticatable;
public array $info = [];
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
protected $fillable = [
'is_active',
'first_name',
'last_name',
'display_name',
'username',
'email',
'phone',
'site_id',
'facility_id',
'profile_photo_path',
'timezone',
'last_seen_at',
'password',
];
protected $guarded = ['id'];
/**
* The attributes that should be hidden for serialization.
@ -159,19 +142,9 @@ public function radiologistProfile(): HasOne
return $this->hasOne(RadiologistProfile::class);
}
public function institute(): BelongsTo
{
return $this->belongsTo(Organization::class);
}
public function facility(): BelongsTo
{
return $this->belongsTo(Department::class);
}
public function canAccessPanel(Panel $panel): bool
{
return $this->isAdmin();
return $this->isAdmin() && $this->is_active;
}
public function panels(): HasManyThrough

View File

@ -0,0 +1,28 @@
<?php
namespace App\Services\ACL;
use App\Domain\ACL\Role;
use Spatie\Permission\Models\Role as SpatieRole;
final class RoleService
{
private static array $roles = [];
public static function select(): array
{
// self::initCache();
return collect(Role::cases())
// ->mapWithKeys(fn (Role $r) => [self::$roles[$r->value] => $r->name])
->mapWithKeys(fn (Role $r) => [$r->value => $r->name])
->toArray();
}
private static function initCache(): void
{
if (empty(self::$roles)) {
self::$roles = SpatieRole::pluck('id', 'name')->toArray();
}
}
}

View File

@ -5,7 +5,6 @@
enum WorklistColumn: string
{
case WorkflowLevel = 'workflow_level';
case ReportStatus = 'report_status';
case StudyHash = 'hash';
case PatientName = 'patient_name';
case PatientId = 'patient_id';

View File

@ -14,7 +14,7 @@ public static function worklistColumns(User|int|null $usr = null): Collection
$user = me($usr);
$columns = collect([
WorklistColumn::Priority,
WorklistColumn::ReportStatus,
WorklistColumn::WorkflowLevel,
WorklistColumn::ActionButtons,
// WorklistColumn::AssignedPhysician,
WorklistColumn::PatientId,
@ -57,7 +57,7 @@ public static function worklistButtons(Study $study, User|int|null $usr = null):
if ($user->isRadiologist()) {
return collect([
WorklistButton::StudyMetadata,
WorklistButton::Notes,
// WorklistButton::Notes,
// WorklistButton::Audit,
WorklistButton::Bookmark,
]);
@ -66,7 +66,7 @@ public static function worklistButtons(Study $study, User|int|null $usr = null):
$buttons = collect([
WorklistButton::StudyMetadata,
WorklistButton::History,
WorklistButton::Notes,
// WorklistButton::Notes,
]);
if ($study->canAssignRad()) {

View File

@ -0,0 +1,273 @@
<?php
namespace App\Services;
final class GeoLocation
{
private static array $countries = [
'AF' => 'Afghanistan',
'AX' => 'Åland Islands',
'AL' => 'Albania',
'DZ' => 'Algeria',
'AS' => 'American Samoa',
'AD' => 'Andorra',
'AO' => 'Angola',
'AI' => 'Anguilla',
'AQ' => 'Antarctica',
'AG' => 'Antigua & Barbuda',
'AR' => 'Argentina',
'AM' => 'Armenia',
'AW' => 'Aruba',
'AU' => 'Australia',
'AT' => 'Austria',
'AZ' => 'Azerbaijan',
'BS' => 'Bahamas',
'BH' => 'Bahrain',
'BD' => 'Bangladesh',
'BB' => 'Barbados',
'BY' => 'Belarus',
'BE' => 'Belgium',
'BZ' => 'Belize',
'BJ' => 'Benin',
'BM' => 'Bermuda',
'BT' => 'Bhutan',
'BO' => 'Bolivia',
'BA' => 'Bosnia & Herzegovina',
'BW' => 'Botswana',
'BV' => 'Bouvet Island',
'BR' => 'Brazil',
'IO' => 'British Indian Ocean Territory',
'VG' => 'British Virgin Islands',
'BN' => 'Brunei',
'BG' => 'Bulgaria',
'BF' => 'Burkina Faso',
'BI' => 'Burundi',
'KH' => 'Cambodia',
'CM' => 'Cameroon',
'CA' => 'Canada',
'CV' => 'Cape Verde',
'BQ' => 'Caribbean Netherlands',
'KY' => 'Cayman Islands',
'CF' => 'Central African Republic',
'TD' => 'Chad',
'CL' => 'Chile',
'CN' => 'China',
'CX' => 'Christmas Island',
'CC' => 'Cocos (Keeling) Islands',
'CO' => 'Colombia',
'KM' => 'Comoros',
'CG' => 'Congo - Brazzaville',
'CD' => 'Congo - Kinshasa',
'CK' => 'Cook Islands',
'CR' => 'Costa Rica',
'CI' => 'Côte dIvoire',
'HR' => 'Croatia',
'CU' => 'Cuba',
'CW' => 'Curaçao',
'CY' => 'Cyprus',
'CZ' => 'Czechia',
'DK' => 'Denmark',
'DJ' => 'Djibouti',
'DM' => 'Dominica',
'DO' => 'Dominican Republic',
'EC' => 'Ecuador',
'EG' => 'Egypt',
'SV' => 'El Salvador',
'GQ' => 'Equatorial Guinea',
'ER' => 'Eritrea',
'EE' => 'Estonia',
'SZ' => 'Eswatini',
'ET' => 'Ethiopia',
'FK' => 'Falkland Islands',
'FO' => 'Faroe Islands',
'FJ' => 'Fiji',
'FI' => 'Finland',
'FR' => 'France',
'GF' => 'French Guiana',
'PF' => 'French Polynesia',
'TF' => 'French Southern Territories',
'GA' => 'Gabon',
'GM' => 'Gambia',
'GE' => 'Georgia',
'DE' => 'Germany',
'GH' => 'Ghana',
'GI' => 'Gibraltar',
'GR' => 'Greece',
'GL' => 'Greenland',
'GD' => 'Grenada',
'GP' => 'Guadeloupe',
'GU' => 'Guam',
'GT' => 'Guatemala',
'GG' => 'Guernsey',
'GN' => 'Guinea',
'GW' => 'Guinea-Bissau',
'GY' => 'Guyana',
'HT' => 'Haiti',
'HM' => 'Heard & McDonald Islands',
'HN' => 'Honduras',
'HK' => 'Hong Kong SAR China',
'HU' => 'Hungary',
'IS' => 'Iceland',
'IN' => 'India',
'ID' => 'Indonesia',
'IR' => 'Iran',
'IQ' => 'Iraq',
'IE' => 'Ireland',
'IM' => 'Isle of Man',
'IL' => 'Israel',
'IT' => 'Italy',
'JM' => 'Jamaica',
'JP' => 'Japan',
'JE' => 'Jersey',
'JO' => 'Jordan',
'KZ' => 'Kazakhstan',
'KE' => 'Kenya',
'KI' => 'Kiribati',
'KW' => 'Kuwait',
'KG' => 'Kyrgyzstan',
'LA' => 'Laos',
'LV' => 'Latvia',
'LB' => 'Lebanon',
'LS' => 'Lesotho',
'LR' => 'Liberia',
'LY' => 'Libya',
'LI' => 'Liechtenstein',
'LT' => 'Lithuania',
'LU' => 'Luxembourg',
'MO' => 'Macao SAR China',
'MG' => 'Madagascar',
'MW' => 'Malawi',
'MY' => 'Malaysia',
'MV' => 'Maldives',
'ML' => 'Mali',
'MT' => 'Malta',
'MH' => 'Marshall Islands',
'MQ' => 'Martinique',
'MR' => 'Mauritania',
'MU' => 'Mauritius',
'YT' => 'Mayotte',
'MX' => 'Mexico',
'FM' => 'Micronesia',
'MD' => 'Moldova',
'MC' => 'Monaco',
'MN' => 'Mongolia',
'ME' => 'Montenegro',
'MS' => 'Montserrat',
'MA' => 'Morocco',
'MZ' => 'Mozambique',
'MM' => 'Myanmar (Burma)',
'NA' => 'Namibia',
'NR' => 'Nauru',
'NP' => 'Nepal',
'NL' => 'Netherlands',
'NC' => 'New Caledonia',
'NZ' => 'New Zealand',
'NI' => 'Nicaragua',
'NE' => 'Niger',
'NG' => 'Nigeria',
'NU' => 'Niue',
'NF' => 'Norfolk Island',
'KP' => 'North Korea',
'MK' => 'North Macedonia',
'MP' => 'Northern Mariana Islands',
'NO' => 'Norway',
'OM' => 'Oman',
'PK' => 'Pakistan',
'PW' => 'Palau',
'PS' => 'Palestinian Territories',
'PA' => 'Panama',
'PG' => 'Papua New Guinea',
'PY' => 'Paraguay',
'PE' => 'Peru',
'PH' => 'Philippines',
'PN' => 'Pitcairn Islands',
'PL' => 'Poland',
'PT' => 'Portugal',
'PR' => 'Puerto Rico',
'QA' => 'Qatar',
'RE' => 'Réunion',
'RO' => 'Romania',
'RU' => 'Russia',
'RW' => 'Rwanda',
'WS' => 'Samoa',
'SM' => 'San Marino',
'ST' => 'São Tomé & Príncipe',
'SA' => 'Saudi Arabia',
'SN' => 'Senegal',
'RS' => 'Serbia',
'SC' => 'Seychelles',
'SL' => 'Sierra Leone',
'SG' => 'Singapore',
'SX' => 'Sint Maarten',
'SK' => 'Slovakia',
'SI' => 'Slovenia',
'SB' => 'Solomon Islands',
'SO' => 'Somalia',
'ZA' => 'South Africa',
'GS' => 'South Georgia & South Sandwich Islands',
'KR' => 'South Korea',
'SS' => 'South Sudan',
'ES' => 'Spain',
'LK' => 'Sri Lanka',
'BL' => 'St. Barthélemy',
'SH' => 'St. Helena',
'KN' => 'St. Kitts & Nevis',
'LC' => 'St. Lucia',
'MF' => 'St. Martin',
'PM' => 'St. Pierre & Miquelon',
'VC' => 'St. Vincent & Grenadines',
'SD' => 'Sudan',
'SR' => 'Suriname',
'SJ' => 'Svalbard & Jan Mayen',
'SE' => 'Sweden',
'CH' => 'Switzerland',
'SY' => 'Syria',
'TW' => 'Taiwan',
'TJ' => 'Tajikistan',
'TZ' => 'Tanzania',
'TH' => 'Thailand',
'TL' => 'Timor-Leste',
'TG' => 'Togo',
'TK' => 'Tokelau',
'TO' => 'Tonga',
'TT' => 'Trinidad & Tobago',
'TN' => 'Tunisia',
'TR' => 'Turkey',
'TM' => 'Turkmenistan',
'TC' => 'Turks & Caicos Islands',
'TV' => 'Tuvalu',
'UM' => 'U.S. Outlying Islands',
'VI' => 'U.S. Virgin Islands',
'UG' => 'Uganda',
'UA' => 'Ukraine',
'AE' => 'United Arab Emirates',
'GB' => 'United Kingdom',
'US' => 'United States',
'UY' => 'Uruguay',
'UZ' => 'Uzbekistan',
'VU' => 'Vanuatu',
'VA' => 'Vatican City',
'VE' => 'Venezuela',
'VN' => 'Vietnam',
'WF' => 'Wallis & Futuna',
'EH' => 'Western Sahara',
'YE' => 'Yemen',
'ZM' => 'Zambia',
'ZW' => 'Zimbabwe',
];
public static function codes(): array
{
return array_keys(self::$countries);
}
public static function select(): array
{
return self::$countries;
}
public static function name(string $code): string
{
return self::$countries[strtoupper($code)] ?? '';
}
}

View File

@ -20,7 +20,7 @@ public function __invoke(StudiesSync $sync, Closure $next): StudiesSync
}
$payload = [
'is_archived' => true,
'archived_at' => now(),
'updated_at' => now(),
];
DB::table('studies')->where('id', $study_id)->update($payload);
@ -30,7 +30,7 @@ public function __invoke(StudiesSync $sync, Closure $next): StudiesSync
->category(Category::SYSTEM)
->on($study_id)
->orthanc($orthanc_uuid)
->did(Activity::Study_Archived)
->did(Activity::Study_Archive)
->log(false);
}

View File

@ -15,6 +15,7 @@ public function __invoke(StudiesSync $sync, Closure $next): StudiesSync
$sync->resetQueues();
$studies = DB::table('studies')
->where('dicom_server_id', $sync->getDicomServer()->id)
->whereNull('archived_at')
->get(['orthanc_uuid', 'workflow_level'])
->pluck('workflow_level', 'orthanc_uuid');

View File

@ -26,7 +26,6 @@ public function __invoke(StudiesSync $sync, Closure $next): StudiesSync
$row = Study::create($payload['study']);
$payload['details']['study_id'] = $row->id;
$payload['details']['orthanc_uuid'] = $orthanc_uuid;
StudyDetails::create($payload['details']);
audit()

View File

@ -4,13 +4,22 @@
use App\Services\Pacs\Sync\StudiesSync;
use Closure;
use Exception;
use Illuminate\Support\Facades\Log;
final readonly class ScanStudies
{
public function __invoke(StudiesSync $sync, Closure $next): StudiesSync
{
$study_ids = [];
try {
$study_ids = $sync->getClient()->getStudiesIds();
} catch (Exception $e) {
Log::error($e->getMessage());
}
if (! empty($study_ids)) {
$sync->setStudyIds($study_ids);
}
return $next($sync);
}

View File

@ -8,14 +8,17 @@
use App\Services\Pacs\DicomUtils;
use App\Services\Pacs\OrthancRestClient;
use App\Services\StudyRouter\DicomStudyRouter;
use App\Services\StudyRouter\RawDicomTags;
use Carbon\Carbon;
use Exception;
use Illuminate\Pipeline\Pipeline;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
class StudiesSync
final class StudiesSync
{
public const SYNC_AGENT = '$$_pacs_agent_$$';
private Collection $study_ids;
private Collection $insert_queue;
@ -145,6 +148,29 @@ public function transformData(mixed $orthanc_src): array
$inst_name = data_get($orthanc_src, 'MainDicomTags.InstitutionName');
$patient_name = data_get($orthanc_src, 'PatientMainDicomTags.PatientName');
$name_parts = tokenizeString($patient_name);
$patient_age = data_get($orthanc_src, 'RequestedTags.PatientAge');
if (blank($patient_age) && ! empty($name_parts)) {
// try to get age from last part of patient name
$last = end($name_parts);
if (preg_match('/^\d+[YMD]$/i', $last)) {
$patient_age = $last;
/*
// sanitize patient name
array_pop($name_parts);
$patient_name = implode(' ', $name_parts);
*/
}
}
if ($patient_age !== null) {
$age = strtoupper(ltrim($patient_age, '0'));
if (strlen($age) > 1) {
$patient_age = $age;
}
}
// $patient_name = trim($patient_name, '.^ ');
$descr = $this->getStudyDescription($orthanc_src);
$study = [
@ -158,7 +184,7 @@ public function transformData(mixed $orthanc_src): array
'patient_id' => data_get($orthanc_src, 'PatientMainDicomTags.PatientID'),
'patient_name' => $patient_name,
'patient_sex' => data_get($orthanc_src, 'PatientMainDicomTags.PatientSex'),
'patient_age' => data_get($orthanc_src, 'RequestedTags.PatientAge'),
'patient_age' => $patient_age,
'accession_number' => data_get($orthanc_src, 'MainDicomTags.AccessionNumber'),
'referring_physician_name' => data_get($orthanc_src, 'MainDicomTags.ReferringPhysicianName'),
@ -179,17 +205,41 @@ public function transformData(mixed $orthanc_src): array
$study['workflow_level'] = $stable_study
? WorkflowLevel::Unassigned->value
: WorkflowLevel::Pending->value;
$study['patient_birthdate'] = null;
: WorkflowLevel::Received->value;
$patient_birthdate = null;
$dob = data_get($orthanc_src, 'PatientMainDicomTags.PatientBirthDate');
if (filled($dob)) {
try {
$study['patient_birthdate'] = Carbon::parse($dob);
$patient_birthdate = Carbon::parse($dob);
} catch (Exception $e) {
Log::error('Failed to parse PatientMainDicomTags.PatientBirthDate: {dob}', ['dob' => $dob, 'exception' => $e->getMessage()]);
}
}
if ($patient_birthdate == null && $patient_age !== null) {
try {
// $age = (int) preg_replace('/[^0-9]/', '', $patient_age);
$age_num = (int) filter_var($patient_age, FILTER_SANITIZE_NUMBER_INT);
$now = now();
switch (strtoupper(substr($patient_age, -1))) {
case 'Y':
$patient_birthdate = $now->subYears($age_num);
break;
case 'M':
$patient_birthdate = $now->subMonths($age_num);
break;
case 'D':
$patient_birthdate = $now->subDays($age_num);
break;
}
} catch (Exception) {
Log::error('Failed to parse PatientMainDicomTags.PatientBirthDate: {dob}', ['dob' => $dob]);
Log::error('Failed to parse patient_age: {age}', ['age' => $patient_age]);
}
}
$study['patient_birthdate'] = $patient_birthdate;
// check for priority in patient name or description
if (preg_match('/\b(urgent|stat)\b/i', implode(' ', [$descr, $patient_name]))) {
$this->setValue($study, 'priority', Priority::Stat->value);
@ -281,22 +331,25 @@ private function getStudyDicomTags(string $study_uuid): array
}
// randomly sample few instances for tags collection
$selectedInstances = count($instances) <= $this->maxInstances ? $instances : array_rand(array_flip($instances), $this->maxInstances);
$selectedInstances = count($instances) <= $this->maxInstances
? $instances
: array_intersect_key($instances, array_flip(array_rand($instances, $this->maxInstances)));
$tags = [];
$tags = collect();
foreach ($selectedInstances as $instance) {
foreach ($this->fetchInstancesTags($instance) as $key => $value) {
if ($key == 'MainDicomTags' || $key == 'RequestedTags') {
foreach ($value as $tag => $val) {
if (! isset($tags[$tag]) || $tags[$tag] !== $val) {
$tags[$tag] = $val;
$dcmTag = RawDicomTags::tryFrom($tag);
if ($dcmTag != null && (! $tags->has($dcmTag->name) || $tags->get($dcmTag->name) !== $val)) {
$tags->put($dcmTag->name, $val);
}
}
}
}
}
return $tags;
return $tags->toArray();
}
private function setValue(array &$array, string $key, mixed $value): void

View File

@ -3,7 +3,7 @@
namespace App\Services\Report;
use App\Domain\ACL\Permission;
use App\Domain\Report\ReportStatus;
use App\Domain\Study\WorkflowLevel;
use App\Models\Study;
use App\Models\StudyReport;
use App\Services\AuditTrail\Activity;
@ -41,13 +41,13 @@ public function getReports(): Collection
return $this->study->reports->sortByDesc('created_at');
}
public function createReport(string $content, ReportStatus $status): StudyReport
public function createReport(string $content, WorkflowLevel $level): StudyReport
{
$report = StudyReport::make([
'study_id' => $this->study->id,
'organization_id' => $this->study->organization_id,
'department_id' => $this->study->department_id,
'report_status' => $status->value,
'workflow_level' => $level->value,
'read_by_id' => me()->id,
]);
$report->saveContent($content);
@ -65,14 +65,14 @@ public function createReport(string $content, ReportStatus $status): StudyReport
public function finalizeReport(StudyReport $report): void
{
$report->setStatus(ReportStatus::Finalized);
$report->setWorkflowLevel(WorkflowLevel::Finalized);
audit()
->on($this->study)
->did(Activity::Report_Finalize)
->notes($report->accession_number)
->log();
$this->study->setReportStatus(ReportStatus::Finalized);
$this->study->setReportWorkflowLevel(WorkflowLevel::Finalized);
audit()
->on($this->study)
@ -112,7 +112,7 @@ public function check(): ?View
public function lockStudyIfRequired(): void
{
if ($this->study->isUnlocked()) {
$this->study->lockStudy();
$this->study->lockStudy(level: WorkflowLevel::ReadInProgress);
audit()
->on($this->study)
->did(Activity::Study_Lock)
@ -123,7 +123,7 @@ public function lockStudyIfRequired(): void
public function latestReport(): ?StudyReport
{
return StudyReport::forStudy($this->study)
->where('report_status', ReportStatus::Preliminary->value)
->where('workflow_level', WorkflowLevel::DraftAvailable->value)
->select(['id', 'accession_number', 'file_path'])
->latest()
->first();

View File

@ -3,16 +3,15 @@
namespace App\Services\StudyRouter;
use App\Domain\ACL\Role;
use App\Domain\Rule\MatchCondition;
use App\Domain\Rule\MatchMode;
use App\Models\AssignmentPanel;
use App\Models\DicomRoutingRule;
use App\Models\DicomRuleCondition;
use App\Models\Organization;
use App\Models\User;
use App\Services\ContentMatcher;
use Exception;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
final class DicomStudyRouter
{
@ -30,22 +29,27 @@ public static function matchStudy(array $dicomHeaders): array
return self::fallbackRouting();
}
$study = json_decode(json_encode($dicomHeaders)); // convert to object
$expression = new ExpressionLanguage;
foreach (self::$rules as $rule) {
$conditions = $rule->conditions()->orderByDesc('priority')->get();
$matches = $rule->match_condition === MatchCondition::ALL
? $conditions->every(fn ($condition) => self::matchCondition($condition, $dicomHeaders))
: $conditions->contains(fn ($condition) => self::matchCondition($condition, $dicomHeaders));
try {
$matches = (bool) $expression->evaluate($rule->condition, ['study' => $study]);
} catch (Exception $exc) {
Log::error('Error evaluating rule expression', [
'rule_id' => $rule->id,
'condition' => $rule->condition,
'error' => $exc->getMessage(),
]);
/*
if ($dicomHeaders[RawDicomTags::Modality->value] === 'CR') {
dd($rule, $conditions->toArray(), $matches, $dicomHeaders[RawDicomTags::Modality->value]);
return self::fallbackRouting();
}
*/
if ($matches) {
return [
'organization_id' => $rule->organization_id,
'department_id' => $rule->department_id,
'rule_id' => $rule->id,
'rule_name' => $rule->name,
'radiologists' => self::getRadiologists($rule),
];
}
@ -70,7 +74,6 @@ private static function initialize(): void
self::$rules = Cache::remember('dicom.rules',
now()->addMinutes(self::CACHE_TTL),
fn () => DicomRoutingRule::active()
->with('conditions')
->orderByDesc('priority')
->get()
);
@ -92,22 +95,6 @@ private static function initialize(): void
}
}
private static function matchCondition(DicomRuleCondition $condition, array $dicomHeaders): bool
{
$dicomTag = $condition->dicom_tag;
$dicomValue = $dicomHeaders[$dicomTag] ?? '';
$searchPattern = $condition->search_pattern;
if (! $condition->case_sensitive) {
$dicomValue = strtolower($dicomValue);
if ($condition->match_mode !== MatchMode::Regex) {
$searchPattern = strtolower($searchPattern);
}
}
return ContentMatcher::match($dicomValue, $searchPattern, $condition->match_mode);
}
private static function getRadiologists(DicomRoutingRule $rule): array
{
if (! is_null($rule->assignment_panel_id)) {

View File

@ -4,62 +4,62 @@
enum RawDicomTags: string
{
case PatientName = '0010,0010'; // Patient's Name
case PatientID = '0010,0020'; // Patient ID
case PatientBirthDate = '0010,0030'; // Patient's Birth Date
case PatientSex = '0010,0040'; // Patient's Sex
case StudyInstanceUID = '0020,000D'; // Study Instance UID
case SeriesInstanceUID = '0020,000E'; // Series Instance UID
case StudyID = '0020,0010'; // Study ID
case SeriesNumber = '0020,0011'; // Series Number
case InstanceNumber = '0020,0013'; // Instance Number
case SOPClassUID = '0008,0016'; // SOP Class UID
case SOPInstanceUID = '0008,0018'; // SOP Instance UID
case StudyDate = '0008,0020'; // Study Date
case StudyTime = '0008,0030'; // Study Time
case AccessionNumber = '0008,0050'; // Accession Number
case Modality = '0008,0060'; // Modality
case Manufacturer = '0008,0070'; // Manufacturer
case InstitutionName = '0008,0080'; // Institution Name
case ReferringPhysicianName = '0008,0090'; // Referring Physician's Name
case StationName = '0008,1010'; // Station Name
case SeriesDescription = '0008,103E'; // Series Description
case ManufacturerModelName = '0008,1090'; // Manufacturer's Model Name
case PatientAge = '0010,1010'; // Patient's Age
case PatientWeight = '0010,1030'; // Patient's Weight
case BodyPartExamined = '0018,0015'; // Body Part Examined
case ProtocolName = '0018,1030'; // Protocol Name
case SoftwareVersions = '0018,1020'; // Software Versions
case AcquisitionDate = '0008,0022'; // Acquisition Date
case AcquisitionTime = '0008,0032'; // Acquisition Time
case ContentDate = '0008,0023'; // Content Date
case ContentTime = '0008,0033'; // Content Time
case AcquisitionDeviceProcessingDescription = '0018,1400'; // Acquisition Device Processing Description
case InstitutionAddress = '0008,0081'; // Institution Address
case StudyDescription = '0008,1030'; // Study Description
case OperatorsName = '0008,1070'; // Operator's Name
case Private10 = '0029,0010'; // Private Tag 10
case IW_Private = '0009,0010'; // IW Private Tag
case ImageType = '0008,0008'; // Image Type
case PatientOrientation = '0020,0020'; // Patient Orientation
case ImagePositionPatient = '0020,0032'; // Image Position (Patient)
case ImageOrientationPatient = '0020,0037'; // Image Orientation (Patient)
case FrameOfReferenceUID = '0020,0052'; // Frame of Reference UID
case PositionReferenceIndicator = '0020,1040'; // Position Reference Indicator
case SliceLocation = '0020,1041'; // Slice Location
case SamplesPerPixel = '0028,0002'; // Samples per Pixel
case PhotometricInterpretation = '0028,0004'; // Photometric Interpretation
case Rows = '0028,0010'; // Rows
case Columns = '0028,0011'; // Columns
case PixelSpacing = '0028,0030'; // Pixel Spacing
case BitsAllocated = '0028,0100'; // Bits Allocated
case BitsStored = '0028,0101'; // Bits Stored
case HighBit = '0028,0102'; // High Bit
case PixelRepresentation = '0028,0103'; // Pixel Representation
case WindowCenter = '0028,1050'; // Window Center
case WindowWidth = '0028,1051'; // Window Width
case RescaleIntercept = '0028,1052'; // Rescale Intercept
case RescaleSlope = '0028,1053'; // Rescale Slope
case InoWave_Private = '0011,1060';
case RadFusion_SenderId = '1971,1020';
case patient_name = '0010,0010'; // Patient's Name
case patient_id = '0010,0020'; // Patient ID
case patient_birth_date = '0010,0030'; // Patient's Birth Date
case patient_sex = '0010,0040'; // Patient's Sex
case study_instance_uid = '0020,000D'; // Study Instance UID
case series_instance_uid = '0020,000E'; // Series Instance UID
case study_id = '0020,0010'; // Study ID
case series_number = '0020,0011'; // Series Number
case instance_number = '0020,0013'; // Instance Number
case sop_class_uid = '0008,0016'; // SOP Class UID
case sop_instance_uid = '0008,0018'; // SOP Instance UID
case study_date = '0008,0020'; // Study Date
case study_time = '0008,0030'; // Study Time
case accession_number = '0008,0050'; // Accession Number
case modality = '0008,0060'; // Modality
case manufacturer = '0008,0070'; // Manufacturer
case institution_name = '0008,0080'; // Institution Name
case referring_physician_name = '0008,0090'; // Referring Physician's Name
case station_name = '0008,1010'; // Station Name
case series_description = '0008,103E'; // Series Description
case manufacturer_model_name = '0008,1090'; // Manufacturer's Model Name
case patient_age = '0010,1010'; // Patient's Age
case patient_weight = '0010,1030'; // Patient's Weight
case body_part_examined = '0018,0015'; // Body Part Examined
case protocol_name = '0018,1030'; // Protocol Name
case software_versions = '0018,1020'; // Software Versions
case acquisition_date = '0008,0022'; // Acquisition Date
case acquisition_time = '0008,0032'; // Acquisition Time
case content_date = '0008,0023'; // Content Date
case content_time = '0008,0033'; // Content Time
case acquisition_device_processing_description = '0018,1400'; // Acquisition Device Processing Description
case institution_address = '0008,0081'; // Institution Address
case study_description = '0008,1030'; // Study Description
case operators_name = '0008,1070'; // Operator's Name
case private_10 = '0029,0010'; // Private Tag 10
case iw_private = '0009,0010'; // IW Private Tag
case image_type = '0008,0008'; // Image Type
case patient_orientation = '0020,0020'; // Patient Orientation
case image_position_patient = '0020,0032'; // Image Position (Patient)
case image_orientation_patient = '0020,0037'; // Image Orientation (Patient)
case frame_of_reference_uid = '0020,0052'; // Frame of Reference UID
case position_reference_indicator = '0020,1040'; // Position Reference Indicator
case slice_location = '0020,1041'; // Slice Location
case samples_per_pixel = '0028,0002'; // Samples per Pixel
case photometric_interpretation = '0028,0004'; // Photometric Interpretation
case rows = '0028,0010'; // Rows
case columns = '0028,0011'; // Columns
case pixel_spacing = '0028,0030'; // Pixel Spacing
case bits_allocated = '0028,0100'; // Bits Allocated
case bits_stored = '0028,0101'; // Bits Stored
case high_bit = '0028,0102'; // High Bit
case pixel_representation = '0028,0103'; // Pixel Representation
case window_center = '0028,1050'; // Window Center
case window_width = '0028,1051'; // Window Width
case rescale_intercept = '0028,1052'; // Rescale Intercept
case rescale_slope = '0028,1053'; // Rescale Slope
case ino_wave_private = '0011,1060';
case rad_fusion_sender_id = '1971,1020';
}

View File

@ -0,0 +1,398 @@
<?php
namespace App\Services;
use DateTime;
use DateTimeZone;
final class TimezoneList
{
/**
* HTML entities.
*/
private const MINUS = '&#8722;';
private const PLUS = '&#43;';
private const WHITESPACE = '&#160;';
/**
* General timezones.
*
* @var array<string>
*/
protected array $generalTimezones = [
'GMT',
'UTC',
];
/**
* All continents of the world.
*
* @var array<string, string>
*/
protected array $continents = [
'Africa' => DateTimeZone::AFRICA,
'America' => DateTimeZone::AMERICA,
'Antarctica' => DateTimeZone::ANTARCTICA,
'Arctic' => DateTimeZone::ARCTIC,
'Asia' => DateTimeZone::ASIA,
'Atlantic' => DateTimeZone::ATLANTIC,
'Australia' => DateTimeZone::AUSTRALIA,
'Europe' => DateTimeZone::EUROPE,
'Indian' => DateTimeZone::INDIAN,
'Pacific' => DateTimeZone::PACIFIC,
];
/**
* The filter of the groups to get.
*/
protected array $groupsFilter = [];
/**
* Status of grouping the return list.
*/
protected bool $splitGroup = true;
/**
* Status of showing timezone offset.
*/
protected bool $showOffset = true;
/**
* The offset prefix in list.
*/
protected string $offsetPrefix = 'GMT/UTC';
/**
* Set the filter of the groups want to get.
*
* @return $this
*/
public function onlyGroups(array $groups = []): static
{
$this->groupsFilter = $groups;
return $this;
}
/**
* Set the filter of the groups do not want to get.
*
* @return $this
*/
public function excludeGroups(array $groups = []): static
{
if (empty($groups)) {
$this->groupsFilter = [];
return $this;
}
$this->groupsFilter = array_values(array_diff(array_keys($this->continents), $groups));
if (! in_array('General', $groups)) {
$this->groupsFilter[] = 'General';
}
return $this;
}
/**
* Decide whether to split group or not.
*
* @return $this
*/
public function splitGroup(bool $status = true): static
{
$this->splitGroup = (bool) $status;
return $this;
}
/**
* Decide whether to show the offset or not.
*
* @return $this
*/
public function showOffset(bool $status = true): static
{
$this->showOffset = (bool) $status;
return $this;
}
/**
* Return new static to reset all config.
*
* @return $this
*/
public function reset(): static
{
return new self;
}
/**
* Create an array of timezones.
*/
public function toArray(bool $htmlEncode = true): mixed
{
$list = [];
// If do not split group
if (! $this->splitGroup) {
if ($this->includeGeneral()) {
foreach ($this->generalTimezones as $timezone) {
$list[$timezone] = $this->formatTimezone($timezone, null, $htmlEncode);
}
}
foreach ($this->loadContinents() as $continent => $mask) {
$timezones = DateTimeZone::listIdentifiers($mask);
foreach ($timezones as $timezone) {
$list[$timezone] = $this->formatTimezone($timezone, null, $htmlEncode);
}
}
return $list;
}
// If split group
if ($this->includeGeneral()) {
foreach ($this->generalTimezones as $timezone) {
$list['General'][$timezone] = $this->formatTimezone($timezone, null, $htmlEncode);
}
}
foreach ($this->loadContinents() as $continent => $mask) {
$timezones = DateTimeZone::listIdentifiers($mask);
foreach ($timezones as $timezone) {
$list[$continent][$timezone] = $this->formatTimezone($timezone, $continent, $htmlEncode);
}
}
return $list;
}
/**
* Alias of the `toSelectBox()` method.
*
* @param string $name The name of the select tag
* @param string|null $selected The selected value
* @param array|string|null $attrs The HTML attributes of select tag
* @param bool $htmlEncode Use HTML entities for values of select tag
*
*@deprecated 6.0.0 This method name no longer matches the semantics
*/
public function create(string $name, ?string $selected = null, array|string|null $attrs = null, bool $htmlEncode = true): string
{
return $this->toSelectBox($name, $selected, $attrs, $htmlEncode);
}
/**
* Create a select box of timezones.
*
* @param string $name The name of the select tag
* @param string|null $selected The selected value
* @param array|string|null $attrs The HTML attributes of select tag
* @param bool $htmlEncode Use HTML entities for values of select tag
* @return string
*/
public function toSelectBox(string $name, ?string $selected = null, array|string|null $attrs = null, bool $htmlEncode = true)
{
// Attributes for select element
$attrString = null;
if (! empty($attrs)) {
if (is_array($attrs)) {
foreach ($attrs as $attr_name => $attr_value) {
$attrString .= ' ' . $attr_name . '="' . $attr_value . '"';
}
} else {
$attrString = $attrs;
}
}
if ($this->splitGroup) {
return $this->makeSelectTagWithGroup($name, $selected, $attrString, $htmlEncode);
}
return $this->makeSelectTagWithoutGroup($name, $selected, $attrString, $htmlEncode);
}
/**
* Generate select element with the optgroup tag.
*
* @param string $name The name of the select tag
* @param null|string $selected The selected value
* @param null|string $attrs The HTML attributes of select tag
* @param bool $htmlEncode Use HTML entities for values of select tag
* @return string
*/
protected function makeSelectTagWithGroup(string $name, ?string $selected = null, ?string $attrs = null, bool $htmlEncode = true)
{
$attrs = ! empty($attrs) ? ' ' . trim((string) $attrs) : '';
$output = '<select name="' . (string) $name . '"' . $attrs . '>';
if ($this->includeGeneral()) {
$output .= '<optgroup label="General">';
foreach ($this->generalTimezones as $timezone) {
$output .= $this->makeOptionTag($this->formatTimezone($timezone, null, $htmlEncode), $timezone, ($selected == $timezone));
}
$output .= '</optgroup>';
}
foreach ($this->loadContinents() as $continent => $mask) {
$timezones = DateTimeZone::listIdentifiers($mask);
$output .= '<optgroup label="' . $continent . '">';
foreach ($timezones as $timezone) {
$output .= $this->makeOptionTag($this->formatTimezone($timezone, $continent, $htmlEncode), $timezone, ($selected == $timezone));
}
$output .= '</optgroup>';
}
$output .= '</select>';
return $output;
}
/**
* Generate select element without the optgroup tag.
*
* @param string $name The name of the select tag
* @param null|string $selected The selected value
* @param null|string $attrs The HTML attributes of select tag
* @param bool $htmlEncode Use HTML entities for values of select tag
* @return string
*/
protected function makeSelectTagWithoutGroup(string $name, ?string $selected = null, ?string $attrs = null, bool $htmlEncode = true)
{
$attrs = ! empty($attrs) ? ' ' . trim((string) $attrs) : '';
$output = '<select name="' . (string) $name . '"' . $attrs . '>';
if ($this->includeGeneral()) {
foreach ($this->generalTimezones as $timezone) {
$output .= $this->makeOptionTag($this->formatTimezone($timezone, null, $htmlEncode), $timezone, ($selected == $timezone));
}
}
foreach ($this->loadContinents() as $continent => $mask) {
$timezones = DateTimeZone::listIdentifiers($mask);
foreach ($timezones as $timezone) {
$output .= $this->makeOptionTag($this->formatTimezone($timezone, null, $htmlEncode), $timezone, ($selected == $timezone));
}
}
$output .= '</select>';
return $output;
}
/**
* Generate the option HTML tag.
*/
protected function makeOptionTag(string $display, string $value, bool $selected = false): string
{
$attrs = (bool) $selected ? ' selected="selected"' : '';
return '<option value="' . $value . '"' . $attrs . '>' . $display . '</option>';
}
/**
* DetermineCheck if the general timezones is loaded in the returned result.
*/
protected function includeGeneral(): bool
{
return empty($this->groupsFilter) || in_array('General', $this->groupsFilter);
}
/**
* Load filtered continents.
*/
protected function loadContinents(): array
{
if (empty($this->groupsFilter)) {
return $this->continents;
}
return array_filter($this->continents, function ($key) {
return in_array($key, $this->groupsFilter);
}, ARRAY_FILTER_USE_KEY);
}
/**
* Format to display timezones.
*/
protected function formatTimezone(string $timezone, ?string $cutOffContinent = null, bool $htmlEncode = true): string
{
$displayedTimezone = empty($cutOffContinent) ? $timezone : substr($timezone, strlen($cutOffContinent) + 1);
$normalizedTimezone = $this->normalizeTimezone($displayedTimezone, $htmlEncode);
if (! $this->showOffset) {
return $normalizedTimezone;
}
$offset = $this->normalizeOffset($this->getOffset($timezone), $htmlEncode);
$separator = $this->normalizeSeparator($htmlEncode);
return '(' . $this->offsetPrefix . $offset . ')' . $separator . $normalizedTimezone;
}
/**
* Normalize the offset.
*/
protected function normalizeOffset(string $offset, bool $htmlEncode = true): string
{
$search = ['-', '+'];
$replace = $htmlEncode ? [' ' . self::MINUS . ' ', ' ' . self::PLUS . ' '] : [' - ', ' + '];
return str_replace($search, $replace, $offset);
}
/**
* Normalize the timezone.
*/
protected function normalizeTimezone(string $timezone, bool $htmlEncode = true): string
{
$search = ['St_', '/', '_'];
$replace = ['St. ', ' / ', ' '];
return str_replace($search, $replace, $timezone);
}
/**
* Normalize the separator between the timezone and offset.
*/
protected function normalizeSeparator(bool $htmlEncode = true): string
{
return $htmlEncode ? str_repeat(self::WHITESPACE, 5) : ' ';
}
/**
* Get the timezone offset.
*/
protected function getOffset(string $timezone): string
{
$time = new DateTime('', new DateTimeZone($timezone));
return $time->format('P');
}
/**
* Get the difference of timezone to Coordinated Universal Time (UTC).
*/
protected function getUTCOffset(string $timezone): string
{
$dateTimeZone = new DateTimeZone($timezone);
$utcTime = new DateTime('', new DateTimeZone('UTC'));
$offset = $dateTimeZone->getOffset($utcTime);
$format = gmdate('H:i', abs($offset));
return $offset >= 0 ? "+{$format}" : "-{$format}";
}
}

View File

@ -3,7 +3,7 @@
namespace App\Services\Workflow;
use App\Domain\ACL\Permission;
use App\Domain\Report\ReportStatus;
use App\Domain\Study\WorkflowLevel;
use App\Models\Study;
use App\Models\User;
use Closure;
@ -42,28 +42,28 @@ public function can(Permission $permission): bool
public function canAssignPhysician(): bool
{
return $this->activeStudy(fn () => $this->study->report_status <= ReportStatus::Preliminary);
return $this->activeStudy(fn () => $this->study->workflow_level <= WorkflowLevel::DraftAvailable);
}
public function canStudyHistoryEdit(): bool
{
return $this->activeStudy(fn () => $this->study->report_status <= ReportStatus::Preliminary);
return $this->activeStudy(fn () => $this->study->workflow_level <= WorkflowLevel::DraftAvailable);
}
public function canAttachmentUpload(): bool
{
return $this->activeStudy(fn () => $this->study->report_status <= ReportStatus::Preliminary);
return $this->activeStudy(fn () => $this->study->workflow_level <= WorkflowLevel::DraftAvailable);
}
public function canReportDownload(): bool
{
return $this->activeStudy(fn () => $this->study->report_status >= ReportStatus::Finalized);
return $this->activeStudy(fn () => $this->study->workflow_level >= WorkflowLevel::Finalized);
}
public function canReportEdit(): bool
{
return $this->activeStudy(fn () => $this->study->isUnlocked() &&
$this->study->report_status <= ReportStatus::Preliminary &&
$this->study->workflow_level <= WorkflowLevel::DraftAvailable &&
$this->study->isAssignedTo($this->user)
);
}

View File

@ -3,6 +3,7 @@
use App\Models\Study;
use App\Models\User;
use App\Services\AuditTrail\ActivityLogger;
use App\Services\Pacs\Sync\StudiesSync;
use Illuminate\Support\Str;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
@ -35,16 +36,11 @@ function array_purge(array $ary): array
}
if (! function_exists('array_trim_strings')) {
function array_trim_strings(array $ary): array
function array_trim_strings(array $ary, bool $null_if_blank = false): array
{
return array_filter($ary, function ($v) {
if (! is_string($v)) {
return $v;
}
$v = trim($v);
$trimmed = array_map(static fn ($v) => is_string($v) ? trim($v) : $v, $ary);
return blank($v) ? null : $v;
});
return $null_if_blank ? array_map(static fn ($v) => empty($v) ? null : $v, $trimmed) : $trimmed;
}
}
@ -52,7 +48,7 @@ function array_trim_strings(array $ary): array
function sync_agent_id(): int
{
return cache()->rememberForever('sync_agent_id',
fn () => User::where('username', '$$_pacs_sync_$$')->first()->id);
fn () => User::where('username', StudiesSync::SYNC_AGENT)->first()->id);
}
}
@ -122,11 +118,13 @@ function me(User|int|null $user = null): User
{
if (is_int($user) && $user > 0) {
return User::find($user);
} elseif ($user instanceof User) {
return $user;
} else {
return auth()->user();
}
if ($user instanceof User) {
return $user;
}
return auth()->user();
}
}
@ -140,3 +138,10 @@ function formatTitle(string $str): string
->toString();
}
}
if (! function_exists('tokenizeString')) {
function tokenizeString(string $str): array
{
return preg_split('/\W+/', trim($str), -1, PREG_SPLIT_NO_EMPTY);
}
}

View File

@ -29,6 +29,7 @@
"sentry/sentry-laravel": "^4.10",
"spatie/laravel-medialibrary": "^11.11",
"spatie/laravel-permission": "^6.10",
"symfony/expression-language": "^7.2",
"vinkla/hashids": "^12.0",
"yajra/laravel-datatables": "^11.0"
},

View File

@ -13,7 +13,9 @@
|
*/
'name' => env('APP_NAME', 'Laravel'),
'name' => env('APP_NAME', 'PixelBridge'),
'version' => env('APP_VERSION', '0.1.0'),
/*
|--------------------------------------------------------------------------

View File

@ -0,0 +1,3 @@
is_active,organization,name
1,Chevron Panchlaish,DR
1,Chevron Panchlaish,Imaging
1 is_active organization name
2 1 Chevron Panchlaish DR
3 1 Chevron Panchlaish Imaging

View File

@ -0,0 +1,3 @@
is_active,server_name,geo_code,host,http_port,dicom_port,rest_api_endpoint,ae_title,username,password,wado_path,stone_viewer_path,ohif_viewer_path,meddream_viewer_path,organization,department
1,CTG-1,BD,cgp1.telerad24.com,8042,4242,http://cgp1.telerad24.com:8042/,BLACKFISH,,,dicom-web,stone-webviewer/index.html,ohif/viewer,,,
1,NBG-1,DE,de1.telerad24.com,8042,4242,http://de1.telerad24.com:8042/,BLACKFISH,,,dicom-web,stone-webviewer/index.html,ohif/viewer,,,
1 is_active server_name geo_code host http_port dicom_port rest_api_endpoint ae_title username password wado_path stone_viewer_path ohif_viewer_path meddream_viewer_path organization department
2 1 CTG-1 BD cgp1.telerad24.com 8042 4242 http://cgp1.telerad24.com:8042/ BLACKFISH dicom-web stone-webviewer/index.html ohif/viewer
3 1 NBG-1 DE de1.telerad24.com 8042 4242 http://de1.telerad24.com:8042/ BLACKFISH dicom-web stone-webviewer/index.html ohif/viewer

View File

@ -0,0 +1,5 @@
name,is_active
CATCH-ALL,1
Chevron Panchlaish,1
Chevron Boropole,1
Chevron Shantibag,1
1 name is_active
2 CATCH-ALL 1
3 Chevron Panchlaish 1
4 Chevron Boropole 1
5 Chevron Shantibag 1

27
database/data/users.csv Normal file
View File

@ -0,0 +1,27 @@
DNAME,PREFIX,FNAME,LNAME,DEGREE,USERNAME,EMAIL,PHONE,ROLE,ORG,DEPT,SIGN_IMG
Dr. Sadrul ,Dr.,Sadrul ,Amin ,"MBBS, FCPS, M-Phil, MD (Radiology & Imaging), Ex-Associate Professor, Department of Radiology & Imaging, Bangabandhu Sheikh Mujib Medical University, BMDC Reg. No: A-26619",aminsadrul@gmail.com,aminsadrul@gmail.com,8801711362820,RAD,,,sadrul-amin.png
Prof. Dr. Subash ,Prof. Dr.,Subash ,Mazumder,"MBBS, MD, HOD (Radiology & Imaging), Chittagong Medical College Hospital. BMDC Reg. No: A 23715",subashmajumder@yahoo.com,subashmajumder@yahoo.com,8801819329484,RAD,,,subash.png
Dr. Goutam ,Dr.,Goutam,Chowdhury ,"MBBS, M.MED, MRCR (London), Fellow Radiology, Act (Neuro-Radiology), Consultant, Radiology & Imaging. BMDC Reg. No: A-32086",drgoutam,,,RAD,,,
Brig. Gen. Khairul,Brig. Gen. Dr.,Md. Khairul ,Islam,"MBBS, FCPS (Radiology & Imaging), Advanced Training on MRI, CT & Coronary CT Angiogram (India). Senior Consultant, Radiology & Imaging",drkhairul,,8801715059151,RAD,,,khairul-islam.png
Prof. Dr. Col. Nasir,Prof. Dr.Col. (Retd),Md. Nasir ,Uddin,"MBBS, FCPS, ARDMS, RVT, ARMRIT (USA), TMSS Medical College & Hospital, Bogura, Consultant Radiologist, Popular Diagnostic Center. BMDC Reg. No: A-21609",drnasir770@gmail.com,drnasir770@gmail.com,8801715748987,RAD,,,nasir-uddin.png
Prof. Brig. Gen. Saad ,Prof. (Brig. Gen),A M Hossain,Saad,"Classified Specialist, Radiology & Imaging, Armed Forces Medical College, Dhaka Cantonment. MBBS, FCPS. Fellow CT Scan (Dublin & Germany). Fellow MRI (KSA)",drsaad,,8801715059151,RAD,,,hossain-saad.png
Brig. Gen. Anwarul,Brig. Gen. (Retd),M Anwarul ,Haque,"MBBS. FCPS.(Radiology). Advance Training in CT & MRI (KSA), BMDC Reg. No: A -13336",dr.mahaque60@gmail.com,dr.mahaque60@gmail.com,8801911302505,RAD,,,
Dr. Moinul ,Dr.,Mohammad Moinul,Hossain,"MBBS, MD (Radiology & Imaging)-BSMMU, Consultant (Radiology & Imaging), Radiology & Imaging Specialist, BMDC Reg. No: A-33097",mmhossainsa06@gmail.com,mmhossainsa06@gmail.com,8801715129437,RAD,,,
Prof. Rabeya ,Asst. Prof. Dr. ,Rabeya,Khatoon,"MBBS, MCPS, MD (BSMMU), Consultant, Radiolgoy & Imaging, BIRDEM, Dhaka. BMDC Reg. No: A-47981",drrabeya,,,RAD,,,rabeya-khatoon.png
Prof. Col. Anowar,Prof. Col. Dr.,Anowar ,Hossain,"MBBS,FCPS (Radiology & Imaging), Fellow Cardiovascular Imaging (CT and MRI). All India Institute of Medical Science, New Delhi, India. Department of Radiology & Imaging Armed Forces Medical College & CMH Dhaka, BMDC Reg No : A-32496",dranowar,,8801795430240,RAD,,,
Dr. Hamid,Dr.,Hamid ,Alam ,,drhamid,,16462956323,RAD,,,
Dr. Shabiha ,Dr.,Shabiha,Quadir ,,dr.shabihaquadir@gmail.com,dr.shabihaquadir@gmail.com,8801819876760,RAD,,,
Dr. Ehsan,Dr.,Ehsan S,Choudhury,,ehsanchy@gmail.com,ehsanchy@gmail.com,8801711898269,RAD,,,
Dr. Mobin,Dr.,Mobinul Alam,Chowdhury ,,mobinchy@hotmail.com,mobinchy@hotmail.com,8801819775757,RAD,,,
Noor Mohammad ,,Noor ,Mohammad ,,noor.mohammad024@gmail.com,noor.mohammad024@gmail.com,8801716817712,TECH,Chevron Panchlaish,Imaging,
Ali Akbar ,,Ali ,Akbar ,,ali.akbar.ri@gmail.com,ali.akbar.ri@gmail.com,8801816913564,TECH,Chevron Panchlaish,Imaging,
Bulbul,,Mahbubul ,Islam ,,bulbulislam10@gmail.com,bulbulislam10@gmail.com,8801761587552,TECH,Chevron Panchlaish,Imaging,
Zulhas,,Zillur Rahman,Zulhas,,rahmanmdzillur9@gmail.com,rahmanmdzillur9@gmail.com,8801868658028,TECH,Chevron Panchlaish,Imaging,
Dr. Masroor,Dr.,Masroor Ehsan,Choudhury,,masroor,masroore@gmail.com,8801733938582,ADMIN,,,
Sumit,,Sumit,Majumder,,sumit.chvrnit@gmail.com,sumit.chvrnit@gmail.com,8801822224492,ADMIN,,,
Saiful,,Saiful,Islam,,saiful,,8801824534945,TECH,Chevron Panchlaish,Imaging,
Apon,,Apon,,,apon,,8801627970974,TECH,Chevron Panchlaish,Imaging,
Chevron CR,,X-ray,Tech,,chevroncr,,,TECH,Chevron Panchlaish,DR,
Dr. Gopinathan,Dr.,Gopinathan,,"MBBS, MDRD, Reg No.62980",drgopinathan,,,RAD,,,
Dr. Sudha,Dr.,K Sudha,,"MBBS, MDRD",drsudha,,,RAD,,,
Dr. Rajkumar,Dr.,Rajkumar,,MBBS MDRD Reg No.TMMC130572,drrajkumar,,,RAD,,,
1 DNAME PREFIX FNAME LNAME DEGREE USERNAME EMAIL PHONE ROLE ORG DEPT SIGN_IMG
2 Dr. Sadrul Dr. Sadrul Amin MBBS, FCPS, M-Phil, MD (Radiology & Imaging), Ex-Associate Professor, Department of Radiology & Imaging, Bangabandhu Sheikh Mujib Medical University, BMDC Reg. No: A-26619 aminsadrul@gmail.com aminsadrul@gmail.com 8801711362820 RAD sadrul-amin.png
3 Prof. Dr. Subash Prof. Dr. Subash Mazumder MBBS, MD, HOD (Radiology & Imaging), Chittagong Medical College Hospital. BMDC Reg. No: A 23715 subashmajumder@yahoo.com subashmajumder@yahoo.com 8801819329484 RAD subash.png
4 Dr. Goutam Dr. Goutam Chowdhury MBBS, M.MED, MRCR (London), Fellow Radiology, Act (Neuro-Radiology), Consultant, Radiology & Imaging. BMDC Reg. No: A-32086 drgoutam RAD
5 Brig. Gen. Khairul Brig. Gen. Dr. Md. Khairul Islam MBBS, FCPS (Radiology & Imaging), Advanced Training on MRI, CT & Coronary CT Angiogram (India). Senior Consultant, Radiology & Imaging drkhairul 8801715059151 RAD khairul-islam.png
6 Prof. Dr. Col. Nasir Prof. Dr.Col. (Retd) Md. Nasir Uddin MBBS, FCPS, ARDMS, RVT, ARMRIT (USA), TMSS Medical College & Hospital, Bogura, Consultant Radiologist, Popular Diagnostic Center. BMDC Reg. No: A-21609 drnasir770@gmail.com drnasir770@gmail.com 8801715748987 RAD nasir-uddin.png
7 Prof. Brig. Gen. Saad Prof. (Brig. Gen) A M Hossain Saad Classified Specialist, Radiology & Imaging, Armed Forces Medical College, Dhaka Cantonment. MBBS, FCPS. Fellow CT Scan (Dublin & Germany). Fellow MRI (KSA) drsaad 8801715059151 RAD hossain-saad.png
8 Brig. Gen. Anwarul Brig. Gen. (Retd) M Anwarul Haque MBBS. FCPS.(Radiology). Advance Training in CT & MRI (KSA), BMDC Reg. No: A -13336 dr.mahaque60@gmail.com dr.mahaque60@gmail.com 8801911302505 RAD
9 Dr. Moinul Dr. Mohammad Moinul Hossain MBBS, MD (Radiology & Imaging)-BSMMU, Consultant (Radiology & Imaging), Radiology & Imaging Specialist, BMDC Reg. No: A-33097 mmhossainsa06@gmail.com mmhossainsa06@gmail.com 8801715129437 RAD
10 Prof. Rabeya Asst. Prof. Dr. Rabeya Khatoon MBBS, MCPS, MD (BSMMU), Consultant, Radiolgoy & Imaging, BIRDEM, Dhaka. BMDC Reg. No: A-47981 drrabeya RAD rabeya-khatoon.png
11 Prof. Col. Anowar Prof. Col. Dr. Anowar Hossain MBBS,FCPS (Radiology & Imaging), Fellow Cardiovascular Imaging (CT and MRI). All India Institute of Medical Science, New Delhi, India. Department of Radiology & Imaging Armed Forces Medical College & CMH Dhaka, BMDC Reg No : A-32496 dranowar 8801795430240 RAD
12 Dr. Hamid Dr. Hamid Alam drhamid 16462956323 RAD
13 Dr. Shabiha Dr. Shabiha Quadir dr.shabihaquadir@gmail.com dr.shabihaquadir@gmail.com 8801819876760 RAD
14 Dr. Ehsan Dr. Ehsan S Choudhury ehsanchy@gmail.com ehsanchy@gmail.com 8801711898269 RAD
15 Dr. Mobin Dr. Mobinul Alam Chowdhury mobinchy@hotmail.com mobinchy@hotmail.com 8801819775757 RAD
16 Noor Mohammad Noor Mohammad noor.mohammad024@gmail.com noor.mohammad024@gmail.com 8801716817712 TECH Chevron Panchlaish Imaging
17 Ali Akbar Ali Akbar ali.akbar.ri@gmail.com ali.akbar.ri@gmail.com 8801816913564 TECH Chevron Panchlaish Imaging
18 Bulbul Mahbubul Islam bulbulislam10@gmail.com bulbulislam10@gmail.com 8801761587552 TECH Chevron Panchlaish Imaging
19 Zulhas Zillur Rahman Zulhas rahmanmdzillur9@gmail.com rahmanmdzillur9@gmail.com 8801868658028 TECH Chevron Panchlaish Imaging
20 Dr. Masroor Dr. Masroor Ehsan Choudhury masroor masroore@gmail.com 8801733938582 ADMIN
21 Sumit Sumit Majumder sumit.chvrnit@gmail.com sumit.chvrnit@gmail.com 8801822224492 ADMIN
22 Saiful Saiful Islam saiful 8801824534945 TECH Chevron Panchlaish Imaging
23 Apon Apon apon 8801627970974 TECH Chevron Panchlaish Imaging
24 Chevron CR X-ray Tech chevroncr TECH Chevron Panchlaish DR
25 Dr. Gopinathan Dr. Gopinathan MBBS, MDRD, Reg No.62980 drgopinathan RAD
26 Dr. Sudha Dr. K Sudha MBBS, MDRD drsudha RAD
27 Dr. Rajkumar Dr. Rajkumar MBBS MDRD Reg No.TMMC130572 drrajkumar RAD

View File

@ -10,7 +10,7 @@ public function up(): void
{
Schema::create('organizations', static function (Blueprint $table) {
$table->id();
$table->string('guid', 40)->unique()->index()->default(DB::raw("concat('INS-', gen_random_uuid())"));
$table->string('guid', 40)->unique()->index()->default(DB::raw("concat('ORG-', gen_random_uuid())"));
$table->string('name')->unique();
$table->boolean('is_active')->default(false);
$table->string('address')->nullable();

View File

@ -11,7 +11,7 @@ public function up(): void
{
Schema::create('departments', static function (Blueprint $table) {
$table->id();
$table->string('guid', 40)->unique()->index()->default(DB::raw("concat('FAC-', gen_random_uuid())"));
$table->string('guid', 40)->unique()->index()->default(DB::raw("concat('DEP-', gen_random_uuid())"));
$table->boolean('is_active')->default(false)->index();
$table->foreignIdFor(Organization::class)->constrained()->cascadeOnDelete();
$table->string('name');

View File

@ -16,7 +16,8 @@ public function up(): void
$table->string('server_name')->unique();
$table->string('geo_code', 2)->index();
$table->string('host');
$table->integer('port');
$table->integer('http_port');
$table->integer('dicom_port');
$table->string('rest_api_endpoint');
$table->string('ae_title')->nullable();
$table->string('username')->nullable();

View File

@ -1,6 +1,5 @@
<?php
use App\Domain\Report\ReportStatus;
use App\Domain\Study\Priority;
use App\Domain\Study\WorkflowLevel;
use App\Models\Department;
@ -21,8 +20,7 @@ public function up(): void
$table->foreignIdFor(Department::class)->nullable()->index();
$table->unsignedTinyInteger('priority')->default(Priority::Routine->value);
$table->unsignedTinyInteger('workflow_level')->default(WorkflowLevel::Pending->value);
$table->unsignedTinyInteger('report_status')->default(ReportStatus::Unread->value);
$table->unsignedTinyInteger('workflow_level')->default(WorkflowLevel::Received->value);
$table->boolean('requires_approval')->default(false);
$table->string('orthanc_uuid')->index();
@ -73,7 +71,7 @@ public function up(): void
$table->index(['department_id', 'assigned_at']);
$table->index(['department_id', 'locked_at']);
$table->index(['requires_approval', 'report_status']);
$table->index(['requires_approval', 'workflow_level']);
});
}

View File

@ -1,6 +1,6 @@
<?php
use App\Domain\Report\ReportStatus;
use App\Domain\Study\WorkflowLevel;
use App\Models\Department;
use App\Models\Organization;
use App\Models\Study;
@ -15,7 +15,7 @@ public function up(): void
{
Schema::create('study_reports', function (Blueprint $table) {
$table->id();
$table->unsignedTinyInteger('report_status')->default(ReportStatus::Unread->value);
$table->unsignedTinyInteger('workflow_level')->default(WorkflowLevel::Received->value);
$table->string('accession_number', 64)->unique()->index()->default(DB::raw("concat('REP-', gen_random_uuid())"));
$table->foreignIdFor(Organization::class)->index()->nullable()->constrained()->nullOnDelete();

View File

@ -11,8 +11,6 @@ public function up(): void
Schema::create('study_details', function (Blueprint $table) {
$table->id();
$table->foreignId('study_id')->unique()->constrained('studies')->cascadeOnDelete();
$table->string('orthanc_uuid')->unique();
// $table->foreignId('user_id')->constrained('users');
$table->text('clinical_history')->nullable();
$table->text('surgical_history')->nullable();
$table->text('lab_results')->nullable();

View File

@ -1,6 +1,5 @@
<?php
use App\Domain\Rule\MatchCondition;
use App\Models\AssignmentPanel;
use App\Models\Department;
use App\Models\Organization;
@ -21,7 +20,7 @@ public function up(): void
$table->foreignIdFor(User::class)->nullable()->constrained()->nullOnDelete();
$table->foreignIdFor(AssignmentPanel::class)->nullable()->constrained()->nullOnDelete();
$table->string('name')->nullable();
$table->string('match_condition')->default(MatchCondition::ANY->value);
$table->string('condition');
$table->unsignedTinyInteger('priority')->default(0);
$table->timestamps();

View File

@ -1,31 +0,0 @@
<?php
use App\Domain\Rule\MatchMode;
use App\Models\DicomRoutingRule;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('dicom_rule_conditions', function (Blueprint $table) {
$table->id();
$table->foreignIdFor(DicomRoutingRule::class)->constrained()->cascadeOnDelete();
$table->string('dicom_tag');
$table->string('search_pattern');
$table->boolean('case_sensitive')->default(false);
$table->string('match_mode')->default(MatchMode::Exact->value);
$table->unsignedTinyInteger('priority')->default(0);
$table->timestamps();
$table->index(['dicom_routing_rule_id', 'priority']);
});
}
public function down(): void
{
Schema::dropIfExists('dicom_rule_conditions');
}
};

View File

@ -15,9 +15,10 @@ public function run(): void
$this->call([
RoleSeeder::class,
OrganizationSeeder::class,
DicomServerSeeder::class,
DicomRoutingRuleSeeder::class,
UserSeeder::class,
ModalityProcedureSeeder::class,
]);
}
}

View File

@ -0,0 +1,43 @@
<?php
namespace Database\Seeders;
use App\Models\Department;
use App\Models\DicomRoutingRule;
use App\Models\Organization;
use Illuminate\Database\Seeder;
class DicomRoutingRuleSeeder extends Seeder
{
public function run(): void
{
$chev = Organization::where('name', 'Chevron Panchlaish')->first();
$dept_chev_xr = Department::where('name', 'DR')->first();
$chev_dep_ct_mr = Department::where('name', 'Imaging')->first();
$dr_modalities = ['DR', 'CR', 'DX', 'MG', 'OT', 'RF', 'XA', 'XRF', 'DXA', 'DBT', 'OPT'];
$dr = collect($dr_modalities)->map(fn ($modality) => sprintf('"%s"', $modality))->implode(', ');
DicomRoutingRule::create(
[
'is_active' => true,
'name' => 'Chevron Panchlaish Imaging',
'organization_id' => $chev->id,
'department_id' => $chev_dep_ct_mr->id,
'condition' => sprintf('(study.institution_name ?? "" starts with "chevron") and (study.modality not in [%s])', $dr),
]
);
DicomRoutingRule::create(
[
'is_active' => true,
'name' => 'Chevron Panchlaish X-ray',
'organization_id' => $chev->id,
'department_id' => $dept_chev_xr->id,
'condition' => sprintf('(study.institution_name ?? "" starts with "chevron") and (study.modality in [%s])', $dr),
'priority' => 99,
]
);
}
}

View File

@ -0,0 +1,43 @@
<?php
namespace Database\Seeders;
use App\Models\DicomServer;
use Illuminate\Database\Seeder;
class DicomServerSeeder extends Seeder
{
public function run(): void
{
$skip_first = true;
$csv_file = fopen(base_path('database/data/dicom-servers.csv'), 'r');
while (($data = fgetcsv($csv_file, 2000, ',')) !== false) {
if ($skip_first) {
$skip_first = false;
continue;
}
$data = array_trim_strings($data, true);
DicomServer::create([
'is_active' => (bool) $data['0'],
'server_name' => strtoupper($data['1']),
'geo_code' => $data['2'],
'host' => $data['3'],
'http_port' => $data['4'],
'dicom_port' => $data['5'],
'rest_api_endpoint' => $data['6'],
'ae_title' => strtoupper($data['7']),
'username' => strtolower($data['8'] ?? ''),
'password' => $data['9'],
'wado_path' => $data['10'],
'stone_viewer_path' => $data['11'],
'ohif_viewer_path' => $data['12'],
'meddream_viewer_path' => $data['13'],
// 'organization' => $data['14'],
// 'department' => $data['15'],
]);
}
fclose($csv_file);
}
}

View File

@ -2,139 +2,47 @@
namespace Database\Seeders;
use App\Domain\Rule\MatchCondition;
use App\Domain\Rule\MatchMode;
use App\Models\Department;
use App\Models\DicomRoutingRule;
use App\Models\DicomRuleCondition;
use App\Models\DicomServer;
use App\Models\Organization;
use App\Services\StudyRouter\RawDicomTags;
use Illuminate\Database\Seeder;
class OrganizationSeeder extends Seeder
{
public function run(): void
{
Organization::create([
'name' => 'CATCH-ALL',
'is_active' => true,
]);
$chev = Organization::create([
'name' => 'Chevron',
'is_active' => true,
]);
$cmch = Organization::create([
'name' => 'CMCH',
'is_active' => true,
]);
$dummy = Organization::create([
'name' => 'Dummy Site',
'is_active' => false,
]);
$organizations = [];
$dept_chev_xr = Department::create(
[
'is_active' => true,
'name' => 'Chev-CR',
'organization_id' => $chev->id,
]
);
$chev_dep_ct_mr = Department::create(
[
'is_active' => true,
'name' => 'Chev-MR',
'organization_id' => $chev->id,
]
);
$dept_cmch_mr = Department::create(
[
'is_active' => false,
'name' => 'CMCH MR',
'organization_id' => $cmch->id,
]
);
$rul_chev_mr_ct = DicomRoutingRule::create(
[
'is_active' => true,
'name' => 'Chevron MR/CT',
'organization_id' => $chev->id,
'department_id' => $chev_dep_ct_mr->id,
'match_condition' => MatchCondition::ALL->value,
]
);
$rul_chev_xr = DicomRoutingRule::create(
[
'is_active' => true,
'name' => 'Chevron X-ray',
'organization_id' => $chev->id,
'department_id' => $dept_chev_xr->id,
'match_condition' => MatchCondition::ALL->value,
'priority' => 10,
]
);
DicomRuleCondition::create([
'dicom_tag' => RawDicomTags::InstitutionName->value,
'search_pattern' => 'chevron',
'dicom_routing_rule_id' => $rul_chev_mr_ct->id,
'match_mode' => MatchMode::Contains->value,
'case_sensitive' => false,
]);
DicomRuleCondition::create([
'dicom_tag' => RawDicomTags::Modality->value,
'search_pattern' => 'MR,CT',
'dicom_routing_rule_id' => $rul_chev_mr_ct->id,
'match_mode' => MatchMode::InList->value,
'case_sensitive' => true,
$skip_first = true;
$org_csv = fopen(base_path('database/data/organizations.csv'), 'r');
while (($data = fgetcsv($org_csv, 2000, ',')) !== false) {
if (! $skip_first) {
$data = array_trim_strings($data);
$name = $data['0'];
$organizations[$name] = Organization::create([
'name' => $name,
'is_active' => (bool) $data['1'],
]);
}
$skip_first = false;
}
fclose($org_csv);
DicomRuleCondition::create([
'dicom_tag' => RawDicomTags::InstitutionName->value,
'search_pattern' => 'chevron',
'dicom_routing_rule_id' => $rul_chev_xr->id,
'match_mode' => MatchMode::Contains->value,
'case_sensitive' => false,
$dept_csv = fopen(base_path('database/data/departments.csv'), 'r');
$skip_first = true;
while (($data = fgetcsv($dept_csv, 2000, ',')) !== false) {
if (! $skip_first) {
$data = array_trim_strings($data, true);
$name = $data['2'];
$org_name = $data['1'];
$org = $organizations[$org_name];
Department::create([
'name' => $name,
'is_active' => (bool) $data['0'],
'organization_id' => $org->id,
]);
DicomRuleCondition::create([
'dicom_tag' => RawDicomTags::Modality->value,
'search_pattern' => 'CR,DX,MG',
'dicom_routing_rule_id' => $rul_chev_xr->id,
'match_mode' => MatchMode::InList->value,
'case_sensitive' => true,
]);
DicomServer::create(
[
'is_active' => true,
'server_name' => 'CTG-1',
'geo_code' => 'BD',
'host' => 'pacs.mylabctg.com',
'port' => 8042,
'rest_api_endpoint' => 'http://pacs.mylabctg.com:8042/',
'wado_path' => 'dicom-web',
'ae_title' => 'RADFUSION',
'stone_viewer_path' => 'stone-webviewer/index.html',
'ohif_viewer_path' => 'ohif/viewer',
]
);
DicomServer::create(
[
'is_active' => false,
'server_name' => 'MAA-1',
'host' => 'pacs.mylabctg.com',
'geo_code' => 'IN',
'port' => 8043,
'rest_api_endpoint' => 'http://pacs.mylabctg.com:8042/',
'ae_title' => 'RADFUSION',
'wado_path' => 'dicom-web',
'stone_viewer_path' => 'stone-webviewer/index.html',
'ohif_viewer_path' => 'ohif/viewer',
'organization_id' => $chev->id,
'department_id' => $dept_chev_xr->id,
]
);
}
$skip_first = false;
}
fclose($dept_csv);
}
}

View File

@ -6,91 +6,79 @@
use App\Models\Department;
use App\Models\Organization;
use App\Models\User;
use App\Services\Pacs\Sync\StudiesSync;
use Illuminate\Database\Seeder;
class UserSeeder extends Seeder
{
public function run(): void
{
// User::factory(10)->create();
$usr = User::factory()->create([
'first_name' => 'PACS Sync',
'display_name' => 'PACS Sync Agent',
'username' => '$$_pacs_sync_$$',
'first_name' => 'PACS Agent',
'display_name' => 'PACS Agent',
'username' => StudiesSync::SYNC_AGENT,
'password' => bcrypt(fake()->password(20)),
'is_active' => false,
]);
$usr->assignRole(Role::SystemAgent);
$usr = User::factory()->create([
'first_name' => 'Administrator',
'display_name' => 'Admin',
'username' => 'admin',
'email' => 'admin@example.com',
'email_verified_at' => now(),
'phone' => '+8801733938582',
$orgs = Organization::pluck('id', 'name');
$depts = Department::pluck('id', 'name');
$skip_first = true;
$csv_file = fopen(base_path('database/data/users.csv'), 'r');
while (($data = fgetcsv($csv_file, 2 * 1024, ',')) !== false) {
if ($skip_first) {
$skip_first = false;
continue;
}
$data = array_trim_strings($data, true);
$role = Role::from(strtolower($data[8]));
$org_id = $data[9] ? $orgs[$data[9]] : null;
$dept_id = $data[10] ? $depts[$data[10]] : null;
$image = $data[11] ? 'signatures/' . $data[11] : null;
$user = User::create([
'is_active' => true,
'display_name' => $data[0],
'prefix' => $data[1],
'first_name' => $data[2],
'last_name' => $data[3],
'signature_text' => $data[4],
'signature_image_path' => $image,
'username' => $data[5],
'email' => strtolower($data[6]),
'phone' => (string) $data[7],
'email_verified_at' => $data[6] ? now() : null,
'password' => bcrypt('password'),
'organization_id' => $org_id,
'department_id' => $dept_id,
]);
$usr->assignRole(Role::Admin);
$user->assignRole($role->value);
}
fclose($csv_file);
$chevron = Organization::where('name', 'Chevron')->first();
$cmch = Organization::where('name', 'CMCH')->first();
$this->copySignatureImages();
}
$chev_xr = Department::where('organization_id', $chevron->id)->first();
User::factory(4)
->create([
'organization_id' => $chevron->id,
])
->each(function ($u, $key) {
$u->assignRole(Role::Technician);
$u->update([
'username' => sprintf('tech%d', $key + 1),
]);
});
private function copySignatureImages()
{
$sourcePath = base_path('resources/report/signatures');
$dest = storage_path('app/public/signatures');
User::factory(4)
->create([
'organization_id' => $chevron->id,
'department_id' => $chev_xr->id,
])
->each(function ($u, $key) {
$u->assignRole(Role::Technician);
$u->update([
'username' => sprintf('xrt%d', $key + 1),
]);
});
if (! is_dir($dest)) {
mkdir($dest, 0755, true);
}
User::factory(4)
->create([
'organization_id' => $cmch->id,
])
->each(function ($u, $key) {
$u->assignRole(Role::Technician);
$u->update([
'username' => sprintf('cmctech%d', $key + 1),
]);
});
$files = scandir($sourcePath);
foreach ($files as $file) {
if (in_array($file, ['.', '..'])) {
continue;
}
$images = [
'hossain-saad.png', 'nasir-uddin.png', 'sadrul-amin.png',
'khairul-islam.png', 'rabeya-khatoon.png', 'subash.png',
];
User::factory(9)
->create()
->each(function (User $u, $key) use ($images) {
$u->assignRole(Role::Radiologist);
$u->update([
'display_name' => sprintf('Dr. %s.%d', $u->first_name, $key + 1),
'username' => sprintf('rad%d', $key + 1),
'signature_image_path' => 'signatures/' . fake()->randomElement($images),
]);
});
User::factory(3)
->create()
->each(function ($u) {
$u->assignRole(Role::Guest);
});
copy("{$sourcePath}/{$file}", "{$dest}/{$file}");
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 902 B

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 915 B

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -7,7 +7,7 @@
<div class="{{ $containerFooter }}">
<div class="footer-container d-flex align-items-center justify-content-between py-4 flex-md-row flex-column">
<div class="text-body mb-2 mb-md-0 fw-light">
© 2024-<script>document.write(new Date().getFullYear())</script> {{ config('app.name') }}. All rights reserved.
© 2024-<script>document.write(new Date().getFullYear())</script> {{ config('app.name') }} v{{ config('app.version') }}. All rights reserved.
</div>
<div class="text-body d-none d-lg-inline-block">
Made with <span class="text-danger"><i class="tf-icons ri-heart-fill"></i></span> by

View File

@ -32,13 +32,25 @@
<h5 class="mb-0">Patient Information</h5>
</div>
<div class="card-body">
@include('staff.meta.partials._text', ['name' => 'patient_id', 'label' => 'Patient ID', 'value' => $study->patient_id])
@include('staff.meta.partials._text', ['name' => 'patient_name', 'label' => 'Patient Name', 'value' => $study->patient_name])
@include('staff.meta.partials._text', ['name' => 'patient_sex', 'label' => 'Sex', 'value' => $study->patient_sex])
@include('staff.meta.partials._date', ['name' => 'patient_birthdate', 'label' => 'Birth Date', 'value' => $study->patient_birthdate->toDateString()])
<div class="row">
<div class="col-6">
@include('staff.meta.partials._text', ['name' => 'patient_id', 'label' => 'Patient ID', 'value' => $study->patient_id])
</div>
<div class="col-6">
@include('staff.meta.partials._text', ['name' => 'accession_number', 'label' => 'Accession number', 'value' => $study->accession_number])
</div>
</div>
<div class="row">
<div class="col-6">
@include('staff.meta.partials._text', ['name' => 'patient_sex', 'label' => 'Sex', 'value' => $study->patient_sex])
</div>
<div class="col-6">
@include('staff.meta.partials._date', ['name' => 'patient_birthdate', 'label' => 'Birth Date', 'value' => $study->patient_birthdate?->toDateString()])
</div>
</div>
</div>
</div>
</div>
<div class="col-xl">
@ -47,10 +59,24 @@
<h5 class="mb-0">Study Information</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-6">
@include('staff.meta.partials._text', ['name' => 'study_description', 'label' => 'Study Description', 'value' => $study->study_description])
</div>
<div class="col-6">
@include('staff.meta.partials._text', ['name' => 'body_part_examined', 'label' => 'Body Part Examined', 'value' => $study->body_part_examined])
</div>
</div>
<div class="row">
<div class="col-6">
@include('staff.meta.partials._text', ['name' => 'institution_name', 'label' => 'Institution', 'value' => $study->institution_name])
</div>
<div class="col-6">
@include('staff.meta.partials._text', ['name' => 'referring_physician_name', 'label' => 'Referring Physician', 'value' => $study->referring_physician_name])
</div>
</div>
<label class="form-check-label">Priority</label>
<div class="col my-2">
@ -62,7 +88,7 @@
<label class="form-check form-check-danger mt-4">
<input name="cancel_read" type="checkbox" class="form-check-input"/>
<span class="form-check-label">Cancel interpretation by radiologist</span>
<span class="form-check-label">No interpretation required</span>
</label>
</div>
</div>

View File

@ -52,7 +52,7 @@ class="ck-editor editor-container editor-container_classic-editor fixed-containe
<div class="form-check form-check-success custom-option custom-option-basic">
<label class="form-check-label custom-option-content" for="radio_prelim">
<input name="report_status" class="form-check-input" type="radio"
value="{{ \App\Domain\Report\ReportStatus::Preliminary->value }}"
value="{{ \App\Domain\Study\WorkflowLevel::DraftAvailable->value }}"
id="radio_prelim" checked/>
<span class="custom-option-body">
@ -68,7 +68,7 @@ class="ck-editor editor-container editor-container_classic-editor fixed-containe
<div class="form-check form-check-success custom-option custom-option-basic">
<label class="form-check-label custom-option-content" for="radio_final">
<input name="report_status" class="form-check-input" type="radio"
value="{{ \App\Domain\Report\ReportStatus::Finalized->value }}"
value="{{ \App\Domain\Study\WorkflowLevel::Finalized->value }}"
id="radio_final"/>
<span class="custom-option-body">

View File

@ -4,7 +4,7 @@
<td class="bg-gray-100">{{ $report->created_at }}</td>
<td>
<a target="_blank" href="{{ $report->viewUrl() }}">
{{ $report->report_status->name }}
{{ $report->workflow_level->name }}
<i class="fa-regular fa-square-arrow-up-right ms-1"></i>
</a>
</td>

45
update_ver.sh Executable file
View File

@ -0,0 +1,45 @@
#!/bin/bash
# Define the default increment type (patch if not provided)
INCREMENT_TYPE=${1:-patch}
# Read the current version from .env
if ! grep -q "^APP_VERSION=" .env; then
echo "APP_VERSION=$(date +'%y.%m')-0.1.0" >> .env
fi
CURRENT_VERSION=$(grep "^APP_VERSION=" .env | cut -d '=' -f2)
# Extract parts of the version
IFS='.-' read -r YY MM MAJOR MINOR PATCH <<< "$CURRENT_VERSION"
# Get the current date-based prefix
CURRENT_YY=$(date +'%y')
CURRENT_MM=$(date +'%m')
# If the month changes, reset the version
if [[ "$YY" != "$CURRENT_YY" || "$MM" != "$CURRENT_MM" ]]; then
MAJOR=0
MINOR=1
PATCH=0
else
# Increment version based on type
if [[ "$INCREMENT_TYPE" == "major" ]]; then
((MAJOR++))
MINOR=0
PATCH=0
elif [[ "$INCREMENT_TYPE" == "minor" ]]; then
((MINOR++))
PATCH=0
else
((PATCH++))
fi
fi
# Generate new version
NEW_VERSION="$CURRENT_YY.$CURRENT_MM-$MAJOR.$MINOR.$PATCH"
# Update the .env file
sed -i "s/^APP_VERSION=.*/APP_VERSION=$NEW_VERSION/" .env
echo "Updated .env APP_VERSION to $NEW_VERSION"