Add specialty module and GP service pricing settings.
Deploy Ladill Care / deploy (push) Successful in 51s
Deploy Ladill Care / deploy (push) Successful in 51s
Admin dashboard module cards open per-module settings with editable service prices; GP consultation and clinic fees live under Settings → GP pricing (inventory drugs unchanged). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Care;
|
||||
|
||||
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Care\CarePermissions;
|
||||
use App\Services\Care\CarePricingService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class SettingsGpPricingController extends Controller
|
||||
{
|
||||
use ScopesToAccount;
|
||||
|
||||
public function edit(Request $request, CarePricingService $pricing): View
|
||||
{
|
||||
$this->authorizeAbility($request, 'settings.view');
|
||||
$organization = $this->organization($request);
|
||||
$canManage = app(CarePermissions::class)->can($this->member($request), 'settings.manage');
|
||||
|
||||
return view('care.settings.gp-pricing', [
|
||||
'organization' => $organization,
|
||||
'services' => $pricing->gpServices($organization),
|
||||
'canManage' => $canManage,
|
||||
'currency' => strtoupper((string) config('care.billing.currency', 'GHS')),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, CarePricingService $pricing): RedirectResponse
|
||||
{
|
||||
$this->authorizeAbility($request, 'settings.manage');
|
||||
$organization = $this->organization($request);
|
||||
|
||||
$validated = $request->validate([
|
||||
'services' => ['required', 'array'],
|
||||
'services.*.code' => ['required', 'string', 'max:64'],
|
||||
'services.*.amount' => ['required', 'numeric', 'min:0', 'max:999999'],
|
||||
]);
|
||||
|
||||
$pricing->updateGpServices($organization, $validated['services']);
|
||||
|
||||
return redirect()
|
||||
->route('care.settings.gp-pricing')
|
||||
->with('success', 'GP service prices saved.');
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,13 @@
|
||||
|
||||
namespace App\Http\Controllers\Care;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Care\CarePermissions;
|
||||
use App\Services\Care\CarePricingService;
|
||||
use App\Services\Care\PlanService;
|
||||
use App\Services\Care\SpecialtyModuleService;
|
||||
use App\Services\Care\SpecialtyShellService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
@@ -19,7 +22,7 @@ class SettingsModulesController extends Controller
|
||||
$this->authorizeAbility($request, 'settings.view');
|
||||
$organization = $this->organization($request);
|
||||
$member = $this->member($request);
|
||||
$canManage = app(\App\Services\Care\CarePermissions::class)->can($member, 'settings.manage');
|
||||
$canManage = app(CarePermissions::class)->can($member, 'settings.manage');
|
||||
|
||||
if ($modules->canManage($organization)) {
|
||||
$modules->ensureDefaultModulesProvisioned($organization, $this->ownerRef($request));
|
||||
@@ -36,6 +39,71 @@ class SettingsModulesController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(
|
||||
Request $request,
|
||||
string $module,
|
||||
SpecialtyModuleService $modules,
|
||||
SpecialtyShellService $shell,
|
||||
CarePricingService $pricing,
|
||||
): View {
|
||||
$this->authorizeAbility($request, 'settings.view');
|
||||
$definition = $modules->definition($module);
|
||||
abort_unless($definition, 404);
|
||||
|
||||
$organization = $this->organization($request);
|
||||
$member = $this->member($request);
|
||||
$canManage = app(CarePermissions::class)->can($member, 'settings.manage');
|
||||
$canUse = $modules->canManage($organization);
|
||||
|
||||
if ($canUse && $modules->isEnabled($organization, $module)) {
|
||||
$shell->seedServices($organization, $module);
|
||||
$organization->refresh();
|
||||
}
|
||||
|
||||
$services = $canUse
|
||||
? $shell->provisionedServices($organization, $module)
|
||||
: $shell->catalogServices($module);
|
||||
|
||||
return view('care.settings.module-show', [
|
||||
'organization' => $organization,
|
||||
'moduleKey' => $module,
|
||||
'definition' => $definition,
|
||||
'services' => $services,
|
||||
'enabled' => $modules->isEnabled($organization, $module),
|
||||
'canManage' => $canManage && $canUse,
|
||||
'canUseSpecialtyModules' => $canUse,
|
||||
'canOpenWorkspace' => $modules->memberCanAccess($organization, $member, $module),
|
||||
'currency' => strtoupper((string) config('care.billing.currency', 'GHS')),
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateServices(
|
||||
Request $request,
|
||||
string $module,
|
||||
SpecialtyModuleService $modules,
|
||||
SpecialtyShellService $shell,
|
||||
CarePricingService $pricing,
|
||||
): RedirectResponse {
|
||||
$this->authorizeAbility($request, 'settings.manage');
|
||||
$organization = $this->organization($request);
|
||||
abort_unless($modules->definition($module), 404);
|
||||
abort_unless($modules->canManage($organization), 403);
|
||||
|
||||
$validated = $request->validate([
|
||||
'services' => ['required', 'array'],
|
||||
'services.*.code' => ['required', 'string', 'max:64'],
|
||||
'services.*.amount' => ['required', 'numeric', 'min:0', 'max:999999'],
|
||||
'services.*.label' => ['nullable', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
$shell->seedServices($organization, $module);
|
||||
$pricing->updateSpecialtyServices($organization->fresh(), $module, $validated['services'], $shell);
|
||||
|
||||
return redirect()
|
||||
->route('care.settings.modules.show', $module)
|
||||
->with('success', 'Service prices saved.');
|
||||
}
|
||||
|
||||
public function update(Request $request, SpecialtyModuleService $modules): RedirectResponse
|
||||
{
|
||||
$this->authorizeAbility($request, 'settings.manage');
|
||||
|
||||
@@ -171,7 +171,10 @@ class BillService
|
||||
|
||||
if (! in_array('consultation', $workflowChargeCodes, true)
|
||||
&& $visit->consultations()->where('status', 'completed')->exists()) {
|
||||
$fee = (int) config('care.billing.consultation_fee_minor', 5000);
|
||||
$org = Organization::query()->find($visit->organization_id);
|
||||
$fee = $org
|
||||
? app(CarePricingService::class)->consultationFee($org)
|
||||
: (int) config('care.billing.consultation_fee_minor', 5000);
|
||||
$this->addLineItem($bill, $ownerRef, [
|
||||
'type' => 'consultation',
|
||||
'description' => 'Consultation fee',
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Care;
|
||||
|
||||
use App\Models\Organization;
|
||||
|
||||
/**
|
||||
* Org-level price lists for GP (general practice) and specialty module services.
|
||||
* Drug prices stay on Inventory (care_drugs.unit_price_minor); lab prices on investigation types.
|
||||
*/
|
||||
class CarePricingService
|
||||
{
|
||||
/**
|
||||
* @return list<array{code: string, label: string, amount_minor: int, type: string}>
|
||||
*/
|
||||
public function defaultGpServices(): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'code' => 'gp.consultation',
|
||||
'label' => 'GP consultation',
|
||||
'amount_minor' => (int) config('care.billing.consultation_fee_minor', 5000),
|
||||
'type' => 'consultation',
|
||||
],
|
||||
[
|
||||
'code' => 'gp.follow_up',
|
||||
'label' => 'Follow-up consultation',
|
||||
'amount_minor' => (int) config('care.billing.follow_up_fee_minor', 3000),
|
||||
'type' => 'consultation',
|
||||
],
|
||||
[
|
||||
'code' => 'gp.patient_card',
|
||||
'label' => 'Patient card / registration',
|
||||
'amount_minor' => (int) config('care.billing.patient_card_fee_minor', 2000),
|
||||
'type' => 'misc',
|
||||
],
|
||||
[
|
||||
'code' => 'gp.vitals',
|
||||
'label' => 'Vitals / nursing charge',
|
||||
'amount_minor' => (int) config('care.billing.vitals_fee_minor', 1000),
|
||||
'type' => 'nursing',
|
||||
],
|
||||
[
|
||||
'code' => 'gp.procedure',
|
||||
'label' => 'Minor procedure',
|
||||
'amount_minor' => (int) config('care.billing.minor_procedure_fee_minor', 5000),
|
||||
'type' => 'procedure',
|
||||
],
|
||||
[
|
||||
'code' => 'gp.home_visit',
|
||||
'label' => 'Home visit',
|
||||
'amount_minor' => (int) config('care.billing.home_visit_fee_minor', 15000),
|
||||
'type' => 'consultation',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{code: string, label: string, amount_minor: int, type: string}>
|
||||
*/
|
||||
public function gpServices(Organization $organization): array
|
||||
{
|
||||
$catalog = collect($this->defaultGpServices())->keyBy(fn ($s) => (string) $s['code']);
|
||||
$stored = data_get($organization->settings, 'gp_services');
|
||||
if (is_array($stored)) {
|
||||
foreach ($stored as $service) {
|
||||
$code = (string) ($service['code'] ?? '');
|
||||
if ($code === '' || ! $catalog->has($code)) {
|
||||
continue;
|
||||
}
|
||||
$prior = $catalog->get($code, []);
|
||||
$catalog->put($code, array_merge(is_array($prior) ? $prior : [], [
|
||||
'code' => $code,
|
||||
'label' => (string) ($service['label'] ?? $prior['label'] ?? $code),
|
||||
'amount_minor' => (int) ($service['amount_minor'] ?? $prior['amount_minor'] ?? 0),
|
||||
'type' => (string) ($service['type'] ?? $prior['type'] ?? 'misc'),
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
return $catalog->values()->all();
|
||||
}
|
||||
|
||||
public function gpAmount(Organization $organization, string $code): int
|
||||
{
|
||||
$match = collect($this->gpServices($organization))
|
||||
->first(fn ($s) => ($s['code'] ?? '') === $code);
|
||||
|
||||
return (int) ($match['amount_minor'] ?? 0);
|
||||
}
|
||||
|
||||
public function consultationFee(Organization $organization): int
|
||||
{
|
||||
return $this->gpAmount($organization, 'gp.consultation');
|
||||
}
|
||||
|
||||
public function patientCardFee(Organization $organization): int
|
||||
{
|
||||
return $this->gpAmount($organization, 'gp.patient_card');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{code: string, amount_minor: int|string, label?: string}> $rows
|
||||
* @return list<array{code: string, label: string, amount_minor: int, type: string}>
|
||||
*/
|
||||
public function updateGpServices(Organization $organization, array $rows): array
|
||||
{
|
||||
$defaults = collect($this->defaultGpServices())->keyBy(fn ($s) => (string) $s['code']);
|
||||
$merged = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$code = (string) ($row['code'] ?? '');
|
||||
if ($code === '' || ! $defaults->has($code)) {
|
||||
continue;
|
||||
}
|
||||
$base = $defaults->get($code);
|
||||
$merged[] = [
|
||||
'code' => $code,
|
||||
'label' => (string) ($base['label'] ?? $code),
|
||||
'amount_minor' => max(0, (int) round(((float) ($row['amount'] ?? 0)) * 100)),
|
||||
'type' => (string) ($base['type'] ?? 'misc'),
|
||||
];
|
||||
}
|
||||
|
||||
// Keep any defaults not submitted.
|
||||
$byCode = collect($merged)->keyBy('code');
|
||||
foreach ($defaults as $code => $base) {
|
||||
if (! $byCode->has($code)) {
|
||||
$byCode->put($code, $base);
|
||||
}
|
||||
}
|
||||
|
||||
$list = $byCode->values()->all();
|
||||
$settings = $organization->settings ?? [];
|
||||
$settings['gp_services'] = $list;
|
||||
$organization->update(['settings' => $settings]);
|
||||
|
||||
return $list;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist specialty module service amounts (major currency input → minor).
|
||||
*
|
||||
* @param list<array{code: string, amount?: float|int|string, label?: string}> $rows
|
||||
* @return list<array{code: string, label: string, amount_minor: int, type: string}>
|
||||
*/
|
||||
public function updateSpecialtyServices(
|
||||
Organization $organization,
|
||||
string $moduleKey,
|
||||
array $rows,
|
||||
SpecialtyShellService $shell,
|
||||
): array {
|
||||
$current = collect($shell->provisionedServices($organization, $moduleKey))
|
||||
->keyBy(fn ($s) => (string) ($s['code'] ?? ''));
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$code = (string) ($row['code'] ?? '');
|
||||
if ($code === '' || ! $current->has($code)) {
|
||||
continue;
|
||||
}
|
||||
$prior = $current->get($code);
|
||||
$current->put($code, array_merge(is_array($prior) ? $prior : [], [
|
||||
'code' => $code,
|
||||
'label' => (string) ($row['label'] ?? $prior['label'] ?? $code),
|
||||
'amount_minor' => max(0, (int) round(((float) ($row['amount'] ?? 0)) * 100)),
|
||||
'type' => (string) ($prior['type'] ?? 'misc'),
|
||||
]));
|
||||
}
|
||||
|
||||
$list = $current->filter(fn ($s, $code) => $code !== '')->values()->all();
|
||||
$settings = $organization->settings ?? [];
|
||||
$provisioning = is_array($settings['specialty_module_provisioning'] ?? null)
|
||||
? $settings['specialty_module_provisioning']
|
||||
: [];
|
||||
$record = is_array($provisioning[$moduleKey] ?? null) ? $provisioning[$moduleKey] : [];
|
||||
$record['services'] = $list;
|
||||
$provisioning[$moduleKey] = $record;
|
||||
$settings['specialty_module_provisioning'] = $provisioning;
|
||||
$organization->update(['settings' => $settings]);
|
||||
|
||||
return $list;
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ use App\Models\Prescription;
|
||||
use App\Models\Visit;
|
||||
use App\Models\WorkflowStage;
|
||||
use App\Services\Care\CareFeatures;
|
||||
use App\Services\Care\CarePricingService;
|
||||
|
||||
/**
|
||||
* Owns FinancialObligation lifecycle and answers whether a stage's financial
|
||||
@@ -154,9 +155,16 @@ class FinancialGateService
|
||||
|
||||
protected function defaultAmountFor(WorkflowStage $stage, Visit $visit): int
|
||||
{
|
||||
$org = Organization::query()->find($visit->organization_id);
|
||||
$pricing = app(CarePricingService::class);
|
||||
|
||||
return match ($stage->charge_code) {
|
||||
'patient_card', 'registration' => (int) config('care.billing.patient_card_fee_minor', 0),
|
||||
'consultation' => (int) config('care.billing.consultation_fee_minor', 0),
|
||||
'patient_card', 'registration' => $org
|
||||
? $pricing->patientCardFee($org)
|
||||
: (int) config('care.billing.patient_card_fee_minor', 0),
|
||||
'consultation' => $org
|
||||
? $pricing->consultationFee($org)
|
||||
: (int) config('care.billing.consultation_fee_minor', 0),
|
||||
'lab' => $this->investigationTotal($visit, imaging: false),
|
||||
'imaging' => $this->investigationTotal($visit, imaging: true),
|
||||
'pharmacy' => $this->pharmacyTotal($visit),
|
||||
|
||||
@@ -253,6 +253,10 @@ return [
|
||||
'billing' => [
|
||||
'patient_card_fee_minor' => (int) env('CARE_PATIENT_CARD_FEE_MINOR', 2000),
|
||||
'consultation_fee_minor' => (int) env('CARE_CONSULTATION_FEE_MINOR', 5000),
|
||||
'follow_up_fee_minor' => (int) env('CARE_FOLLOW_UP_FEE_MINOR', 3000),
|
||||
'vitals_fee_minor' => (int) env('CARE_VITALS_FEE_MINOR', 1000),
|
||||
'minor_procedure_fee_minor' => (int) env('CARE_MINOR_PROCEDURE_FEE_MINOR', 5000),
|
||||
'home_visit_fee_minor' => (int) env('CARE_HOME_VISIT_FEE_MINOR', 15000),
|
||||
'currency' => env('CARE_CURRENCY', 'GHS'),
|
||||
],
|
||||
|
||||
|
||||
@@ -197,10 +197,19 @@
|
||||
<h2 class="text-sm font-semibold text-slate-900">Specialty modules</h2>
|
||||
<div class="mt-3 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
@foreach ($specialtyModules as $item)
|
||||
<a href="{{ route('care.specialty.show', $item['key']) }}"
|
||||
@php
|
||||
$toSettings = $permissions->can($member, 'settings.view');
|
||||
$href = $toSettings
|
||||
? route('care.settings.modules.show', $item['key'])
|
||||
: route('care.specialty.show', $item['key']);
|
||||
@endphp
|
||||
<a href="{{ $href }}"
|
||||
class="rounded-2xl border border-slate-200 bg-white px-4 py-4 transition hover:border-indigo-300 hover:shadow-sm">
|
||||
<p class="text-sm font-semibold text-slate-900">{{ $item['definition']['nav_label'] ?? $item['definition']['label'] }}</p>
|
||||
<p class="mt-1 text-xs text-slate-500">{{ $item['definition']['description'] ?? '' }}</p>
|
||||
@if ($toSettings)
|
||||
<p class="mt-2 text-[11px] font-medium text-indigo-600">Settings & pricing →</p>
|
||||
@endif
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
@@ -116,6 +116,16 @@
|
||||
</a>
|
||||
</li>
|
||||
@endif
|
||||
<li>
|
||||
<a href="{{ route('care.settings.gp-pricing') }}"
|
||||
class="flex items-center justify-between rounded-xl border border-slate-100 bg-slate-50 px-4 py-3 text-sm text-slate-700 hover:text-indigo-700">
|
||||
<span>
|
||||
GP pricing
|
||||
<span class="mt-0.5 block text-xs text-slate-500">Consultation, registration, and clinic service prices</span>
|
||||
</span>
|
||||
<span class="text-slate-400">→</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ route('care.settings.modules') }}"
|
||||
class="flex items-center justify-between rounded-xl border border-slate-100 bg-slate-50 px-4 py-3 text-sm text-slate-700 hover:text-indigo-700">
|
||||
@@ -124,7 +134,7 @@
|
||||
@if (empty($canUseSpecialtyModules))
|
||||
<span class="ml-2 rounded-full bg-amber-50 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-amber-700">Pro</span>
|
||||
@endif
|
||||
<span class="mt-0.5 block text-xs text-slate-500">Dentistry, eye care, physiotherapy, maternity, radiology</span>
|
||||
<span class="mt-0.5 block text-xs text-slate-500">Dentistry, eye care, physiotherapy, maternity, radiology — activate & set prices</span>
|
||||
</span>
|
||||
<span class="text-slate-400">→</span>
|
||||
</a>
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
<x-app-layout title="GP pricing · Settings">
|
||||
<x-settings.page
|
||||
title="GP products & services"
|
||||
description="Prices for general practice consultations and clinic services at {{ $organization->name }}. Drug prices are managed in Inventory; lab tests in Laboratory catalog."
|
||||
>
|
||||
<p class="text-sm">
|
||||
<a href="{{ route('care.settings') }}" class="font-medium text-indigo-600 hover:text-indigo-800">← Facility settings</a>
|
||||
</p>
|
||||
|
||||
@if (session('success'))
|
||||
<p class="rounded-lg bg-emerald-50 px-3 py-2 text-sm text-emerald-800">{{ session('success') }}</p>
|
||||
@endif
|
||||
|
||||
<x-settings.card title="Service prices" description="Used for GP consultation bills, registration / patient card, and related charges. Currency: {{ $currency }}.">
|
||||
<form method="POST" action="{{ route('care.settings.gp-pricing.update') }}" class="space-y-4">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-slate-200 text-xs uppercase tracking-wide text-slate-500">
|
||||
<th class="py-2 pr-3">Service</th>
|
||||
<th class="py-2 pr-3">Type</th>
|
||||
<th class="py-2">Price ({{ $currency }})</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach ($services as $i => $service)
|
||||
<tr class="border-b border-slate-100">
|
||||
<td class="py-2.5 pr-3">
|
||||
<input type="hidden" name="services[{{ $i }}][code]" value="{{ $service['code'] }}">
|
||||
<p class="font-medium text-slate-900">{{ $service['label'] }}</p>
|
||||
<p class="text-xs text-slate-400">{{ $service['code'] }}</p>
|
||||
</td>
|
||||
<td class="py-2.5 pr-3 text-slate-600">{{ ucfirst($service['type'] ?? 'misc') }}</td>
|
||||
<td class="py-2.5">
|
||||
<input
|
||||
type="number"
|
||||
name="services[{{ $i }}][amount]"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value="{{ old('services.'.$i.'.amount', number_format(((int) ($service['amount_minor'] ?? 0)) / 100, 2, '.', '')) }}"
|
||||
@disabled(! $canManage)
|
||||
class="w-32 rounded-lg border-slate-300 text-sm tabular-nums"
|
||||
required
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@if ($canManage)
|
||||
<div class="flex justify-end">
|
||||
<button type="submit" class="btn-primary">Save prices</button>
|
||||
</div>
|
||||
@endif
|
||||
</form>
|
||||
</x-settings.card>
|
||||
</x-settings.page>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,82 @@
|
||||
<x-app-layout title="{{ $definition['label'] ?? $moduleKey }} · Module settings">
|
||||
<x-settings.page
|
||||
title="{{ $definition['label'] ?? ucfirst($moduleKey) }}"
|
||||
description="{{ $definition['description'] ?? 'Module settings and service prices for '.$organization->name.'.' }}"
|
||||
>
|
||||
<p class="text-sm">
|
||||
<a href="{{ route('care.settings.modules') }}" class="font-medium text-indigo-600 hover:text-indigo-800">← Specialty modules</a>
|
||||
</p>
|
||||
|
||||
@if (session('success'))
|
||||
<p class="rounded-lg bg-emerald-50 px-3 py-2 text-sm text-emerald-800">{{ session('success') }}</p>
|
||||
@endif
|
||||
|
||||
<x-settings.card title="Status" description="Activate modules from the modules list. Drug prices stay in Inventory; lab test prices in Laboratory catalog.">
|
||||
<div class="flex flex-wrap items-center gap-3 text-sm">
|
||||
@if ($enabled)
|
||||
<span class="rounded-full bg-emerald-50 px-2.5 py-1 text-xs font-semibold text-emerald-800">Active</span>
|
||||
@else
|
||||
<span class="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-semibold text-slate-600">Inactive</span>
|
||||
@endif
|
||||
@if ($canOpenWorkspace)
|
||||
<a href="{{ route('care.specialty.show', $moduleKey) }}" class="font-medium text-indigo-600 hover:text-indigo-800">Open module workspace →</a>
|
||||
@endif
|
||||
<a href="{{ route('care.settings.modules') }}" class="font-medium text-slate-600 hover:text-slate-900">Manage activation</a>
|
||||
</div>
|
||||
</x-settings.card>
|
||||
|
||||
@if (! $canUseSpecialtyModules)
|
||||
<x-settings.card title="Pro feature" description="Specialty module pricing requires Care Pro or Enterprise.">
|
||||
<a href="{{ route('care.pro.index') }}" class="btn-primary mt-2 inline-flex text-sm">View plans</a>
|
||||
</x-settings.card>
|
||||
@else
|
||||
<x-settings.card title="Service prices" description="Amounts billed when these services are added from the specialty workspace. Currency: {{ $currency }}.">
|
||||
<form method="POST" action="{{ route('care.settings.modules.services.update', $moduleKey) }}" class="space-y-4">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-slate-200 text-xs uppercase tracking-wide text-slate-500">
|
||||
<th class="py-2 pr-3">Service</th>
|
||||
<th class="py-2 pr-3">Type</th>
|
||||
<th class="py-2">Price ({{ $currency }})</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach ($services as $i => $service)
|
||||
<tr class="border-b border-slate-100">
|
||||
<td class="py-2.5 pr-3">
|
||||
<input type="hidden" name="services[{{ $i }}][code]" value="{{ $service['code'] }}">
|
||||
<input type="hidden" name="services[{{ $i }}][label]" value="{{ $service['label'] }}">
|
||||
<p class="font-medium text-slate-900">{{ $service['label'] }}</p>
|
||||
<p class="text-xs text-slate-400">{{ $service['code'] }}</p>
|
||||
</td>
|
||||
<td class="py-2.5 pr-3 text-slate-600">{{ ucfirst($service['type'] ?? 'misc') }}</td>
|
||||
<td class="py-2.5">
|
||||
<input
|
||||
type="number"
|
||||
name="services[{{ $i }}][amount]"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value="{{ old('services.'.$i.'.amount', number_format(((int) ($service['amount_minor'] ?? 0)) / 100, 2, '.', '')) }}"
|
||||
@disabled(! $canManage)
|
||||
class="w-32 rounded-lg border-slate-300 text-sm tabular-nums"
|
||||
required
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@if ($canManage)
|
||||
<div class="flex justify-end">
|
||||
<button type="submit" class="btn-primary">Save prices</button>
|
||||
</div>
|
||||
@endif
|
||||
</form>
|
||||
</x-settings.card>
|
||||
@endif
|
||||
</x-settings.page>
|
||||
</x-app-layout>
|
||||
@@ -1,7 +1,9 @@
|
||||
<x-app-layout title="Modules · Settings">
|
||||
<x-settings.page title="Specialty modules" description="Activate specialty practice surfaces for {{ $organization->name }}. Departments and queues are provisioned per branch; deactivating hides UI without deleting clinical history.">
|
||||
<x-settings.page title="Specialty modules" description="Activate specialty practice surfaces for {{ $organization->name }}. Open a module to set service prices. Departments and queues are provisioned per branch; deactivating hides UI without deleting clinical history.">
|
||||
<p class="text-sm">
|
||||
<a href="{{ route('care.settings') }}" class="font-medium text-indigo-600 hover:text-indigo-800">← Facility settings</a>
|
||||
<span class="mx-2 text-slate-300">·</span>
|
||||
<a href="{{ route('care.settings.gp-pricing') }}" class="font-medium text-indigo-600 hover:text-indigo-800">GP pricing</a>
|
||||
</p>
|
||||
|
||||
@if (session('success'))
|
||||
@@ -24,27 +26,30 @@
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
@foreach ($catalog as $key => $module)
|
||||
@php $isDefault = ! empty($module['default_on_paid_plans']); @endphp
|
||||
<label class="flex flex-col rounded-2xl border border-slate-200 bg-white p-5 shadow-sm transition {{ $isDefault ? '' : 'cursor-pointer hover:border-indigo-200' }} {{ ! empty($enabled[$key]) || $isDefault ? 'ring-1 ring-indigo-200' : '' }}">
|
||||
<div class="flex flex-col rounded-2xl border border-slate-200 bg-white p-5 shadow-sm transition {{ ! empty($enabled[$key]) || $isDefault ? 'ring-1 ring-indigo-200' : '' }}">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<a href="{{ route('care.settings.modules.show', $key) }}" class="min-w-0 flex-1 hover:opacity-90">
|
||||
<p class="text-sm font-semibold text-slate-900">{{ $module['label'] }}</p>
|
||||
<p class="mt-1 text-xs text-slate-500">{{ $module['description'] }}</p>
|
||||
@if ($isDefault)
|
||||
<p class="mt-2 text-[11px] font-medium uppercase tracking-wide text-indigo-600">Included on Pro / Enterprise</p>
|
||||
@endif
|
||||
</div>
|
||||
<input type="checkbox" name="modules[{{ $key }}]" value="1"
|
||||
@checked(old("modules.{$key}", ! empty($enabled[$key]) || $isDefault))
|
||||
@disabled(! $canManage || $isDefault)
|
||||
class="mt-0.5 rounded border-slate-300">
|
||||
@if ($isDefault)
|
||||
<input type="hidden" name="modules[{{ $key }}]" value="1">
|
||||
@endif
|
||||
<p class="mt-3 text-[11px] font-medium text-indigo-600">Settings & pricing →</p>
|
||||
</a>
|
||||
<label class="{{ $isDefault ? '' : 'cursor-pointer' }}" title="Activate module" @click.stop>
|
||||
<input type="checkbox" name="modules[{{ $key }}]" value="1"
|
||||
@checked(old("modules.{$key}", ! empty($enabled[$key]) || $isDefault))
|
||||
@disabled(! $canManage || $isDefault)
|
||||
class="mt-0.5 rounded border-slate-300">
|
||||
@if ($isDefault)
|
||||
<input type="hidden" name="modules[{{ $key }}]" value="1">
|
||||
@endif
|
||||
</label>
|
||||
</div>
|
||||
<p class="mt-3 text-[11px] uppercase tracking-wide text-slate-400">
|
||||
{{ $module['department_name'] ?? $module['label'] }}
|
||||
</p>
|
||||
</label>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ use App\Http\Controllers\Care\QueueController;
|
||||
use App\Http\Controllers\Care\ReportController;
|
||||
use App\Http\Controllers\Care\ServiceQueueController;
|
||||
use App\Http\Controllers\Care\SettingsController;
|
||||
use App\Http\Controllers\Care\SettingsGpPricingController;
|
||||
use App\Http\Controllers\Care\SettingsModulesController;
|
||||
use App\Http\Controllers\Care\DentistryWorkspaceController;
|
||||
use App\Http\Controllers\Care\SpecialtyModuleController;
|
||||
@@ -227,8 +228,12 @@ Route::middleware(['auth', 'platform.session'])->group(function () {
|
||||
|
||||
Route::get('/settings', [SettingsController::class, 'edit'])->name('care.settings');
|
||||
Route::put('/settings', [SettingsController::class, 'update'])->name('care.settings.update');
|
||||
Route::get('/settings/gp-pricing', [SettingsGpPricingController::class, 'edit'])->name('care.settings.gp-pricing');
|
||||
Route::put('/settings/gp-pricing', [SettingsGpPricingController::class, 'update'])->name('care.settings.gp-pricing.update');
|
||||
Route::get('/settings/modules', [SettingsModulesController::class, 'edit'])->name('care.settings.modules');
|
||||
Route::put('/settings/modules', [SettingsModulesController::class, 'update'])->name('care.settings.modules.update');
|
||||
Route::get('/settings/modules/{module}', [SettingsModulesController::class, 'show'])->name('care.settings.modules.show');
|
||||
Route::put('/settings/modules/{module}/services', [SettingsModulesController::class, 'updateServices'])->name('care.settings.modules.services.update');
|
||||
|
||||
Route::get('/integrations', [IntegrationsController::class, 'edit'])->name('care.integrations');
|
||||
Route::post('/integrations/sms', [IntegrationsController::class, 'saveSms'])->name('care.integrations.sms.save');
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Http\Middleware\EnsurePlatformSession;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use App\Services\Care\CarePricingService;
|
||||
use App\Services\Care\SpecialtyModuleService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class CareServicePricingSettingsTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected User $owner;
|
||||
|
||||
protected Organization $organization;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->withoutMiddleware(EnsurePlatformSession::class);
|
||||
|
||||
$this->owner = User::create([
|
||||
'public_id' => 'pricing-owner',
|
||||
'name' => 'Owner',
|
||||
'email' => 'pricing-owner@example.com',
|
||||
]);
|
||||
|
||||
$this->organization = Organization::create([
|
||||
'owner_ref' => $this->owner->public_id,
|
||||
'name' => 'Pricing Clinic',
|
||||
'slug' => 'pricing-clinic',
|
||||
'settings' => [
|
||||
'onboarded' => true,
|
||||
'facility_type' => 'clinic',
|
||||
'plan' => 'pro',
|
||||
'plan_expires_at' => now()->addMonth()->toIso8601String(),
|
||||
],
|
||||
]);
|
||||
|
||||
Member::create([
|
||||
'owner_ref' => $this->owner->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
'user_ref' => $this->owner->public_id,
|
||||
'role' => 'hospital_admin',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_gp_pricing_page_saves_org_overrides(): void
|
||||
{
|
||||
$this->actingAs($this->owner)
|
||||
->get(route('care.settings.gp-pricing'))
|
||||
->assertOk()
|
||||
->assertSee('GP consultation')
|
||||
->assertSee('Patient card');
|
||||
|
||||
$this->actingAs($this->owner)
|
||||
->put(route('care.settings.gp-pricing.update'), [
|
||||
'services' => [
|
||||
['code' => 'gp.consultation', 'amount' => 75.50],
|
||||
['code' => 'gp.follow_up', 'amount' => 40],
|
||||
['code' => 'gp.patient_card', 'amount' => 25],
|
||||
['code' => 'gp.vitals', 'amount' => 10],
|
||||
['code' => 'gp.procedure', 'amount' => 50],
|
||||
['code' => 'gp.home_visit', 'amount' => 150],
|
||||
],
|
||||
])
|
||||
->assertRedirect(route('care.settings.gp-pricing'));
|
||||
|
||||
$this->organization->refresh();
|
||||
$pricing = app(CarePricingService::class);
|
||||
$this->assertSame(7550, $pricing->consultationFee($this->organization));
|
||||
$this->assertSame(2500, $pricing->patientCardFee($this->organization));
|
||||
}
|
||||
|
||||
public function test_specialty_module_settings_update_service_prices(): void
|
||||
{
|
||||
app(SpecialtyModuleService::class)->activate(
|
||||
$this->organization,
|
||||
$this->owner->public_id,
|
||||
'dentistry',
|
||||
);
|
||||
|
||||
$this->actingAs($this->owner)
|
||||
->get(route('care.settings.modules.show', 'dentistry'))
|
||||
->assertOk()
|
||||
->assertSee('Dental consultation')
|
||||
->assertSee('Service prices');
|
||||
|
||||
$this->actingAs($this->owner)
|
||||
->put(route('care.settings.modules.services.update', 'dentistry'), [
|
||||
'services' => [
|
||||
['code' => 'den.consult', 'amount' => 80, 'label' => 'Dental consultation'],
|
||||
['code' => 'den.fill', 'amount' => 150, 'label' => 'Filling'],
|
||||
],
|
||||
])
|
||||
->assertRedirect(route('care.settings.modules.show', 'dentistry'));
|
||||
|
||||
$this->organization->refresh();
|
||||
$services = collect(app(\App\Services\Care\SpecialtyShellService::class)
|
||||
->provisionedServices($this->organization, 'dentistry'))
|
||||
->keyBy('code');
|
||||
|
||||
$this->assertSame(8000, (int) $services['den.consult']['amount_minor']);
|
||||
$this->assertSame(15000, (int) $services['den.fill']['amount_minor']);
|
||||
}
|
||||
|
||||
public function test_admin_dashboard_links_modules_to_settings(): void
|
||||
{
|
||||
app(SpecialtyModuleService::class)->activate(
|
||||
$this->organization,
|
||||
$this->owner->public_id,
|
||||
'dentistry',
|
||||
);
|
||||
|
||||
$this->actingAs($this->owner)
|
||||
->get(route('care.dashboard'))
|
||||
->assertOk()
|
||||
->assertSee(route('care.settings.modules.show', 'dentistry', false))
|
||||
->assertSee('Settings & pricing');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user