update
This commit is contained in:
No files matched your search
@@ -7,7 +7,9 @@ use App\CriticalValueSample;
|
|||||||
use App\Organisms;
|
use App\Organisms;
|
||||||
use App\Periksa;
|
use App\Periksa;
|
||||||
use App\PeriksaSYNC;
|
use App\PeriksaSYNC;
|
||||||
|
use App\Paslab;
|
||||||
use App\Riwayat;
|
use App\Riwayat;
|
||||||
|
use App\SIMBHPReport;
|
||||||
use App\Subjawaban;
|
use App\Subjawaban;
|
||||||
use App\User;
|
use App\User;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
@@ -15,6 +17,7 @@ use Illuminate\Http\Request;
|
|||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Crypt;
|
use Illuminate\Support\Facades\Crypt;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
use Illuminate\Support\Facades\Session;
|
use Illuminate\Support\Facades\Session;
|
||||||
|
|
||||||
class MobileApiController extends Controller
|
class MobileApiController extends Controller
|
||||||
@@ -154,6 +157,7 @@ class MobileApiController extends Controller
|
|||||||
'klinisi' => $row->klinisi,
|
'klinisi' => $row->klinisi,
|
||||||
'klinis' => $row->klinis,
|
'klinis' => $row->klinis,
|
||||||
'nmdokter' => $row->nmdokter,
|
'nmdokter' => $row->nmdokter,
|
||||||
|
'kd_spesimen' => $row->kd_spesimen,
|
||||||
'nm_spesimen' => $row->nm_spesimen,
|
'nm_spesimen' => $row->nm_spesimen,
|
||||||
'kesimpulan' => $row->kesimpulan,
|
'kesimpulan' => $row->kesimpulan,
|
||||||
'dlp' => $row->dlp,
|
'dlp' => $row->dlp,
|
||||||
@@ -230,19 +234,187 @@ class MobileApiController extends Controller
|
|||||||
'butuh_verifikasi' => Periksa::where('status', 'like', '%Un Verified%')->count(),
|
'butuh_verifikasi' => Periksa::where('status', 'like', '%Un Verified%')->count(),
|
||||||
'antrian_hari_ini' => Periksa::whereDate('daftar', Carbon::today())->count(),
|
'antrian_hari_ini' => Periksa::whereDate('daftar', Carbon::today())->count(),
|
||||||
'antrian_sinkron' => PeriksaSYNC::where('created_by', $user->username)->where('status', '')->count(),
|
'antrian_sinkron' => PeriksaSYNC::where('created_by', $user->username)->where('status', '')->count(),
|
||||||
|
'criticalNotificationCount' => CriticalValueSample::whereNull('followed_up_at')->count(),
|
||||||
],
|
],
|
||||||
'books' => $books,
|
'books' => $books,
|
||||||
'early_warning_groups' => $this->earlyWarningGroups(),
|
'early_warning_groups' => $this->earlyWarningGroups(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function criticalValueNotifications(Request $request)
|
||||||
|
{
|
||||||
|
$this->requireUser($request);
|
||||||
|
$range = $this->resolveCriticalValueReportRange($request);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'count' => CriticalValueSample::whereNull('followed_up_at')->count(),
|
||||||
|
'pending_items' => $this->criticalValueItems(
|
||||||
|
CriticalValueSample::whereNull('followed_up_at')
|
||||||
|
->orderBy('critical_set_at', 'asc')
|
||||||
|
->limit(100)
|
||||||
|
->get()
|
||||||
|
),
|
||||||
|
'followed_items' => $this->criticalValueItems(
|
||||||
|
CriticalValueSample::whereNotNull('followed_up_at')
|
||||||
|
->where('followed_up_at', '>=', $range['start_at'])
|
||||||
|
->where('followed_up_at', '<=', $range['end_at'])
|
||||||
|
->orderBy('followed_up_at', 'desc')
|
||||||
|
->limit(100)
|
||||||
|
->get()
|
||||||
|
),
|
||||||
|
'start_date' => $range['start_date'],
|
||||||
|
'end_date' => $range['end_date'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function criticalValueNotificationMarkRead(Request $request, int $id)
|
||||||
|
{
|
||||||
|
$user = $this->requireUser($request);
|
||||||
|
$criticalSample = CriticalValueSample::find($id);
|
||||||
|
abort_if(!$criticalSample, 404, 'Sample nilai kritis tidak ditemukan.');
|
||||||
|
|
||||||
|
if (!empty($criticalSample->followed_up_at)) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Sample nilai kritis ini sudah ditindaklanjuti sebelumnya.',
|
||||||
|
'item' => $this->criticalValueItem($criticalSample),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$followUpMethod = trim((string) $request->input('caratindaklanjut', ''));
|
||||||
|
$followUpRecipient = trim((string) $request->input('penerima_laporan', ''));
|
||||||
|
$followUpReason = trim((string) $request->input('alasan_belum_laporkan', ''));
|
||||||
|
|
||||||
|
if ($followUpMethod === '') {
|
||||||
|
if ($followUpReason === '') {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Alasan belum dilaporkan wajib diisi jika belum dilaporkan.',
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$allowed = ['Telpon', 'Whatshap', 'Email', 'Ketemu di Jalan'];
|
||||||
|
if (!in_array($followUpMethod, $allowed, true)) {
|
||||||
|
return response()->json(['message' => 'Cara tindak lanjut tidak valid.'], 422);
|
||||||
|
}
|
||||||
|
if ($followUpRecipient === '') {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Penerima laporan wajib diisi jika sudah dilaporkan.',
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$criticalSample->followed_up_at = Carbon::now();
|
||||||
|
$criticalSample->followed_up_by_user_id = $user->id;
|
||||||
|
$criticalSample->followed_up_by_name = $user->nama;
|
||||||
|
$criticalSample->follow_up_status = $followUpMethod === '' ? 'not_reported' : 'reported';
|
||||||
|
$criticalSample->follow_up_method = $followUpMethod !== '' ? $followUpMethod : null;
|
||||||
|
$criticalSample->follow_up_recipient = $followUpRecipient !== '' ? $followUpRecipient : null;
|
||||||
|
$criticalSample->follow_up_reason = $followUpReason !== '' ? $followUpReason : null;
|
||||||
|
$criticalSample->save();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Sample nilai kritis sudah ditandai sudah ditindaklanjuti.',
|
||||||
|
'item' => $this->criticalValueItem($criticalSample),
|
||||||
|
'count' => CriticalValueSample::whereNull('followed_up_at')->count(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function resolveCriticalValueReportRange(Request $request): array
|
||||||
|
{
|
||||||
|
$defaultStart = Carbon::now()->startOfMonth()->toDateString();
|
||||||
|
$defaultEnd = Carbon::now()->endOfMonth()->toDateString();
|
||||||
|
$startDate = trim((string) $request->query('start_date', $defaultStart));
|
||||||
|
$endDate = trim((string) $request->query('end_date', $defaultEnd));
|
||||||
|
|
||||||
|
try {
|
||||||
|
$start = Carbon::createFromFormat('Y-m-d', $startDate)->startOfDay();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$start = Carbon::createFromFormat('Y-m-d', $defaultStart)->startOfDay();
|
||||||
|
$startDate = $defaultStart;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$end = Carbon::createFromFormat('Y-m-d', $endDate)->endOfDay();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$end = Carbon::createFromFormat('Y-m-d', $defaultEnd)->endOfDay();
|
||||||
|
$endDate = $defaultEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($start->gt($end)) {
|
||||||
|
[$start, $end] = [$end->copy()->startOfDay(), $start->copy()->endOfDay()];
|
||||||
|
$startDate = $start->toDateString();
|
||||||
|
$endDate = $end->toDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'start_date' => $startDate,
|
||||||
|
'end_date' => $endDate,
|
||||||
|
'start_at' => $start,
|
||||||
|
'end_at' => $end,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function criticalValueItems($items): array
|
||||||
|
{
|
||||||
|
return $items->map(fn ($item) => $this->criticalValueItem($item))->values()->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function criticalValueItem(CriticalValueSample $item): array
|
||||||
|
{
|
||||||
|
$tatMinutes = null;
|
||||||
|
$tatDurationLabel = '-';
|
||||||
|
$tatStatusLabel = '-';
|
||||||
|
|
||||||
|
if (!empty($item->critical_set_at) && !empty($item->followed_up_at)) {
|
||||||
|
$criticalSetAt = Carbon::parse($item->critical_set_at);
|
||||||
|
$followedUpAt = Carbon::parse($item->followed_up_at);
|
||||||
|
$tatMinutes = $criticalSetAt->diffInMinutes($followedUpAt);
|
||||||
|
$tatDurationLabel = $this->formatCriticalValueTatDuration($tatMinutes);
|
||||||
|
$tatStatusLabel = $tatMinutes <= 30 ? 'Memenuhi TAT' : 'Tidak Memenuhi TAT';
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $item->id,
|
||||||
|
'periksa_id' => $item->periksa_id,
|
||||||
|
'nofoto' => $item->nofoto,
|
||||||
|
'noregister' => $item->noregister,
|
||||||
|
'nmpasien' => $item->nmpasien,
|
||||||
|
'asalpasien' => $item->asalpasien,
|
||||||
|
'nm_spesimen' => $item->nm_spesimen,
|
||||||
|
'critical_set_at' => $item->critical_set_at,
|
||||||
|
'critical_set_by_name' => $item->critical_set_by_name,
|
||||||
|
'followed_up_at' => $item->followed_up_at,
|
||||||
|
'followed_up_by_name' => $item->followed_up_by_name,
|
||||||
|
'tat_minutes' => $tatMinutes,
|
||||||
|
'tat_duration_label' => $tatDurationLabel,
|
||||||
|
'tat_status_label' => $tatStatusLabel,
|
||||||
|
'follow_up_status' => $item->follow_up_status,
|
||||||
|
'follow_up_method' => $item->follow_up_method,
|
||||||
|
'follow_up_recipient' => $item->follow_up_recipient,
|
||||||
|
'follow_up_reason' => $item->follow_up_reason,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function formatCriticalValueTatDuration(int $totalMinutes): string
|
||||||
|
{
|
||||||
|
$hours = intdiv($totalMinutes, 60);
|
||||||
|
$minutes = $totalMinutes % 60;
|
||||||
|
|
||||||
|
if ($hours > 0) {
|
||||||
|
return $hours.' jam '.$minutes.' menit';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $minutes.' menit';
|
||||||
|
}
|
||||||
|
|
||||||
public function examinations(Request $request, string $master)
|
public function examinations(Request $request, string $master)
|
||||||
{
|
{
|
||||||
$this->requireUser($request);
|
$this->requireUser($request);
|
||||||
$query = $this->activeExaminationQuery();
|
$query = $this->activeExaminationQuery();
|
||||||
$mapped = collect($this->bookPoliMap())->flatten()->all();
|
$mapped = collect($this->bookPoliMap())->flatten()->all();
|
||||||
|
|
||||||
if ($master === 'buku0') {
|
if ($master === 'ALL') {
|
||||||
|
// Semua spesimen: tidak dibatasi kategori buku/poli.
|
||||||
|
} elseif ($master === 'buku0') {
|
||||||
$query->whereNotIn('poli_id', $mapped);
|
$query->whereNotIn('poli_id', $mapped);
|
||||||
} elseif (isset($this->bookPoliMap()[$master])) {
|
} elseif (isset($this->bookPoliMap()[$master])) {
|
||||||
$query->whereIn('poli_id', $this->bookPoliMap()[$master]);
|
$query->whereIn('poli_id', $this->bookPoliMap()[$master]);
|
||||||
@@ -260,15 +432,237 @@ class MobileApiController extends Controller
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
$items = $query->orderBy('daftar', 'ASC')->limit(250)->get()->map(fn ($row) => $this->examinationPayload($row));
|
$query->orderBy('daftar', 'ASC');
|
||||||
|
if ($master !== 'ALL') {
|
||||||
|
$query->limit(250);
|
||||||
|
}
|
||||||
|
$items = $query->get()->map(fn ($row) => $this->examinationPayload($row));
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'master' => $master,
|
'master' => $master,
|
||||||
'label' => $this->bookLabels[$master],
|
'label' => $master === 'ALL' ? 'Semua Spesimen' : $this->bookLabels[$master],
|
||||||
'items' => $items,
|
'items' => $items,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function searchExamination(Request $request)
|
||||||
|
{
|
||||||
|
$this->requireUser($request);
|
||||||
|
$search = trim((string) $request->query('search', ''));
|
||||||
|
abort_if($search === '', 422, 'Kata kunci pencarian wajib diisi.');
|
||||||
|
$exactColumns = $this->availablePeriksaColumns(['nofoto', 'noregister', 'orderid', 'acc_number', 'barcode']);
|
||||||
|
$likeColumns = $this->availablePeriksaColumns(['nofoto', 'noregister', 'nmpasien', 'reques', 'orderid', 'acc_number', 'barcode']);
|
||||||
|
|
||||||
|
$row = $this->activeExaminationQuery()
|
||||||
|
->where(function ($q) use ($search, $exactColumns) {
|
||||||
|
foreach ($exactColumns as $index => $column) {
|
||||||
|
$index === 0
|
||||||
|
? $q->where($column, $search)
|
||||||
|
: $q->orWhere($column, $search);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (!$row) {
|
||||||
|
$row = $this->activeExaminationQuery()
|
||||||
|
->where(function ($q) use ($search, $likeColumns) {
|
||||||
|
foreach ($likeColumns as $index => $column) {
|
||||||
|
$index === 0
|
||||||
|
? $q->where($column, 'like', "%{$search}%")
|
||||||
|
: $q->orWhere($column, 'like', "%{$search}%");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
->orderBy('daftar', 'desc')
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$row) {
|
||||||
|
return response()->json(['message' => 'Data pemeriksaan tidak ditemukan.'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'item' => $this->examinationPayload($row),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function availablePeriksaColumns(array $columns): array
|
||||||
|
{
|
||||||
|
return array_values(array_filter(
|
||||||
|
$columns,
|
||||||
|
fn ($column) => Schema::hasColumn('periksa', $column)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function initialWorkSamples(Request $request)
|
||||||
|
{
|
||||||
|
$this->requireUser($request);
|
||||||
|
|
||||||
|
$items = Periksa::query()
|
||||||
|
->whereIn('status', [
|
||||||
|
'Penerimaan Sampel',
|
||||||
|
'Pemeriksaan Sampel',
|
||||||
|
'Pemeriksaan Awal',
|
||||||
|
'Pemeriksaan awal',
|
||||||
|
])
|
||||||
|
->orderBy('id', 'DESC')
|
||||||
|
->limit(500)
|
||||||
|
->get()
|
||||||
|
->map(fn ($row) => $this->examinationPayload($row))
|
||||||
|
->values();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'items' => $items,
|
||||||
|
'total' => $items->count(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function initialWorkAccept(Request $request, int $id)
|
||||||
|
{
|
||||||
|
$user = $this->requireUser($request);
|
||||||
|
$row = Periksa::find($id);
|
||||||
|
abort_if(!$row, 404, 'Data pemeriksaan tidak ditemukan.');
|
||||||
|
|
||||||
|
$mediaTanam = $request->input('mediatanam', []);
|
||||||
|
if (!is_array($mediaTanam)) {
|
||||||
|
$mediaTanam = [$mediaTanam];
|
||||||
|
}
|
||||||
|
$mediaTanam = array_values(array_filter($mediaTanam, fn ($item) => trim((string) $item) !== ''));
|
||||||
|
if (count($mediaTanam) === 0) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Media Tanam Yang di gunakan wajib dipilih.',
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$jenis = trim((string) $request->input('jenis', ''));
|
||||||
|
$bulan = trim((string) $request->input('bulan', ''));
|
||||||
|
if ($jenis !== '' && $jenis !== 'F' && $bulan === '') {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Jika jenis pengobatan bukan Follow Up, Bulan Ke wajib diisi.',
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($jenis !== '') {
|
||||||
|
$this->acceptInitialWorkWithTreatmentCode($row, $user, $jenis, $bulan);
|
||||||
|
} else {
|
||||||
|
$this->acceptInitialWork($row, $user);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->recordInitialWorkMediaUsage($row->fresh(), $mediaTanam, $user);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Sampel berhasil diterima.',
|
||||||
|
'item' => $this->examinationPayload($row->fresh()),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function initialWorkReject(Request $request, int $id)
|
||||||
|
{
|
||||||
|
$this->requireUser($request);
|
||||||
|
$updated = Periksa::where('id', $id)->update([
|
||||||
|
'status' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!$updated) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Data pemeriksaan tidak ditemukan atau tidak dapat ditolak.',
|
||||||
|
], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Sampel dikembalikan ke loket.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function acceptInitialWork(Periksa $row, User $user): void
|
||||||
|
{
|
||||||
|
$row->status = 'Pemeriksaan awal';
|
||||||
|
$row->tgldraft = Carbon::now();
|
||||||
|
if (empty($row->mulai)) {
|
||||||
|
$row->mulai = Carbon::now();
|
||||||
|
}
|
||||||
|
$row->excutor = $user->id;
|
||||||
|
$row->nmppdsmiddle2 = $user->nama;
|
||||||
|
$row->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function acceptInitialWorkWithTreatmentCode(Periksa $row, User $user, string $jenis, string $bulan): void
|
||||||
|
{
|
||||||
|
$nofoto = $row->nofoto.$jenis.$bulan;
|
||||||
|
Paslab::updateOrCreate(
|
||||||
|
['rnoreg' => $nofoto],
|
||||||
|
[
|
||||||
|
'nama' => $row->nmpasien,
|
||||||
|
'norm' => $row->noregister,
|
||||||
|
'rtglast' => $row->daftar,
|
||||||
|
'alamat' => $row->alamatpasien,
|
||||||
|
'rjenis' => $row->jkpasien,
|
||||||
|
'umur' => $row->usia,
|
||||||
|
'namadok' => $row->ktp,
|
||||||
|
'ruangan' => $row->asalpasien,
|
||||||
|
'tes' => $row->reques,
|
||||||
|
'alat' => 'ALL',
|
||||||
|
'kd_spesimen' => $row->kd_spesimen,
|
||||||
|
'nm_spesimen' => $row->nm_spesimen,
|
||||||
|
'tgllahir' => $row->tgllahirpasien,
|
||||||
|
'flg_vitek1' => true,
|
||||||
|
'flg_vitek2' => true,
|
||||||
|
'flg_bd1' => true,
|
||||||
|
'flg_bd2' => false,
|
||||||
|
'flg_gxp1' => true,
|
||||||
|
'flg_gxp2' => true,
|
||||||
|
'flg_gxp3' => true,
|
||||||
|
'flg_vitek3' => true,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$row->nofoto = $nofoto;
|
||||||
|
$row->status = 'Pemeriksaan awal';
|
||||||
|
$row->tgldraft = Carbon::now();
|
||||||
|
$row->mulai = Carbon::now();
|
||||||
|
$row->excutor = $user->id;
|
||||||
|
$row->nmppdsmiddle2 = $user->nama;
|
||||||
|
$row->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function recordInitialWorkMediaUsage(?Periksa $row, array $mediaTanam, User $user): void
|
||||||
|
{
|
||||||
|
if (!$row) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($mediaTanam as $media) {
|
||||||
|
$jenis = $this->mediaTanamReportName((string) $media);
|
||||||
|
if ($jenis === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$today = Carbon::today();
|
||||||
|
SIMBHPReport::create([
|
||||||
|
'tanggal' => (int) $today->format('d'),
|
||||||
|
'bulan' => (int) $today->format('m'),
|
||||||
|
'tahun' => (int) $today->format('Y'),
|
||||||
|
'deskripsi' => 'Pemeriksaan Awal Nomor Lab.'.$row->nofoto.' petugas '.$user->nama,
|
||||||
|
'pemasukan' => null,
|
||||||
|
'pengeluaran' => 1,
|
||||||
|
'qty_base' => 1,
|
||||||
|
'satuan_transaksi' => 'besar',
|
||||||
|
'jenis' => $jenis,
|
||||||
|
'keterangan' => '',
|
||||||
|
'marking' => '',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function mediaTanamReportName(string $media): string
|
||||||
|
{
|
||||||
|
return match ($media) {
|
||||||
|
'Media BAP' => 'PLATE BAP (Blood Agar Plate) MEDIA',
|
||||||
|
'Media CAP' => 'PLATE CAP (Chocolate Agar Plate) Media',
|
||||||
|
'Media Mc Conkey' => 'PLATE MC (Mac Conkey Agar Plate) MEDIA',
|
||||||
|
'Media SDA R1', 'Media SDA R2', 'Media SDA I1', 'Media SDA I2' => 'PLATE SDA (Saboroud Dextrose Agar Plate) MEDIA',
|
||||||
|
default => '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
public function examination(Request $request, int $id)
|
public function examination(Request $request, int $id)
|
||||||
{
|
{
|
||||||
$this->requireUser($request);
|
$this->requireUser($request);
|
||||||
|
|||||||
@@ -6605,8 +6605,59 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
$("#btnsendexpertisetopacs").click(function(){
|
$("#btnsendexpertisetopacs").click(function(){
|
||||||
$('#id_iframepreviewexpertise').attr('src', '{{ url("/") }}/hasil/' + document.getElementById('nofoto').value);
|
var keterangan = CKEDITOR.instances['keterangan'].getData();
|
||||||
$('#modalpreviewsendexpertise').modal('show');
|
var lsg_pewarnaanlain = CKEDITOR.instances['lsg_pewarnaanlain'].getData();
|
||||||
|
var viralload = CKEDITOR.instances['viralload'].getData();
|
||||||
|
var periksa_id = document.getElementById('periksa_id').value;
|
||||||
|
var template_jenis = document.getElementById('template_jenis').value;
|
||||||
|
var id_dokter = document.getElementById('dokter').value;
|
||||||
|
var acc_number = document.getElementById('nofoto').value;
|
||||||
|
const checkboxMap = {
|
||||||
|
inlineCheckbox1: 'TBC SO',
|
||||||
|
inlineCheckbox2: 'TBC RO',
|
||||||
|
inlineCheckbox3: 'Anak',
|
||||||
|
inlineCheckbox4: 'HIV',
|
||||||
|
inlineCheckbox5: 'DM',
|
||||||
|
inlineCheckbox6: 'Nanah Lendir',
|
||||||
|
inlineCheckbox7: 'Bercak darah',
|
||||||
|
inlineCheckbox8: 'Air liur',
|
||||||
|
inlineCheckbox9: 'Nanah Lendir',
|
||||||
|
inlineCheckbox10: 'Bercak darah',
|
||||||
|
inlineCheckbox11: 'Air liur',
|
||||||
|
};
|
||||||
|
const checkboxValues = {};
|
||||||
|
$.each(checkboxMap, function (id, label) {
|
||||||
|
checkboxValues[id] = $(`#${id}`).is(':checked') ? label : '';
|
||||||
|
});
|
||||||
|
var formdata = new FormData($('#kt_form')[0]);
|
||||||
|
formdata.set('periksa_id', periksa_id);
|
||||||
|
formdata.set('acc_number', acc_number);
|
||||||
|
formdata.set('val01', 'Draft');
|
||||||
|
formdata.set('val10', template_jenis);
|
||||||
|
formdata.set('keterangan', keterangan);
|
||||||
|
formdata.set('lsg_pewarnaanlain', lsg_pewarnaanlain);
|
||||||
|
formdata.set('viralload', viralload);
|
||||||
|
formdata.set('_token', '{{ csrf_token() }}');
|
||||||
|
$.each(checkboxValues, (key, val) => formdata.set(key, val));
|
||||||
|
$.ajax({
|
||||||
|
url : '{{ route("exexpertisepacs") }}',
|
||||||
|
data : formdata,
|
||||||
|
type : 'POST',
|
||||||
|
contentType : false,
|
||||||
|
processData : false,
|
||||||
|
success: function (data) {
|
||||||
|
$('#id_iframepreviewexpertise').attr('src', '{{ url("/") }}/hasil/' + document.getElementById('nofoto').value);
|
||||||
|
$('#modalpreviewsendexpertise').modal('show');
|
||||||
|
},
|
||||||
|
error: function (xhr, status, error) {
|
||||||
|
swal({
|
||||||
|
title : 'Stop',
|
||||||
|
text : xhr.responseText,
|
||||||
|
type : 'warning',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
$("#btnapproveexpertise").click(function(){
|
$("#btnapproveexpertise").click(function(){
|
||||||
var keterangan = CKEDITOR.instances['keterangan'].getData();
|
var keterangan = CKEDITOR.instances['keterangan'].getData();
|
||||||
|
|||||||
@@ -6663,8 +6663,59 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
$("#btnsendexpertisetopacs").click(function(){
|
$("#btnsendexpertisetopacs").click(function(){
|
||||||
$('#id_iframepreviewexpertise').attr('src', '{{ url("/") }}/hasil/' + document.getElementById('nofoto').value);
|
var keterangan = CKEDITOR.instances['keterangan'].getData();
|
||||||
$('#modalpreviewsendexpertise').modal('show');
|
var lsg_pewarnaanlain = CKEDITOR.instances['lsg_pewarnaanlain'].getData();
|
||||||
|
var viralload = CKEDITOR.instances['viralload'].getData();
|
||||||
|
var periksa_id = document.getElementById('periksa_id').value;
|
||||||
|
var template_jenis = document.getElementById('template_jenis').value;
|
||||||
|
var id_dokter = document.getElementById('dokter').value;
|
||||||
|
var acc_number = document.getElementById('nofoto').value;
|
||||||
|
const checkboxMap = {
|
||||||
|
inlineCheckbox1: 'TBC SO',
|
||||||
|
inlineCheckbox2: 'TBC RO',
|
||||||
|
inlineCheckbox3: 'Anak',
|
||||||
|
inlineCheckbox4: 'HIV',
|
||||||
|
inlineCheckbox5: 'DM',
|
||||||
|
inlineCheckbox6: 'Nanah Lendir',
|
||||||
|
inlineCheckbox7: 'Bercak darah',
|
||||||
|
inlineCheckbox8: 'Air liur',
|
||||||
|
inlineCheckbox9: 'Nanah Lendir',
|
||||||
|
inlineCheckbox10: 'Bercak darah',
|
||||||
|
inlineCheckbox11: 'Air liur',
|
||||||
|
};
|
||||||
|
const checkboxValues = {};
|
||||||
|
$.each(checkboxMap, function (id, label) {
|
||||||
|
checkboxValues[id] = $(`#${id}`).is(':checked') ? label : '';
|
||||||
|
});
|
||||||
|
var formdata = new FormData($('#kt_form')[0]);
|
||||||
|
formdata.set('periksa_id', periksa_id);
|
||||||
|
formdata.set('acc_number', acc_number);
|
||||||
|
formdata.set('val01', 'Draft');
|
||||||
|
formdata.set('val10', template_jenis);
|
||||||
|
formdata.set('keterangan', keterangan);
|
||||||
|
formdata.set('lsg_pewarnaanlain', lsg_pewarnaanlain);
|
||||||
|
formdata.set('viralload', viralload);
|
||||||
|
formdata.set('_token', '{{ csrf_token() }}');
|
||||||
|
$.each(checkboxValues, (key, val) => formdata.set(key, val));
|
||||||
|
$.ajax({
|
||||||
|
url : '{{ route("exexpertisepacs") }}',
|
||||||
|
data : formdata,
|
||||||
|
type : 'POST',
|
||||||
|
contentType : false,
|
||||||
|
processData : false,
|
||||||
|
success: function (data) {
|
||||||
|
$('#id_iframepreviewexpertise').attr('src', '{{ url("/") }}/hasil/' + document.getElementById('nofoto').value);
|
||||||
|
$('#modalpreviewsendexpertise').modal('show');
|
||||||
|
},
|
||||||
|
error: function (xhr, status, error) {
|
||||||
|
swal({
|
||||||
|
title : 'Stop',
|
||||||
|
text : xhr.responseText,
|
||||||
|
type : 'warning',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
$("#btnapproveexpertise").click(function(){
|
$("#btnapproveexpertise").click(function(){
|
||||||
var keterangan = CKEDITOR.instances['keterangan'].getData();
|
var keterangan = CKEDITOR.instances['keterangan'].getData();
|
||||||
|
|||||||
@@ -6812,8 +6812,59 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
$("#btnsendexpertisetopacs").click(function(){
|
$("#btnsendexpertisetopacs").click(function(){
|
||||||
$('#id_iframepreviewexpertise').attr('src', '{{ url("/") }}/hasil/' + document.getElementById('nofoto').value);
|
var keterangan = CKEDITOR.instances['keterangan'].getData();
|
||||||
$('#modalpreviewsendexpertise').modal('show');
|
var lsg_pewarnaanlain = CKEDITOR.instances['lsg_pewarnaanlain'].getData();
|
||||||
|
var viralload = CKEDITOR.instances['viralload'].getData();
|
||||||
|
var periksa_id = document.getElementById('periksa_id').value;
|
||||||
|
var template_jenis = document.getElementById('template_jenis').value;
|
||||||
|
var id_dokter = document.getElementById('dokter').value;
|
||||||
|
var acc_number = document.getElementById('nofoto').value;
|
||||||
|
const checkboxMap = {
|
||||||
|
inlineCheckbox1: 'TBC SO',
|
||||||
|
inlineCheckbox2: 'TBC RO',
|
||||||
|
inlineCheckbox3: 'Anak',
|
||||||
|
inlineCheckbox4: 'HIV',
|
||||||
|
inlineCheckbox5: 'DM',
|
||||||
|
inlineCheckbox6: 'Nanah Lendir',
|
||||||
|
inlineCheckbox7: 'Bercak darah',
|
||||||
|
inlineCheckbox8: 'Air liur',
|
||||||
|
inlineCheckbox9: 'Nanah Lendir',
|
||||||
|
inlineCheckbox10: 'Bercak darah',
|
||||||
|
inlineCheckbox11: 'Air liur',
|
||||||
|
};
|
||||||
|
const checkboxValues = {};
|
||||||
|
$.each(checkboxMap, function (id, label) {
|
||||||
|
checkboxValues[id] = $(`#${id}`).is(':checked') ? label : '';
|
||||||
|
});
|
||||||
|
var formdata = new FormData($('#kt_form')[0]);
|
||||||
|
formdata.set('periksa_id', periksa_id);
|
||||||
|
formdata.set('acc_number', acc_number);
|
||||||
|
formdata.set('val01', 'Draft');
|
||||||
|
formdata.set('val10', template_jenis);
|
||||||
|
formdata.set('keterangan', keterangan);
|
||||||
|
formdata.set('lsg_pewarnaanlain', lsg_pewarnaanlain);
|
||||||
|
formdata.set('viralload', viralload);
|
||||||
|
formdata.set('_token', '{{ csrf_token() }}');
|
||||||
|
$.each(checkboxValues, (key, val) => formdata.set(key, val));
|
||||||
|
$.ajax({
|
||||||
|
url : '{{ route("exexpertisepacs") }}',
|
||||||
|
data : formdata,
|
||||||
|
type : 'POST',
|
||||||
|
contentType : false,
|
||||||
|
processData : false,
|
||||||
|
success: function (data) {
|
||||||
|
$('#id_iframepreviewexpertise').attr('src', '{{ url("/") }}/hasil/' + document.getElementById('nofoto').value);
|
||||||
|
$('#modalpreviewsendexpertise').modal('show');
|
||||||
|
},
|
||||||
|
error: function (xhr, status, error) {
|
||||||
|
swal({
|
||||||
|
title : 'Stop',
|
||||||
|
text : xhr.responseText,
|
||||||
|
type : 'warning',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
$("#btnapproveexpertise").click(function(){
|
$("#btnapproveexpertise").click(function(){
|
||||||
var keterangan = CKEDITOR.instances['keterangan'].getData();
|
var keterangan = CKEDITOR.instances['keterangan'].getData();
|
||||||
|
|||||||
@@ -23,7 +23,13 @@ Route::prefix('mobile')->group(function () {
|
|||||||
Route::get('ping', [MobileApiController::class, 'ping']);
|
Route::get('ping', [MobileApiController::class, 'ping']);
|
||||||
Route::post('login', [MobileApiController::class, 'login']);
|
Route::post('login', [MobileApiController::class, 'login']);
|
||||||
Route::get('dashboard', [MobileApiController::class, 'dashboard']);
|
Route::get('dashboard', [MobileApiController::class, 'dashboard']);
|
||||||
|
Route::get('critical-values', [MobileApiController::class, 'criticalValueNotifications']);
|
||||||
|
Route::post('critical-values/{id}/mark-read', [MobileApiController::class, 'criticalValueNotificationMarkRead']);
|
||||||
Route::get('early-warning', [MobileApiController::class, 'earlyWarning']);
|
Route::get('early-warning', [MobileApiController::class, 'earlyWarning']);
|
||||||
|
Route::get('examinations/search', [MobileApiController::class, 'searchExamination']);
|
||||||
|
Route::get('initial-work/samples', [MobileApiController::class, 'initialWorkSamples']);
|
||||||
|
Route::post('initial-work/samples/{id}/accept', [MobileApiController::class, 'initialWorkAccept']);
|
||||||
|
Route::post('initial-work/samples/{id}/reject', [MobileApiController::class, 'initialWorkReject']);
|
||||||
Route::get('books/{master}/examinations', [MobileApiController::class, 'examinations']);
|
Route::get('books/{master}/examinations', [MobileApiController::class, 'examinations']);
|
||||||
Route::get('examinations/{id}', [MobileApiController::class, 'examination']);
|
Route::get('examinations/{id}', [MobileApiController::class, 'examination']);
|
||||||
Route::get('examinations/{id}/expertise', [MobileApiController::class, 'expertise']);
|
Route::get('examinations/{id}/expertise', [MobileApiController::class, 'expertise']);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
<application
|
<application
|
||||||
android:label="MyLIS"
|
android:label="MyLIS"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
|
|||||||
@@ -26,6 +26,8 @@
|
|||||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>NSCameraUsageDescription</key>
|
||||||
|
<string>MyLIS memakai kamera untuk scan barcode sampel.</string>
|
||||||
<key>UIApplicationSceneManifest</key>
|
<key>UIApplicationSceneManifest</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>UIApplicationSupportsMultipleScenes</key>
|
<key>UIApplicationSupportsMultipleScenes</key>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'dart:io';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:http/io_client.dart';
|
import 'package:http/io_client.dart';
|
||||||
|
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
@@ -13,6 +14,12 @@ part '../services/api_client.dart';
|
|||||||
part '../services/session_store.dart';
|
part '../services/session_store.dart';
|
||||||
part '../screens/login/login_screen.dart';
|
part '../screens/login/login_screen.dart';
|
||||||
part '../screens/dashboard/dashboard_screen.dart';
|
part '../screens/dashboard/dashboard_screen.dart';
|
||||||
|
part '../screens/dashboard/critical_values_screen.dart';
|
||||||
|
part '../screens/dashboard/barcode_scanner_screen.dart';
|
||||||
|
part '../screens/dashboard/ews_screen.dart';
|
||||||
|
part '../screens/dashboard/sample_groups_screen.dart';
|
||||||
|
part '../screens/dashboard/specimen_register_screen.dart';
|
||||||
|
part '../screens/dashboard/initial_work_screen.dart';
|
||||||
part '../screens/examinations/examination_list_screen.dart';
|
part '../screens/examinations/examination_list_screen.dart';
|
||||||
part '../screens/examinations/examination_detail_screen.dart';
|
part '../screens/examinations/examination_detail_screen.dart';
|
||||||
part '../screens/expertise/expertise_screen.dart';
|
part '../screens/expertise/expertise_screen.dart';
|
||||||
@@ -29,8 +36,7 @@ part '../data/expertise_data.dart';
|
|||||||
part '../widgets/common_widgets.dart';
|
part '../widgets/common_widgets.dart';
|
||||||
|
|
||||||
const String kAppShortName = 'MyLIS';
|
const String kAppShortName = 'MyLIS';
|
||||||
const String kAppLongName =
|
const String kAppLongName = 'Mikrobiology Laboratory Information System';
|
||||||
'Mikrobiology Laboratory Information System';
|
|
||||||
const String kHospitalName = 'Rumah Sakit Umum Daerah Dr. Saiful Anwar';
|
const String kHospitalName = 'Rumah Sakit Umum Daerah Dr. Saiful Anwar';
|
||||||
const String kAppLogoAsset = 'assets/branding/logo.png';
|
const String kAppLogoAsset = 'assets/branding/logo.png';
|
||||||
const String kHospitalLogoAsset = 'assets/branding/logo_rssa.png';
|
const String kHospitalLogoAsset = 'assets/branding/logo_rssa.png';
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
part of '../../app/app.dart';
|
||||||
|
|
||||||
|
class BarcodeScannerScreen extends StatefulWidget {
|
||||||
|
const BarcodeScannerScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<BarcodeScannerScreen> createState() => _BarcodeScannerScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BarcodeScannerScreenState extends State<BarcodeScannerScreen> {
|
||||||
|
final MobileScannerController _controller = MobileScannerController();
|
||||||
|
final _manualCode = TextEditingController();
|
||||||
|
bool _handled = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_manualCode.dispose();
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleDetect(BarcodeCapture capture) {
|
||||||
|
if (_handled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final value = capture.barcodes
|
||||||
|
.map((barcode) => barcode.rawValue?.trim() ?? '')
|
||||||
|
.firstWhere((text) => text.isNotEmpty, orElse: () => '');
|
||||||
|
if (value.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_handled = true;
|
||||||
|
Navigator.of(context).pop(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Scan Barcode Sampel'),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => _controller.toggleTorch(),
|
||||||
|
icon: const Icon(Icons.flashlight_on_outlined),
|
||||||
|
tooltip: 'Lampu',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
MobileScanner(
|
||||||
|
controller: _controller,
|
||||||
|
onDetect: _handleDetect,
|
||||||
|
errorBuilder: (context, error) => _ScannerFallback(
|
||||||
|
controller: _manualCode,
|
||||||
|
message:
|
||||||
|
'Scanner belum tersedia. Tutup aplikasi lalu jalankan ulang full rebuild, atau masukkan barcode manual.',
|
||||||
|
onSubmit: _submitManualCode,
|
||||||
|
),
|
||||||
|
placeholderBuilder: (context) =>
|
||||||
|
const Center(child: CircularProgressIndicator()),
|
||||||
|
),
|
||||||
|
Center(
|
||||||
|
child: Container(
|
||||||
|
width: 260,
|
||||||
|
height: 180,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: Colors.white, width: 3),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
left: 18,
|
||||||
|
right: 18,
|
||||||
|
bottom: 24,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withValues(alpha: 0.62),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'Arahkan kamera ke barcode nomor sampel.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _submitManualCode() {
|
||||||
|
final code = _manualCode.text.trim();
|
||||||
|
if (code.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Navigator.of(context).pop(code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ScannerFallback extends StatelessWidget {
|
||||||
|
const _ScannerFallback({
|
||||||
|
required this.controller,
|
||||||
|
required this.message,
|
||||||
|
required this.onSubmit,
|
||||||
|
});
|
||||||
|
|
||||||
|
final TextEditingController controller;
|
||||||
|
final String message;
|
||||||
|
final VoidCallback onSubmit;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
color: const Color(0xFFF7FAF9),
|
||||||
|
padding: const EdgeInsets.all(18),
|
||||||
|
child: Center(
|
||||||
|
child: Card(
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.qr_code_scanner_rounded,
|
||||||
|
size: 42,
|
||||||
|
color: Color(0xFF0F766E),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Text(
|
||||||
|
message,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(color: Colors.black54),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
TextField(
|
||||||
|
controller: controller,
|
||||||
|
autofocus: true,
|
||||||
|
textInputAction: TextInputAction.search,
|
||||||
|
onSubmitted: (_) => onSubmit(),
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Barcode / No. Sampel',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: onSubmit,
|
||||||
|
icon: const Icon(Icons.manage_search_rounded),
|
||||||
|
label: const Text('Gunakan Kode'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,567 @@
|
|||||||
|
part of '../../app/app.dart';
|
||||||
|
|
||||||
|
class CriticalValuesScreen extends StatefulWidget {
|
||||||
|
const CriticalValuesScreen({
|
||||||
|
super.key,
|
||||||
|
required this.token,
|
||||||
|
required this.baseUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String token;
|
||||||
|
final String baseUrl;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<CriticalValuesScreen> createState() => _CriticalValuesScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CriticalValuesScreenState extends State<CriticalValuesScreen> {
|
||||||
|
late final ApiClient _api;
|
||||||
|
late Future<Map<String, dynamic>> _future;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_api = ApiClient(baseUrl: widget.baseUrl, token: widget.token);
|
||||||
|
_future = _load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> _load() {
|
||||||
|
return _api.get('api/mobile/critical-values');
|
||||||
|
}
|
||||||
|
|
||||||
|
void _reload() {
|
||||||
|
setState(() {
|
||||||
|
_future = _load();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _markRead(Map<String, dynamic> item) async {
|
||||||
|
final method = ValueNotifier<String>('');
|
||||||
|
final recipient = TextEditingController();
|
||||||
|
final reason = TextEditingController();
|
||||||
|
|
||||||
|
await showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
showDragHandle: true,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (context) => SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.fromLTRB(
|
||||||
|
18,
|
||||||
|
0,
|
||||||
|
18,
|
||||||
|
MediaQuery.viewInsetsOf(context).bottom + 18,
|
||||||
|
),
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: ValueListenableBuilder<String>(
|
||||||
|
valueListenable: method,
|
||||||
|
builder: (context, value, _) => Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Tindak Lanjut Nilai Kritis',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
'${item['nofoto'] ?? '-'} • ${item['nmpasien'] ?? '-'}',
|
||||||
|
style: const TextStyle(color: Colors.black54),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
DropdownButtonFormField<String>(
|
||||||
|
initialValue: value.isEmpty ? null : value,
|
||||||
|
isExpanded: true,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Melalui',
|
||||||
|
helperText: 'Kosongkan jika belum dilaporkan',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
items: const [
|
||||||
|
DropdownMenuItem(value: 'Telpon', child: Text('Telpon')),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: 'Whatshap',
|
||||||
|
child: Text('Whatshap'),
|
||||||
|
),
|
||||||
|
DropdownMenuItem(value: 'Email', child: Text('Email')),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: 'Ketemu di Jalan',
|
||||||
|
child: Text('Ketemu di Jalan'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onChanged: (next) => method.value = next ?? '',
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: recipient,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Penerima Laporan',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: reason,
|
||||||
|
minLines: 2,
|
||||||
|
maxLines: 4,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Alasan belum dilaporkan/Pasien meninggal',
|
||||||
|
hintText: 'Meninggal / Pindah Rumah Sakit / dll',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: () async {
|
||||||
|
try {
|
||||||
|
await _api.post(
|
||||||
|
'api/mobile/critical-values/${item['id']}/mark-read',
|
||||||
|
{
|
||||||
|
'caratindaklanjut': method.value,
|
||||||
|
'penerima_laporan': recipient.text.trim(),
|
||||||
|
'alasan_belum_laporkan': reason.text.trim(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!context.mounted) return;
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
_reload();
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Sample nilai kritis sudah ditindaklanjuti.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} on ApiException catch (error) {
|
||||||
|
if (!context.mounted) return;
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text(error.message)));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.done_all_outlined),
|
||||||
|
label: const Text('Mark as Read'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
recipient.dispose();
|
||||||
|
reason.dispose();
|
||||||
|
method.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return DefaultTabController(
|
||||||
|
length: 2,
|
||||||
|
child: Scaffold(
|
||||||
|
body: FutureBuilder<Map<String, dynamic>>(
|
||||||
|
future: _future,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.connectionState != ConnectionState.done) {
|
||||||
|
return const LoadingScreen();
|
||||||
|
}
|
||||||
|
if (snapshot.hasError) {
|
||||||
|
return ErrorView(
|
||||||
|
message: _message(snapshot.error),
|
||||||
|
onRetry: _reload,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final data = snapshot.data!;
|
||||||
|
final pending = asList(data['pending_items']);
|
||||||
|
final followed = asList(data['followed_items']);
|
||||||
|
return SafeArea(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(18, 18, 18, 0),
|
||||||
|
child: _CriticalValuesHeader(
|
||||||
|
pendingCount: pending.length,
|
||||||
|
onRefresh: _reload,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(18, 22, 18, 0),
|
||||||
|
child: _CriticalValuesSummary(
|
||||||
|
pendingCount: pending.length,
|
||||||
|
followedCount: followed.length,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(18, 18, 18, 0),
|
||||||
|
child: Container(
|
||||||
|
height: 48,
|
||||||
|
padding: const EdgeInsets.all(4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFEFF6FF),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: const TabBar(
|
||||||
|
indicatorSize: TabBarIndicatorSize.tab,
|
||||||
|
dividerColor: Colors.transparent,
|
||||||
|
indicator: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.all(Radius.circular(9)),
|
||||||
|
),
|
||||||
|
labelColor: Color(0xFF0B7BFF),
|
||||||
|
unselectedLabelColor: Color(0xFF47517A),
|
||||||
|
labelStyle: TextStyle(fontWeight: FontWeight.w900),
|
||||||
|
tabs: [
|
||||||
|
Tab(text: 'Belum Dilaporkan'),
|
||||||
|
Tab(text: 'Riwayat'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: TabBarView(
|
||||||
|
children: [
|
||||||
|
CriticalValueList(
|
||||||
|
items: pending,
|
||||||
|
emptyText:
|
||||||
|
'Tidak ada sample nilai kritis yang perlu dilaporkan.',
|
||||||
|
onMarkRead: _markRead,
|
||||||
|
),
|
||||||
|
CriticalValueList(
|
||||||
|
items: followed,
|
||||||
|
emptyText: 'Belum ada riwayat tindak lanjut.',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CriticalValuesHeader extends StatelessWidget {
|
||||||
|
const _CriticalValuesHeader({
|
||||||
|
required this.pendingCount,
|
||||||
|
required this.onRefresh,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int pendingCount;
|
||||||
|
final VoidCallback onRefresh;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
icon: const Icon(Icons.arrow_back_ios_new_rounded),
|
||||||
|
color: const Color(0xFF080D3D),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
'Nilai Kritis',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.headlineSmall
|
||||||
|
?.copyWith(
|
||||||
|
color: const Color(0xFF080D3D),
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
BadgeLabel(
|
||||||
|
text: '$pendingCount',
|
||||||
|
color: const Color(0xFFFF1D25),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Notifikasi & Tindak Lanjut',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
color: const Color(0xFF47517A),
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: onRefresh,
|
||||||
|
icon: const Icon(Icons.refresh_rounded),
|
||||||
|
color: const Color(0xFF063B60),
|
||||||
|
tooltip: 'Refresh',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CriticalValuesSummary extends StatelessWidget {
|
||||||
|
const _CriticalValuesSummary({
|
||||||
|
required this.pendingCount,
|
||||||
|
required this.followedCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int pendingCount;
|
||||||
|
final int followedCount;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(18),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
border: Border.all(color: const Color(0xFFE1E8F5)),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: const Color(0xFF2563EB).withValues(alpha: 0.05),
|
||||||
|
blurRadius: 18,
|
||||||
|
offset: const Offset(0, 10),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _CriticalSummaryItem(
|
||||||
|
label: 'Belum Dilaporkan',
|
||||||
|
value: pendingCount,
|
||||||
|
color: const Color(0xFFFF1D25),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(width: 1, height: 48, color: const Color(0xFFE1E8F5)),
|
||||||
|
Expanded(
|
||||||
|
child: _CriticalSummaryItem(
|
||||||
|
label: 'Riwayat',
|
||||||
|
value: followedCount,
|
||||||
|
color: const Color(0xFF0B7BFF),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CriticalSummaryItem extends StatelessWidget {
|
||||||
|
const _CriticalSummaryItem({
|
||||||
|
required this.label,
|
||||||
|
required this.value,
|
||||||
|
required this.color,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final int value;
|
||||||
|
final Color color;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'$value',
|
||||||
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||||
|
color: color,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Color(0xFF47517A),
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class CriticalValueList extends StatelessWidget {
|
||||||
|
const CriticalValueList({
|
||||||
|
super.key,
|
||||||
|
required this.items,
|
||||||
|
required this.emptyText,
|
||||||
|
this.onMarkRead,
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<dynamic> items;
|
||||||
|
final String emptyText;
|
||||||
|
final ValueChanged<Map<String, dynamic>>? onMarkRead;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (items.isEmpty) {
|
||||||
|
return EmptyPanel(text: emptyText);
|
||||||
|
}
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: () async {},
|
||||||
|
child: ListView.separated(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 14, 16, 24),
|
||||||
|
itemCount: items.length,
|
||||||
|
separatorBuilder: (_, _) => const SizedBox(height: 10),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final item = asMap(items[index]);
|
||||||
|
return CriticalValueCard(
|
||||||
|
item: item,
|
||||||
|
index: index,
|
||||||
|
onMarkRead: onMarkRead == null ? null : () => onMarkRead!(item),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class CriticalValueCard extends StatelessWidget {
|
||||||
|
const CriticalValueCard({
|
||||||
|
super.key,
|
||||||
|
required this.item,
|
||||||
|
required this.index,
|
||||||
|
this.onMarkRead,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Map<String, dynamic> item;
|
||||||
|
final int index;
|
||||||
|
final VoidCallback? onMarkRead;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final followed = item['followed_up_at']?.toString().isNotEmpty == true;
|
||||||
|
return Card(
|
||||||
|
elevation: 0,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
side: const BorderSide(color: Color(0xFFE1E8F5)),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
CircleAvatar(
|
||||||
|
backgroundColor: const Color(
|
||||||
|
0xFFFF1D25,
|
||||||
|
).withValues(alpha: 0.12),
|
||||||
|
child: Text(
|
||||||
|
'${index + 1}',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Color(0xFFFF1D25),
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
item['nmpasien']?.toString() ?? '-',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium
|
||||||
|
?.copyWith(fontWeight: FontWeight.w900),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 3),
|
||||||
|
Text(
|
||||||
|
'${item['nofoto'] ?? '-'} / RM ${item['noregister'] ?? '-'}',
|
||||||
|
style: const TextStyle(color: Colors.black54),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
StatusPill(
|
||||||
|
status: followed
|
||||||
|
? item['tat_status_label']?.toString() ?? '-'
|
||||||
|
: 'Belum Dilaporkan',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Wrap(
|
||||||
|
spacing: 10,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
BadgeLabel(
|
||||||
|
text: item['asalpasien']?.toString() ?? '-',
|
||||||
|
color: const Color(0xFF0F766E),
|
||||||
|
),
|
||||||
|
BadgeLabel(
|
||||||
|
text: item['nm_spesimen']?.toString() ?? '-',
|
||||||
|
color: const Color(0xFF2563EB),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
DetailRow(
|
||||||
|
label: 'Waktu Set Nilai Kritis',
|
||||||
|
value: _timeWithUser(
|
||||||
|
item['critical_set_at'],
|
||||||
|
item['critical_set_by_name'],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (followed) ...[
|
||||||
|
DetailRow(
|
||||||
|
label: 'Waktu Ditindaklanjuti',
|
||||||
|
value: _timeWithUser(
|
||||||
|
item['followed_up_at'],
|
||||||
|
item['followed_up_by_name'],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
DetailRow(
|
||||||
|
label: 'Selisih Waktu',
|
||||||
|
value: item['tat_duration_label'],
|
||||||
|
),
|
||||||
|
DetailRow(label: 'Aksi', value: _followUpLabel(item)),
|
||||||
|
],
|
||||||
|
if (onMarkRead != null)
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: FilledButton.icon(
|
||||||
|
onPressed: onMarkRead,
|
||||||
|
icon: const Icon(Icons.done_all_outlined),
|
||||||
|
label: const Text('Mark as Read'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _timeWithUser(Object? time, Object? user) {
|
||||||
|
final first = time?.toString().isNotEmpty == true ? time.toString() : '-';
|
||||||
|
final second = user?.toString().isNotEmpty == true ? '\noleh $user' : '';
|
||||||
|
return '$first$second';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _followUpLabel(Map<String, dynamic> item) {
|
||||||
|
final status = item['follow_up_status']?.toString() ?? '';
|
||||||
|
if (status == 'reported') {
|
||||||
|
return 'Dilaporkan\nMelalui: ${item['follow_up_method'] ?? '-'}\nPenerima: ${item['follow_up_recipient'] ?? '-'}';
|
||||||
|
}
|
||||||
|
if (status == 'not_reported') {
|
||||||
|
return 'Belum Dilaporkan\nAlasan: ${item['follow_up_reason'] ?? '-'}';
|
||||||
|
}
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load diff
@@ -0,0 +1,44 @@
|
|||||||
|
part of '../../app/app.dart';
|
||||||
|
|
||||||
|
class EwsScreen extends StatelessWidget {
|
||||||
|
const EwsScreen({
|
||||||
|
super.key,
|
||||||
|
required this.warnings,
|
||||||
|
required this.token,
|
||||||
|
required this.baseUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<dynamic> warnings;
|
||||||
|
final String token;
|
||||||
|
final String baseUrl;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final total = warnings.fold<int>(
|
||||||
|
0,
|
||||||
|
(sum, item) => sum + (asMap(item)['total'] as num? ?? 0).toInt(),
|
||||||
|
);
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('Early Warning Sistem')),
|
||||||
|
body: ListView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
|
||||||
|
children: [
|
||||||
|
SectionTitle(
|
||||||
|
title: 'Early Warning Sistem',
|
||||||
|
actionLabel: '$total kasus',
|
||||||
|
),
|
||||||
|
if (warnings.isEmpty)
|
||||||
|
const EmptyPanel(text: 'Tidak ada data Early Warning.')
|
||||||
|
else
|
||||||
|
...warnings.map(
|
||||||
|
(group) => EarlyWarningGroupCard(
|
||||||
|
group: asMap(group),
|
||||||
|
token: token,
|
||||||
|
baseUrl: baseUrl,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,878 @@
|
|||||||
|
part of '../../app/app.dart';
|
||||||
|
|
||||||
|
class InitialWorkScreen extends StatefulWidget {
|
||||||
|
const InitialWorkScreen({
|
||||||
|
super.key,
|
||||||
|
required this.token,
|
||||||
|
required this.baseUrl,
|
||||||
|
required this.user,
|
||||||
|
required this.notificationCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String token;
|
||||||
|
final String baseUrl;
|
||||||
|
final Map<String, dynamic> user;
|
||||||
|
final int notificationCount;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<InitialWorkScreen> createState() => _InitialWorkScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _InitialWorkScreenState extends State<InitialWorkScreen> {
|
||||||
|
late final ApiClient _api;
|
||||||
|
late Future<Map<String, dynamic>> _future;
|
||||||
|
final _search = TextEditingController();
|
||||||
|
static const int _pageSize = 10;
|
||||||
|
int _page = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_api = ApiClient(baseUrl: widget.baseUrl, token: widget.token);
|
||||||
|
_future = _load();
|
||||||
|
_search.addListener(_onSearchChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_search.removeListener(_onSearchChanged);
|
||||||
|
_search.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> _load() {
|
||||||
|
return _api.get('api/mobile/initial-work/samples');
|
||||||
|
}
|
||||||
|
|
||||||
|
void _reload() {
|
||||||
|
setState(() {
|
||||||
|
_future = _load();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onSearchChanged() {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_page = 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openCriticalValues() async {
|
||||||
|
await Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) =>
|
||||||
|
CriticalValuesScreen(token: widget.token, baseUrl: widget.baseUrl),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showProfile() {
|
||||||
|
showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
showDragHandle: true,
|
||||||
|
builder: (context) => SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 4, 20, 20),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
CircleAvatar(
|
||||||
|
radius: 28,
|
||||||
|
backgroundColor: const Color(0xFF0F766E),
|
||||||
|
child: Text(
|
||||||
|
_initials(widget.user['nama']?.toString() ?? 'SP'),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
widget.user['nama']?.toString() ?? '-',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium
|
||||||
|
?.copyWith(fontWeight: FontWeight.w900),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
'${widget.user['username'] ?? '-'} - ${widget.user['previlage'] ?? '-'}',
|
||||||
|
style: const TextStyle(color: Colors.black54),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: () async {
|
||||||
|
await SessionStore.clear();
|
||||||
|
if (!context.mounted) return;
|
||||||
|
Navigator.of(context).pushAndRemoveUntil(
|
||||||
|
MaterialPageRoute(builder: (_) => const LoginScreen()),
|
||||||
|
(_) => false,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.logout),
|
||||||
|
label: const Text('Logout'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _showActions(Map<String, dynamic> item) async {
|
||||||
|
await showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
showDragHandle: true,
|
||||||
|
builder: (context) => SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(18, 4, 18, 18),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
item['nofoto']?.toString() ?? '-',
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w900),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'${item['nmpasien'] ?? '-'} / RM ${item['noregister'] ?? '-'}',
|
||||||
|
style: const TextStyle(color: Color(0xFF47517A)),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
_showAcceptDialog(item);
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.check_circle_outline_rounded),
|
||||||
|
label: const Text('Terima'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
_confirmReject(item);
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.block_rounded),
|
||||||
|
label: const Text('Tolak'),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: const Color(0xFFDC2626),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _showAcceptDialog(Map<String, dynamic> item) async {
|
||||||
|
await showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => _AcceptInitialWorkDialog(
|
||||||
|
item: item,
|
||||||
|
onSubmit: (payload) => _accept(item, payload),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _accept(
|
||||||
|
Map<String, dynamic> item,
|
||||||
|
Map<String, dynamic> payload,
|
||||||
|
) async {
|
||||||
|
final id = (item['id'] as num?)?.toInt();
|
||||||
|
if (id == null) return false;
|
||||||
|
try {
|
||||||
|
final response = await _api.post(
|
||||||
|
'api/mobile/initial-work/samples/$id/accept',
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
if (!mounted) return false;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(response['message']?.toString() ?? 'Sampel diterima.'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
_reload();
|
||||||
|
return true;
|
||||||
|
} on ApiException catch (error) {
|
||||||
|
if (!mounted) return false;
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text(error.message)));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _confirmReject(Map<String, dynamic> item) async {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Tolak Sampel'),
|
||||||
|
content: Text('Kembalikan ${item['nofoto'] ?? 'sampel ini'} ke loket?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(false),
|
||||||
|
child: const Text('Batal'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(true),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: const Color(0xFFDC2626),
|
||||||
|
),
|
||||||
|
child: const Text('Tolak'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirmed != true) return;
|
||||||
|
await _reject(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _reject(Map<String, dynamic> item) async {
|
||||||
|
final id = (item['id'] as num?)?.toInt();
|
||||||
|
if (id == null) return;
|
||||||
|
try {
|
||||||
|
final response = await _api.post(
|
||||||
|
'api/mobile/initial-work/samples/$id/reject',
|
||||||
|
const {},
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(response['message']?.toString() ?? 'Sampel ditolak.'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
_reload();
|
||||||
|
} on ApiException catch (error) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text(error.message)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Map<String, dynamic>> _filtered(List<dynamic> items) {
|
||||||
|
final query = _search.text.trim().toLowerCase();
|
||||||
|
final mapped = items.map(asMap).toList();
|
||||||
|
if (query.isEmpty) {
|
||||||
|
return mapped;
|
||||||
|
}
|
||||||
|
return mapped.where((item) {
|
||||||
|
final haystack = [
|
||||||
|
item['nofoto'],
|
||||||
|
item['noregister'],
|
||||||
|
item['nmpasien'],
|
||||||
|
item['reques'],
|
||||||
|
item['status'],
|
||||||
|
item['nm_spesimen'],
|
||||||
|
].whereType<Object>().join(' ').toLowerCase();
|
||||||
|
return haystack.contains(query);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _initials(String name) {
|
||||||
|
final parts = name
|
||||||
|
.trim()
|
||||||
|
.split(RegExp(r'\s+'))
|
||||||
|
.where((part) => part.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
if (parts.isEmpty) {
|
||||||
|
return 'SP';
|
||||||
|
}
|
||||||
|
return parts.take(2).map((part) => part[0].toUpperCase()).join();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
body: SafeArea(
|
||||||
|
child: FutureBuilder<Map<String, dynamic>>(
|
||||||
|
future: _future,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
final items = _filtered(asList(snapshot.data?['items']));
|
||||||
|
final maxPage = items.isEmpty ? 0 : (items.length - 1) ~/ _pageSize;
|
||||||
|
final page = _page.clamp(0, maxPage);
|
||||||
|
final start = items.isEmpty ? 0 : page * _pageSize;
|
||||||
|
final end = items.isEmpty
|
||||||
|
? 0
|
||||||
|
: (start + _pageSize).clamp(0, items.length);
|
||||||
|
final visible = items.sublist(start, end);
|
||||||
|
|
||||||
|
return ListView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(18, 18, 18, 28),
|
||||||
|
children: [
|
||||||
|
_InitialWorkHeader(
|
||||||
|
total: items.length,
|
||||||
|
user: widget.user,
|
||||||
|
notificationCount: widget.notificationCount,
|
||||||
|
onNotifications: _openCriticalValues,
|
||||||
|
onProfile: _showProfile,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 22),
|
||||||
|
TextField(
|
||||||
|
controller: _search,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
prefixIcon: const Icon(Icons.search),
|
||||||
|
hintText: 'Cari No. Sampel, No. RM, atau Nama Pasien',
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 26),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Sampel dengan status Penerimaan Sampel',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium
|
||||||
|
?.copyWith(
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
color: const Color(0xFF080D3D),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
BadgeLabel(
|
||||||
|
text: '${items.length} sampel',
|
||||||
|
color: const Color(0xFF0B7BFF),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
if (snapshot.connectionState != ConnectionState.done)
|
||||||
|
const SizedBox(
|
||||||
|
height: 180,
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
)
|
||||||
|
else if (snapshot.hasError)
|
||||||
|
ErrorView(message: _message(snapshot.error), onRetry: _reload)
|
||||||
|
else if (items.isEmpty)
|
||||||
|
const EmptyPanel(text: 'Tidak ada sampel penerimaan.')
|
||||||
|
else ...[
|
||||||
|
...visible.map(
|
||||||
|
(item) => _InitialWorkCard(
|
||||||
|
item: item,
|
||||||
|
onTap: () => _showActions(item),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_InitialWorkPager(
|
||||||
|
start: start + 1,
|
||||||
|
end: end,
|
||||||
|
total: items.length,
|
||||||
|
canPrevious: page > 0,
|
||||||
|
canNext: page < maxPage,
|
||||||
|
onPrevious: () => setState(() => _page = page - 1),
|
||||||
|
onNext: () => setState(() => _page = page + 1),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _InitialWorkHeader extends StatelessWidget {
|
||||||
|
const _InitialWorkHeader({
|
||||||
|
required this.total,
|
||||||
|
required this.user,
|
||||||
|
required this.notificationCount,
|
||||||
|
required this.onNotifications,
|
||||||
|
required this.onProfile,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int total;
|
||||||
|
final Map<String, dynamic> user;
|
||||||
|
final int notificationCount;
|
||||||
|
final VoidCallback onNotifications;
|
||||||
|
final VoidCallback onProfile;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
icon: const Icon(Icons.arrow_back_ios_new_rounded),
|
||||||
|
color: const Color(0xFF080D3D),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
'Pengerjaan Awal',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.headlineSmall
|
||||||
|
?.copyWith(
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
color: const Color(0xFF080D3D),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
BadgeLabel(text: '$total', color: const Color(0xFF0B7BFF)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Penerimaan Sampel',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
color: const Color(0xFF47517A),
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
onPressed: onNotifications,
|
||||||
|
icon: const Icon(Icons.notifications_none_rounded, size: 30),
|
||||||
|
color: const Color(0xFF063B60),
|
||||||
|
tooltip: 'Notifikasi nilai kritis',
|
||||||
|
),
|
||||||
|
if (notificationCount > 0)
|
||||||
|
Positioned(
|
||||||
|
top: 6,
|
||||||
|
right: 6,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(5),
|
||||||
|
constraints: const BoxConstraints(minWidth: 20),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Color(0xFFFF1D25),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
notificationCount > 99 ? '99+' : '$notificationCount',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(28),
|
||||||
|
onTap: onProfile,
|
||||||
|
child: CircleAvatar(
|
||||||
|
radius: 27,
|
||||||
|
backgroundColor: const Color(0xFF12BFA5),
|
||||||
|
child: Text(
|
||||||
|
_initials(user['nama']?.toString() ?? 'SP'),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _initials(String name) {
|
||||||
|
final parts = name
|
||||||
|
.trim()
|
||||||
|
.split(RegExp(r'\s+'))
|
||||||
|
.where((part) => part.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
if (parts.isEmpty) {
|
||||||
|
return 'SP';
|
||||||
|
}
|
||||||
|
return parts.take(2).map((part) => part[0].toUpperCase()).join();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _InitialWorkCard extends StatelessWidget {
|
||||||
|
const _InitialWorkCard({required this.item, required this.onTap});
|
||||||
|
|
||||||
|
final Map<String, dynamic> item;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final dateTime = _splitDateTime(item['daftar']?.toString() ?? '');
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
elevation: 0,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
side: const BorderSide(color: Color(0xFFE1E8F5)),
|
||||||
|
),
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
onTap: onTap,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
item['nofoto']?.toString() ?? '-',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
|
color: const Color(0xFF080D3D),
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
BadgeLabel(
|
||||||
|
text: item['status']?.toString() ?? 'Penerimaan Sampel',
|
||||||
|
color: const Color(0xFF0B7BFF),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_CardMeta(text: dateTime.$1),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'No. RM',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Color(0xFF47517A),
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
item['noregister']?.toString() ?? '-',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Color(0xFF080D3D),
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
const Text(
|
||||||
|
'Nama',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Color(0xFF47517A),
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
item['nmpasien']?.toString() ?? '-',
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Color(0xFF080D3D),
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_CardMeta(text: dateTime.$2),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
(String, String) _splitDateTime(String value) {
|
||||||
|
if (value.isEmpty) {
|
||||||
|
return ('-', '-');
|
||||||
|
}
|
||||||
|
final normalized = value.replaceFirst('T', ' ');
|
||||||
|
final parts = normalized.split(RegExp(r'\s+'));
|
||||||
|
final date = parts.isNotEmpty ? parts.first : '-';
|
||||||
|
final time = parts.length > 1 ? parts[1].split('.').first : '-';
|
||||||
|
return (date, time);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CardMeta extends StatelessWidget {
|
||||||
|
const _CardMeta({required this.text});
|
||||||
|
|
||||||
|
final String text;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Text(
|
||||||
|
text,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Color(0xFF47517A),
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
fontSize: 13,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _InitialWorkPager extends StatelessWidget {
|
||||||
|
const _InitialWorkPager({
|
||||||
|
required this.start,
|
||||||
|
required this.end,
|
||||||
|
required this.total,
|
||||||
|
required this.canPrevious,
|
||||||
|
required this.canNext,
|
||||||
|
required this.onPrevious,
|
||||||
|
required this.onNext,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int start;
|
||||||
|
final int end;
|
||||||
|
final int total;
|
||||||
|
final bool canPrevious;
|
||||||
|
final bool canNext;
|
||||||
|
final VoidCallback onPrevious;
|
||||||
|
final VoidCallback onNext;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'$start-$end dari $total sampel',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Color(0xFF47517A),
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Text(
|
||||||
|
'Tampilkan',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Color(0xFF47517A),
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
BadgeLabel(text: '10', color: const Color(0xFF667095)),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
IconButton.filledTonal(
|
||||||
|
onPressed: canPrevious ? onPrevious : null,
|
||||||
|
icon: const Icon(Icons.chevron_left_rounded),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
IconButton.filledTonal(
|
||||||
|
onPressed: canNext ? onNext : null,
|
||||||
|
icon: const Icon(Icons.chevron_right_rounded),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AcceptInitialWorkDialog extends StatefulWidget {
|
||||||
|
const _AcceptInitialWorkDialog({required this.item, required this.onSubmit});
|
||||||
|
|
||||||
|
final Map<String, dynamic> item;
|
||||||
|
final Future<bool> Function(Map<String, dynamic> payload) onSubmit;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_AcceptInitialWorkDialog> createState() =>
|
||||||
|
_AcceptInitialWorkDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AcceptInitialWorkDialogState extends State<_AcceptInitialWorkDialog> {
|
||||||
|
static const _mediaOptions = [
|
||||||
|
'-',
|
||||||
|
'Media BAP',
|
||||||
|
'Media CAP',
|
||||||
|
'Media Mc Conkey',
|
||||||
|
'Media SDA R1',
|
||||||
|
'Media SDA R2',
|
||||||
|
'Media SDA I1',
|
||||||
|
'Media SDA I2',
|
||||||
|
];
|
||||||
|
|
||||||
|
final Set<String> _selectedMedia = {};
|
||||||
|
String _jenis = '';
|
||||||
|
String _bulan = '';
|
||||||
|
bool _saving = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Terima Sampel'),
|
||||||
|
content: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
widget.item['nofoto']?.toString() ?? '-',
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w900),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(widget.item['nmpasien']?.toString() ?? '-'),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
Text(
|
||||||
|
'Media Tanam Yang digunakan',
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w900),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: _mediaOptions.map((media) {
|
||||||
|
final selected = _selectedMedia.contains(media);
|
||||||
|
return FilterChip(
|
||||||
|
label: Text(media == '-' ? 'Tidak Menggunakan Media' : media),
|
||||||
|
selected: selected,
|
||||||
|
onSelected: (_) => setState(() {
|
||||||
|
if (selected) {
|
||||||
|
_selectedMedia.remove(media);
|
||||||
|
} else {
|
||||||
|
_selectedMedia.add(media);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
Text(
|
||||||
|
'Khusus Yang Akan dikirim ke BD MGIT',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: const Color(0xFF667095),
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
DropdownButtonFormField<String>(
|
||||||
|
initialValue: _jenis,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Jenis Pengobatan Terduga/Pasien TBC',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
items: const [
|
||||||
|
DropdownMenuItem(value: '', child: Text('Pilih Salah Satu')),
|
||||||
|
DropdownMenuItem(value: 'F', child: Text('Follow Up')),
|
||||||
|
DropdownMenuItem(value: 'K', child: Text('Kontrol Bulan Ke')),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: 'P',
|
||||||
|
child: Text('Pasca Pengobatan Bulan Ke'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onChanged: (value) => setState(() {
|
||||||
|
_jenis = value ?? '';
|
||||||
|
if (_jenis == 'F' || _jenis.isEmpty) {
|
||||||
|
_bulan = '';
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
DropdownButtonFormField<String>(
|
||||||
|
initialValue: _bulan,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Bulan Ke',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
items: [
|
||||||
|
const DropdownMenuItem(
|
||||||
|
value: '',
|
||||||
|
child: Text('Pilih Salah Satu'),
|
||||||
|
),
|
||||||
|
...List.generate(
|
||||||
|
24,
|
||||||
|
(index) => DropdownMenuItem(
|
||||||
|
value: '${index + 1}',
|
||||||
|
child: Text('Bulan Ke - ${index + 1}'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onChanged: _jenis == 'K' || _jenis == 'P'
|
||||||
|
? (value) => setState(() => _bulan = value ?? '')
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: _saving ? null : () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('Batal'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: _saving ? null : _submit,
|
||||||
|
child: _saving
|
||||||
|
? const SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Text('Terima'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
if (_selectedMedia.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Media Tanam Yang digunakan wajib dipilih.'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((_jenis == 'K' || _jenis == 'P') && _bulan.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Jika bukan Follow Up, Bulan Ke wajib diisi.'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() => _saving = true);
|
||||||
|
final saved = await widget.onSubmit({
|
||||||
|
'mediatanam': _selectedMedia.toList(),
|
||||||
|
'jenis': _jenis,
|
||||||
|
'bulan': _bulan,
|
||||||
|
});
|
||||||
|
if (!mounted) return;
|
||||||
|
if (saved) {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
} else {
|
||||||
|
setState(() => _saving = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,421 @@
|
|||||||
|
part of '../../app/app.dart';
|
||||||
|
|
||||||
|
class _DashboardSubHeader extends StatelessWidget {
|
||||||
|
const _DashboardSubHeader({
|
||||||
|
required this.title,
|
||||||
|
required this.subtitle,
|
||||||
|
required this.badgeText,
|
||||||
|
required this.user,
|
||||||
|
required this.notificationCount,
|
||||||
|
required this.onNotifications,
|
||||||
|
required this.onProfile,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String title;
|
||||||
|
final String subtitle;
|
||||||
|
final String badgeText;
|
||||||
|
final Map<String, dynamic> user;
|
||||||
|
final int notificationCount;
|
||||||
|
final VoidCallback onNotifications;
|
||||||
|
final VoidCallback onProfile;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
icon: const Icon(Icons.arrow_back_ios_new_rounded),
|
||||||
|
color: const Color(0xFF080D3D),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.headlineSmall
|
||||||
|
?.copyWith(
|
||||||
|
color: const Color(0xFF080D3D),
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
BadgeLabel(text: badgeText, color: const Color(0xFF0B7BFF)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
subtitle,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
color: const Color(0xFF47517A),
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
onPressed: onNotifications,
|
||||||
|
icon: const Icon(Icons.notifications_none_rounded, size: 30),
|
||||||
|
color: const Color(0xFF063B60),
|
||||||
|
tooltip: 'Notifikasi nilai kritis',
|
||||||
|
),
|
||||||
|
if (notificationCount > 0)
|
||||||
|
Positioned(
|
||||||
|
top: 6,
|
||||||
|
right: 6,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(5),
|
||||||
|
constraints: const BoxConstraints(minWidth: 20),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Color(0xFFFF1D25),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
notificationCount > 99 ? '99+' : '$notificationCount',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(28),
|
||||||
|
onTap: onProfile,
|
||||||
|
child: CircleAvatar(
|
||||||
|
radius: 27,
|
||||||
|
backgroundColor: const Color(0xFF12BFA5),
|
||||||
|
child: Text(
|
||||||
|
_initials(user['nama']?.toString() ?? 'SP'),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _initials(String name) {
|
||||||
|
final parts = name
|
||||||
|
.trim()
|
||||||
|
.split(RegExp(r'\s+'))
|
||||||
|
.where((part) => part.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
if (parts.isEmpty) {
|
||||||
|
return 'SP';
|
||||||
|
}
|
||||||
|
return parts.take(2).map((part) => part[0].toUpperCase()).join();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SampleGroupsScreen extends StatelessWidget {
|
||||||
|
const SampleGroupsScreen({
|
||||||
|
super.key,
|
||||||
|
required this.books,
|
||||||
|
required this.token,
|
||||||
|
required this.baseUrl,
|
||||||
|
required this.user,
|
||||||
|
required this.notificationCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<dynamic> books;
|
||||||
|
final String token;
|
||||||
|
final String baseUrl;
|
||||||
|
final Map<String, dynamic> user;
|
||||||
|
final int notificationCount;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final total = books.fold<int>(
|
||||||
|
0,
|
||||||
|
(sum, item) => sum + (asMap(item)['total'] as num? ?? 0).toInt(),
|
||||||
|
);
|
||||||
|
return Scaffold(
|
||||||
|
body: ListView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(18, 18, 18, 24),
|
||||||
|
children: [
|
||||||
|
_DashboardSubHeader(
|
||||||
|
title: 'Detail Sampel',
|
||||||
|
subtitle: 'Catatan & Register',
|
||||||
|
badgeText: '$total',
|
||||||
|
user: user,
|
||||||
|
notificationCount: notificationCount,
|
||||||
|
onNotifications: () {
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) =>
|
||||||
|
CriticalValuesScreen(token: token, baseUrl: baseUrl),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onProfile: () => _showProfile(context, user),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 28),
|
||||||
|
_SampleGroupsSummary(total: total, count: books.length),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
Text(
|
||||||
|
'Jenis Spesimen',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
color: const Color(0xFF080D3D),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
if (books.isEmpty)
|
||||||
|
const EmptyPanel(text: 'Tidak ada kelompok pemeriksaan.')
|
||||||
|
else
|
||||||
|
...books.map(
|
||||||
|
(book) => _SampleGroupCard(
|
||||||
|
book: asMap(book),
|
||||||
|
onTap: () {
|
||||||
|
final mapped = asMap(book);
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => SpecimenRegisterScreen(
|
||||||
|
token: token,
|
||||||
|
baseUrl: baseUrl,
|
||||||
|
master: mapped['master']?.toString() ?? 'buku0',
|
||||||
|
title: mapped['label']?.toString() ?? 'Spesimen',
|
||||||
|
total: (mapped['total'] as num? ?? 0).toInt(),
|
||||||
|
user: user,
|
||||||
|
notificationCount: notificationCount,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showProfile(BuildContext context, Map<String, dynamic> user) {
|
||||||
|
showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
showDragHandle: true,
|
||||||
|
builder: (context) => SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 4, 20, 20),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
CircleAvatar(
|
||||||
|
radius: 28,
|
||||||
|
backgroundColor: const Color(0xFF0F766E),
|
||||||
|
child: Text(
|
||||||
|
_initials(user['nama']?.toString() ?? 'SP'),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
user['nama']?.toString() ?? '-',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium
|
||||||
|
?.copyWith(fontWeight: FontWeight.w900),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
'${user['username'] ?? '-'} - ${user['previlage'] ?? '-'}',
|
||||||
|
style: const TextStyle(color: Colors.black54),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: () async {
|
||||||
|
await SessionStore.clear();
|
||||||
|
if (!context.mounted) return;
|
||||||
|
Navigator.of(context).pushAndRemoveUntil(
|
||||||
|
MaterialPageRoute(builder: (_) => const LoginScreen()),
|
||||||
|
(_) => false,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.logout),
|
||||||
|
label: const Text('Logout'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _initials(String name) {
|
||||||
|
final parts = name
|
||||||
|
.trim()
|
||||||
|
.split(RegExp(r'\s+'))
|
||||||
|
.where((part) => part.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
if (parts.isEmpty) {
|
||||||
|
return 'SP';
|
||||||
|
}
|
||||||
|
return parts.take(2).map((part) => part[0].toUpperCase()).join();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SampleGroupsSummary extends StatelessWidget {
|
||||||
|
const _SampleGroupsSummary({required this.total, required this.count});
|
||||||
|
|
||||||
|
final int total;
|
||||||
|
final int count;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(18),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
border: Border.all(color: const Color(0xFFE1E8F5)),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: const Color(0xFF2563EB).withValues(alpha: 0.05),
|
||||||
|
blurRadius: 18,
|
||||||
|
offset: const Offset(0, 10),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 58,
|
||||||
|
height: 58,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFFF6B00).withValues(alpha: 0.12),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.menu_book_rounded,
|
||||||
|
color: Color(0xFFFF6B00),
|
||||||
|
size: 32,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'$total sampel aktif',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
|
color: const Color(0xFF080D3D),
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'$count jenis spesimen tersedia',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Color(0xFF47517A),
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SampleGroupCard extends StatelessWidget {
|
||||||
|
const _SampleGroupCard({required this.book, required this.onTap});
|
||||||
|
|
||||||
|
final Map<String, dynamic> book;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final total = (book['total'] as num? ?? 0).toInt();
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
elevation: 0,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
side: const BorderSide(color: Color(0xFFE1E8F5)),
|
||||||
|
),
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
onTap: onTap,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 54,
|
||||||
|
height: 54,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFFF6B00).withValues(alpha: 0.10),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.science_outlined,
|
||||||
|
color: Color(0xFFFF6B00),
|
||||||
|
size: 28,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
book['label']?.toString() ?? 'Spesimen',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
color: const Color(0xFF080D3D),
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
BadgeLabel(
|
||||||
|
text: '${book['master'] ?? '-'}',
|
||||||
|
color: const Color(0xFFFF6B00),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
BadgeLabel(text: '$total sampel', color: const Color(0xFF0B7BFF)),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const Icon(Icons.chevron_right_rounded, color: Color(0xFF667095)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,765 @@
|
|||||||
|
part of '../../app/app.dart';
|
||||||
|
|
||||||
|
class SpecimenRegisterScreen extends StatefulWidget {
|
||||||
|
const SpecimenRegisterScreen({
|
||||||
|
super.key,
|
||||||
|
required this.token,
|
||||||
|
required this.baseUrl,
|
||||||
|
required this.master,
|
||||||
|
required this.title,
|
||||||
|
required this.total,
|
||||||
|
required this.user,
|
||||||
|
required this.notificationCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String token;
|
||||||
|
final String baseUrl;
|
||||||
|
final String master;
|
||||||
|
final String title;
|
||||||
|
final int total;
|
||||||
|
final Map<String, dynamic> user;
|
||||||
|
final int notificationCount;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SpecimenRegisterScreen> createState() => _SpecimenRegisterScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SpecimenRegisterScreenState extends State<SpecimenRegisterScreen> {
|
||||||
|
late final ApiClient _api;
|
||||||
|
late Future<Map<String, dynamic>> _future;
|
||||||
|
final _search = TextEditingController();
|
||||||
|
static const _allSpecimens = '__all_specimens__';
|
||||||
|
static const _allStatuses = '__all_statuses__';
|
||||||
|
static const int _pageSize = 10;
|
||||||
|
String _specimen = _allSpecimens;
|
||||||
|
String _status = _allStatuses;
|
||||||
|
int _page = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_api = ApiClient(baseUrl: widget.baseUrl, token: widget.token);
|
||||||
|
_future = _load();
|
||||||
|
_search.addListener(_onSearchChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_search.removeListener(_onSearchChanged);
|
||||||
|
_search.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> _load() {
|
||||||
|
return _api.get('api/mobile/books/${widget.master}/examinations');
|
||||||
|
}
|
||||||
|
|
||||||
|
void _reload() {
|
||||||
|
setState(() {
|
||||||
|
_page = 0;
|
||||||
|
_future = _load();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onSearchChanged() {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_page = 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openCriticalValues() async {
|
||||||
|
await Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) =>
|
||||||
|
CriticalValuesScreen(token: widget.token, baseUrl: widget.baseUrl),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showProfile() {
|
||||||
|
showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
showDragHandle: true,
|
||||||
|
builder: (context) => SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 4, 20, 20),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
CircleAvatar(
|
||||||
|
radius: 28,
|
||||||
|
backgroundColor: const Color(0xFF0F766E),
|
||||||
|
child: Text(
|
||||||
|
_initials(widget.user['nama']?.toString() ?? 'SP'),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
widget.user['nama']?.toString() ?? '-',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium
|
||||||
|
?.copyWith(fontWeight: FontWeight.w900),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
'${widget.user['username'] ?? '-'} • ${widget.user['previlage'] ?? '-'}',
|
||||||
|
style: const TextStyle(color: Colors.black54),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: () async {
|
||||||
|
await SessionStore.clear();
|
||||||
|
if (!context.mounted) return;
|
||||||
|
Navigator.of(context).pushAndRemoveUntil(
|
||||||
|
MaterialPageRoute(builder: (_) => const LoginScreen()),
|
||||||
|
(_) => false,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.logout),
|
||||||
|
label: const Text('Logout'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _initials(String name) {
|
||||||
|
final parts = name
|
||||||
|
.trim()
|
||||||
|
.split(RegExp(r'\s+'))
|
||||||
|
.where((part) => part.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
if (parts.isEmpty) {
|
||||||
|
return 'SP';
|
||||||
|
}
|
||||||
|
return parts.take(2).map((part) => part[0].toUpperCase()).join();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
body: SafeArea(
|
||||||
|
child: FutureBuilder<Map<String, dynamic>>(
|
||||||
|
future: _future,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
final items = asList(snapshot.data?['items']);
|
||||||
|
final filtered = _filtered(items);
|
||||||
|
final specimens = _specimenOptions(items);
|
||||||
|
final statuses = _statusOptions(items);
|
||||||
|
final maxPage = filtered.isEmpty
|
||||||
|
? 0
|
||||||
|
: (filtered.length - 1) ~/ _pageSize;
|
||||||
|
final page = _page.clamp(0, maxPage);
|
||||||
|
final start = filtered.isEmpty ? 0 : page * _pageSize;
|
||||||
|
final end = filtered.isEmpty
|
||||||
|
? 0
|
||||||
|
: (start + _pageSize).clamp(0, filtered.length);
|
||||||
|
final visible = filtered.sublist(start, end);
|
||||||
|
return ListView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(18, 12, 18, 24),
|
||||||
|
children: [
|
||||||
|
_SpecimenHeader(
|
||||||
|
title: widget.title,
|
||||||
|
total: filtered.length,
|
||||||
|
user: widget.user,
|
||||||
|
notificationCount: widget.notificationCount,
|
||||||
|
onNotifications: _openCriticalValues,
|
||||||
|
onProfile: _showProfile,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
_FilterDropdown(
|
||||||
|
label: 'Jenis Spesimen',
|
||||||
|
value: _specimen,
|
||||||
|
icon: Icons.science_outlined,
|
||||||
|
options: specimens,
|
||||||
|
onChanged: (value) => setState(() {
|
||||||
|
_specimen = value;
|
||||||
|
_status = _allStatuses;
|
||||||
|
_page = 0;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
_FilterDropdown(
|
||||||
|
label: 'Status Pengerjaan',
|
||||||
|
value: _status,
|
||||||
|
icon: Icons.assignment_outlined,
|
||||||
|
options: statuses,
|
||||||
|
onChanged: (value) => setState(() {
|
||||||
|
_status = value;
|
||||||
|
_page = 0;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 22),
|
||||||
|
Text(
|
||||||
|
'Cari Spesimen',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
color: const Color(0xFF080D3D),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
_SearchBlock(search: _search),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
const Divider(),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
_RegisterTitle(title: widget.title, total: filtered.length),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
if (snapshot.connectionState != ConnectionState.done)
|
||||||
|
const SizedBox(
|
||||||
|
height: 180,
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
)
|
||||||
|
else if (snapshot.hasError)
|
||||||
|
ErrorView(message: _message(snapshot.error), onRetry: _reload)
|
||||||
|
else if (filtered.isEmpty)
|
||||||
|
const EmptyPanel(text: 'Tidak ada sampel.')
|
||||||
|
else ...[
|
||||||
|
...visible.map(
|
||||||
|
(item) => SpecimenSampleCard(
|
||||||
|
item: asMap(item),
|
||||||
|
token: widget.token,
|
||||||
|
baseUrl: widget.baseUrl,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_SpecimenPager(
|
||||||
|
start: start + 1,
|
||||||
|
end: end,
|
||||||
|
total: filtered.length,
|
||||||
|
canPrevious: page > 0,
|
||||||
|
canNext: page < maxPage,
|
||||||
|
onPrevious: () => setState(() => _page = page - 1),
|
||||||
|
onNext: () => setState(() => _page = page + 1),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
bottomNavigationBar: NavigationBar(
|
||||||
|
selectedIndex: 1,
|
||||||
|
onDestinationSelected: (index) {
|
||||||
|
if (index == 0) {
|
||||||
|
Navigator.of(context).popUntil((route) => route.isFirst);
|
||||||
|
}
|
||||||
|
if (index == 2) {
|
||||||
|
Navigator.of(context).popUntil((route) => route.isFirst);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
destinations: const [
|
||||||
|
NavigationDestination(
|
||||||
|
icon: Icon(Icons.home_outlined),
|
||||||
|
label: 'Beranda',
|
||||||
|
),
|
||||||
|
NavigationDestination(icon: Icon(Icons.search), label: 'Cari Sampel'),
|
||||||
|
NavigationDestination(
|
||||||
|
icon: Icon(Icons.person_outline),
|
||||||
|
label: 'Profil',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<dynamic> _filtered(List<dynamic> items) {
|
||||||
|
final query = _search.text.trim().toLowerCase();
|
||||||
|
return items.where((raw) {
|
||||||
|
final item = asMap(raw);
|
||||||
|
final specimenValue = _specimenValue(item);
|
||||||
|
final specimenOk =
|
||||||
|
_specimen == _allSpecimens || specimenValue == _specimen;
|
||||||
|
final statusOk =
|
||||||
|
_status == _allStatuses || item['status']?.toString() == _status;
|
||||||
|
final searchOk =
|
||||||
|
query.isEmpty ||
|
||||||
|
[
|
||||||
|
item['nofoto'],
|
||||||
|
item['noregister'],
|
||||||
|
item['nmpasien'],
|
||||||
|
item['reques'],
|
||||||
|
item['kd_spesimen'],
|
||||||
|
item['nm_spesimen'],
|
||||||
|
item['status'],
|
||||||
|
].whereType<Object>().join(' ').toLowerCase().contains(query);
|
||||||
|
return specimenOk && statusOk && searchOk;
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<_FilterOption> _specimenOptions(List<dynamic> items) {
|
||||||
|
final values = <String, String>{};
|
||||||
|
for (final raw in items) {
|
||||||
|
final item = asMap(raw);
|
||||||
|
final value = _specimenValue(item);
|
||||||
|
final label = item['nm_spesimen']?.toString() ?? '';
|
||||||
|
if (value.isNotEmpty && label.isNotEmpty) {
|
||||||
|
values.putIfAbsent(value, () => label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final options =
|
||||||
|
values.entries
|
||||||
|
.map((entry) => _FilterOption(value: entry.key, label: entry.value))
|
||||||
|
.toList()
|
||||||
|
..sort((a, b) => a.label.compareTo(b.label));
|
||||||
|
return [
|
||||||
|
const _FilterOption(value: _allSpecimens, label: 'Semua Spesimen'),
|
||||||
|
...options,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
List<_FilterOption> _statusOptions(List<dynamic> items) {
|
||||||
|
final filteredBySpecimen = items.where((raw) {
|
||||||
|
final item = asMap(raw);
|
||||||
|
return _specimen == _allSpecimens || _specimenValue(item) == _specimen;
|
||||||
|
});
|
||||||
|
final values =
|
||||||
|
filteredBySpecimen
|
||||||
|
.map((item) => asMap(item)['status']?.toString() ?? '')
|
||||||
|
.where((value) => value.isNotEmpty)
|
||||||
|
.toSet()
|
||||||
|
.toList()
|
||||||
|
..sort();
|
||||||
|
return [
|
||||||
|
const _FilterOption(value: _allStatuses, label: 'Semua Status'),
|
||||||
|
...values.map((value) => _FilterOption(value: value, label: value)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
String _specimenValue(Map<String, dynamic> item) {
|
||||||
|
final code = item['kd_spesimen']?.toString() ?? '';
|
||||||
|
if (code.isNotEmpty) {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
return item['nm_spesimen']?.toString() ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FilterOption {
|
||||||
|
const _FilterOption({required this.value, required this.label});
|
||||||
|
|
||||||
|
final String value;
|
||||||
|
final String label;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SpecimenHeader extends StatelessWidget {
|
||||||
|
const _SpecimenHeader({
|
||||||
|
required this.title,
|
||||||
|
required this.total,
|
||||||
|
required this.user,
|
||||||
|
required this.notificationCount,
|
||||||
|
required this.onNotifications,
|
||||||
|
required this.onProfile,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String title;
|
||||||
|
final int total;
|
||||||
|
final Map<String, dynamic> user;
|
||||||
|
final int notificationCount;
|
||||||
|
final VoidCallback onNotifications;
|
||||||
|
final VoidCallback onProfile;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.headlineSmall
|
||||||
|
?.copyWith(
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
color: const Color(0xFF080D3D),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
BadgeLabel(text: '$total', color: const Color(0xFF6D28D9)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Catatan & Register',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
color: const Color(0xFF47517A),
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
onPressed: onNotifications,
|
||||||
|
icon: const Icon(Icons.notifications_none_rounded, size: 30),
|
||||||
|
color: const Color(0xFF063B60),
|
||||||
|
tooltip: 'Notifikasi nilai kritis',
|
||||||
|
),
|
||||||
|
if (notificationCount > 0)
|
||||||
|
Positioned(
|
||||||
|
top: 6,
|
||||||
|
right: 6,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(5),
|
||||||
|
constraints: const BoxConstraints(minWidth: 20),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Color(0xFFFF1D25),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
notificationCount > 99 ? '99+' : '$notificationCount',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
onTap: onProfile,
|
||||||
|
child: CircleAvatar(
|
||||||
|
backgroundColor: const Color(0xFF12BFA5),
|
||||||
|
child: Text(
|
||||||
|
_initials(user['nama']?.toString() ?? 'SP'),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _initials(String name) {
|
||||||
|
final parts = name
|
||||||
|
.trim()
|
||||||
|
.split(RegExp(r'\s+'))
|
||||||
|
.where((part) => part.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
if (parts.isEmpty) {
|
||||||
|
return 'SP';
|
||||||
|
}
|
||||||
|
return parts.take(2).map((part) => part[0].toUpperCase()).join();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FilterDropdown extends StatelessWidget {
|
||||||
|
const _FilterDropdown({
|
||||||
|
required this.label,
|
||||||
|
required this.value,
|
||||||
|
required this.icon,
|
||||||
|
required this.options,
|
||||||
|
required this.onChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final String value;
|
||||||
|
final IconData icon;
|
||||||
|
final List<_FilterOption> options;
|
||||||
|
final ValueChanged<String> onChanged;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final values = options.map((option) => option.value).toSet();
|
||||||
|
final effectiveValue = values.contains(value) ? value : options.first.value;
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
color: const Color(0xFF080D3D),
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
DropdownButtonFormField<String>(
|
||||||
|
initialValue: effectiveValue,
|
||||||
|
isExpanded: true,
|
||||||
|
selectedItemBuilder: (context) => options
|
||||||
|
.map(
|
||||||
|
(option) => Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Text(
|
||||||
|
option.label,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
prefixIcon: Icon(icon, color: const Color(0xFF6D28D9)),
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
borderSide: const BorderSide(color: Color(0xFFA875FF)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
items: options
|
||||||
|
.map(
|
||||||
|
(option) => DropdownMenuItem(
|
||||||
|
value: option.value,
|
||||||
|
child: Text(option.label, overflow: TextOverflow.ellipsis),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
onChanged: (value) => onChanged(value ?? options.first.value),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SearchBlock extends StatelessWidget {
|
||||||
|
const _SearchBlock({required this.search});
|
||||||
|
|
||||||
|
final TextEditingController search;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: search,
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
prefixIcon: const Icon(Icons.search),
|
||||||
|
hintText: 'Ketik No. Sampel, No. RM, atau Nama Pasien',
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: BadgeLabel(
|
||||||
|
text: 'Pencarian: No. Sampel / No. RM / Nama',
|
||||||
|
color: const Color(0xFF6D28D9),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SpecimenPager extends StatelessWidget {
|
||||||
|
const _SpecimenPager({
|
||||||
|
required this.start,
|
||||||
|
required this.end,
|
||||||
|
required this.total,
|
||||||
|
required this.canPrevious,
|
||||||
|
required this.canNext,
|
||||||
|
required this.onPrevious,
|
||||||
|
required this.onNext,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int start;
|
||||||
|
final int end;
|
||||||
|
final int total;
|
||||||
|
final bool canPrevious;
|
||||||
|
final bool canNext;
|
||||||
|
final VoidCallback onPrevious;
|
||||||
|
final VoidCallback onNext;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'$start-$end dari $total sampel',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Color(0xFF47517A),
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Text(
|
||||||
|
'Tampilkan',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Color(0xFF47517A),
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
BadgeLabel(text: '10', color: const Color(0xFF667095)),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
IconButton.filledTonal(
|
||||||
|
onPressed: canPrevious ? onPrevious : null,
|
||||||
|
icon: const Icon(Icons.chevron_left_rounded),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
IconButton.filledTonal(
|
||||||
|
onPressed: canNext ? onNext : null,
|
||||||
|
icon: const Icon(Icons.chevron_right_rounded),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RegisterTitle extends StatelessWidget {
|
||||||
|
const _RegisterTitle({required this.title, required this.total});
|
||||||
|
|
||||||
|
final String title;
|
||||||
|
final int total;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.menu_book_rounded, color: Color(0xFF6D28D9)),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'BUKU REGISTER ${title.toUpperCase()}',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
color: const Color(0xFF6D28D9),
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'$total',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Color(0xFF667095),
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SpecimenSampleCard extends StatelessWidget {
|
||||||
|
const SpecimenSampleCard({
|
||||||
|
super.key,
|
||||||
|
required this.item,
|
||||||
|
required this.token,
|
||||||
|
required this.baseUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Map<String, dynamic> item;
|
||||||
|
final String token;
|
||||||
|
final String baseUrl;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
elevation: 0,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
side: const BorderSide(color: Color(0xFFE1E8F5)),
|
||||||
|
),
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
onTap: () => _openDetail(context, item, token, baseUrl),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
flex: 6,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
item['nofoto']?.toString() ?? '-',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
|
color: const Color(0xFF080D3D),
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
BadgeLabel(
|
||||||
|
text: item['reques']?.toString() ?? '-',
|
||||||
|
color: const Color(0xFF0891B2),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
item['nmpasien']?.toString() ?? '-',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Color(0xFF47517A),
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'No.RM ${item['noregister'] ?? '-'}',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Color(0xFF47517A),
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(width: 1, height: 96, color: const Color(0xFFE1E8F5)),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
Expanded(
|
||||||
|
flex: 4,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Status :',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Color(0xFF667095),
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
StatusPill(status: item['status']?.toString() ?? 'NEW'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -65,16 +65,6 @@ class _ExaminationDetailScreenState extends State<ExaminationDetailScreen> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
|
||||||
title: const Text('Detail Pemeriksaan'),
|
|
||||||
actions: [
|
|
||||||
IconButton(
|
|
||||||
onPressed: _reload,
|
|
||||||
icon: const Icon(Icons.refresh),
|
|
||||||
tooltip: 'Cek status',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
body: FutureBuilder<Map<String, dynamic>>(
|
body: FutureBuilder<Map<String, dynamic>>(
|
||||||
future: _future,
|
future: _future,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
@@ -90,80 +80,27 @@ class _ExaminationDetailScreenState extends State<ExaminationDetailScreen> {
|
|||||||
final data = snapshot.data!;
|
final data = snapshot.data!;
|
||||||
final item = asMap(data['item']);
|
final item = asMap(data['item']);
|
||||||
return ListView(
|
return ListView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.fromLTRB(18, 18, 18, 28),
|
||||||
children: [
|
children: [
|
||||||
Card(
|
SafeArea(
|
||||||
shape: RoundedRectangleBorder(
|
bottom: false,
|
||||||
borderRadius: BorderRadius.circular(8),
|
child: _ExaminationScreenHeader(
|
||||||
),
|
title: 'Detail Pemeriksaan',
|
||||||
child: Padding(
|
subtitle: item['nofoto']?.toString() ?? '-',
|
||||||
padding: const EdgeInsets.all(16),
|
onRefresh: _reload,
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
item['nmpasien']?.toString() ?? '-',
|
|
||||||
style: Theme.of(context).textTheme.titleLarge
|
|
||||||
?.copyWith(fontWeight: FontWeight.w800),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
StatusPill(
|
|
||||||
status: item['status']?.toString() ?? 'NEW',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
DetailRow(label: 'No. Lab', value: item['nofoto']),
|
|
||||||
DetailRow(label: 'No. RM', value: item['noregister']),
|
|
||||||
DetailRow(label: 'Order', value: item['reques']),
|
|
||||||
DetailRow(
|
|
||||||
label: 'Asal Pasien',
|
|
||||||
value: item['asalpasien'],
|
|
||||||
),
|
|
||||||
DetailRow(label: 'Ruangan', value: item['ruangan']),
|
|
||||||
DetailRow(
|
|
||||||
label: 'Dokter Pengirim',
|
|
||||||
value: item['klinisi'] ?? item['nmdokter'],
|
|
||||||
),
|
|
||||||
DetailRow(label: 'Spesimen', value: item['nm_spesimen']),
|
|
||||||
DetailRow(label: 'Tanggal Daftar', value: item['daftar']),
|
|
||||||
DetailRow(
|
|
||||||
label: 'Tanggal Sampel',
|
|
||||||
value: item['tanggalsampel'],
|
|
||||||
),
|
|
||||||
DetailRow(
|
|
||||||
label: 'Cara Pengambilan',
|
|
||||||
value: item['pengambilan'],
|
|
||||||
),
|
|
||||||
DetailRow(
|
|
||||||
label: 'Asal Pengambilan',
|
|
||||||
value: item['asalpengirim'],
|
|
||||||
),
|
|
||||||
DetailRow(label: 'Alamat', value: item['alamatpasien']),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 22),
|
||||||
FilledButton.icon(
|
_ExaminationPatientCard(item: item),
|
||||||
onPressed: _openExpertise,
|
const SizedBox(height: 14),
|
||||||
icon: const Icon(Icons.edit_document),
|
_ExaminationInfoCard(item: item),
|
||||||
label: const Text('Expertise'),
|
const SizedBox(height: 14),
|
||||||
|
_ExaminationActionPanel(
|
||||||
|
onExpertise: _openExpertise,
|
||||||
|
onRefresh: _reload,
|
||||||
|
resultUrl: data['result_url']?.toString(),
|
||||||
|
onPreview: () => _launch(data['result_url']?.toString()),
|
||||||
),
|
),
|
||||||
OutlinedButton.icon(
|
|
||||||
onPressed: _reload,
|
|
||||||
icon: const Icon(Icons.fact_check_outlined),
|
|
||||||
label: const Text('Cek Status'),
|
|
||||||
),
|
|
||||||
if (data['result_url'] != null)
|
|
||||||
TextButton.icon(
|
|
||||||
onPressed: () => _launch(data['result_url']?.toString()),
|
|
||||||
icon: const Icon(Icons.description_outlined),
|
|
||||||
label: const Text('Preview Hasil'),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -171,3 +108,170 @@ class _ExaminationDetailScreenState extends State<ExaminationDetailScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _ExaminationPatientCard extends StatelessWidget {
|
||||||
|
const _ExaminationPatientCard({required this.item});
|
||||||
|
|
||||||
|
final Map<String, dynamic> item;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(18),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
border: Border.all(color: const Color(0xFFE1E8F5)),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: const Color(0xFF2563EB).withValues(alpha: 0.05),
|
||||||
|
blurRadius: 18,
|
||||||
|
offset: const Offset(0, 10),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
item['nmpasien']?.toString() ?? '-',
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
|
color: const Color(0xFF080D3D),
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
StatusPill(status: item['status']?.toString() ?? 'NEW'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
BadgeLabel(
|
||||||
|
text: item['nofoto']?.toString() ?? '-',
|
||||||
|
color: const Color(0xFF0B7BFF),
|
||||||
|
),
|
||||||
|
BadgeLabel(
|
||||||
|
text: 'RM ${item['noregister'] ?? '-'}',
|
||||||
|
color: const Color(0xFF667095),
|
||||||
|
),
|
||||||
|
BadgeLabel(
|
||||||
|
text: item['nm_spesimen']?.toString() ?? '-',
|
||||||
|
color: const Color(0xFFFF6B00),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
item['reques']?.toString() ?? '-',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Color(0xFF47517A),
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
height: 1.35,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ExaminationInfoCard extends StatelessWidget {
|
||||||
|
const _ExaminationInfoCard({required this.item});
|
||||||
|
|
||||||
|
final Map<String, dynamic> item;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final rows = [
|
||||||
|
('Asal Pasien', item['asalpasien']),
|
||||||
|
('Ruangan', item['ruangan']),
|
||||||
|
('Dokter Pengirim', item['klinisi'] ?? item['nmdokter']),
|
||||||
|
('Tanggal Daftar', item['daftar']),
|
||||||
|
('Tanggal Sampel', item['tanggalsampel']),
|
||||||
|
('Cara Pengambilan', item['pengambilan']),
|
||||||
|
('Asal Pengambilan', item['asalpengirim']),
|
||||||
|
('Alamat', item['alamatpasien']),
|
||||||
|
];
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(18),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
border: Border.all(color: const Color(0xFFE1E8F5)),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Informasi Pemeriksaan',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
color: const Color(0xFF080D3D),
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
...rows.map((row) => DetailRow(label: row.$1, value: row.$2)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ExaminationActionPanel extends StatelessWidget {
|
||||||
|
const _ExaminationActionPanel({
|
||||||
|
required this.onExpertise,
|
||||||
|
required this.onRefresh,
|
||||||
|
required this.resultUrl,
|
||||||
|
required this.onPreview,
|
||||||
|
});
|
||||||
|
|
||||||
|
final VoidCallback onExpertise;
|
||||||
|
final VoidCallback onRefresh;
|
||||||
|
final String? resultUrl;
|
||||||
|
final VoidCallback onPreview;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
border: Border.all(color: const Color(0xFFE1E8F5)),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: onExpertise,
|
||||||
|
icon: const Icon(Icons.edit_document),
|
||||||
|
label: const Text('Expertise'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: onRefresh,
|
||||||
|
icon: const Icon(Icons.fact_check_outlined),
|
||||||
|
label: const Text('Cek Status'),
|
||||||
|
),
|
||||||
|
if (resultUrl?.isNotEmpty == true) ...[
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: onPreview,
|
||||||
|
icon: const Icon(Icons.description_outlined),
|
||||||
|
label: const Text('Preview Hasil'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,12 +7,14 @@ class ExaminationListScreen extends StatefulWidget {
|
|||||||
required this.baseUrl,
|
required this.baseUrl,
|
||||||
required this.master,
|
required this.master,
|
||||||
required this.title,
|
required this.title,
|
||||||
|
this.initialSearch,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String token;
|
final String token;
|
||||||
final String baseUrl;
|
final String baseUrl;
|
||||||
final String master;
|
final String master;
|
||||||
final String title;
|
final String title;
|
||||||
|
final String? initialSearch;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ExaminationListScreen> createState() => _ExaminationListScreenState();
|
State<ExaminationListScreen> createState() => _ExaminationListScreenState();
|
||||||
@@ -27,6 +29,7 @@ class _ExaminationListScreenState extends State<ExaminationListScreen> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_api = ApiClient(baseUrl: widget.baseUrl, token: widget.token);
|
_api = ApiClient(baseUrl: widget.baseUrl, token: widget.token);
|
||||||
|
_search.text = widget.initialSearch ?? '';
|
||||||
_future = _load();
|
_future = _load();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,32 +56,50 @@ class _ExaminationListScreenState extends State<ExaminationListScreen> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
|
||||||
title: Text(widget.title),
|
|
||||||
actions: [
|
|
||||||
IconButton(
|
|
||||||
onPressed: _reload,
|
|
||||||
icon: const Icon(Icons.refresh),
|
|
||||||
tooltip: 'Refresh',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 10),
|
padding: const EdgeInsets.fromLTRB(18, 18, 18, 0),
|
||||||
child: TextField(
|
child: SafeArea(
|
||||||
controller: _search,
|
bottom: false,
|
||||||
textInputAction: TextInputAction.search,
|
child: _ExaminationScreenHeader(
|
||||||
onSubmitted: (_) => _reload(),
|
title: widget.title,
|
||||||
decoration: InputDecoration(
|
subtitle: 'List Pemeriksaan',
|
||||||
prefixIcon: const Icon(Icons.search),
|
onRefresh: _reload,
|
||||||
labelText: 'Cari no lab, RM, pasien, order',
|
),
|
||||||
border: const OutlineInputBorder(),
|
),
|
||||||
suffixIcon: IconButton(
|
),
|
||||||
onPressed: _reload,
|
Padding(
|
||||||
icon: const Icon(Icons.arrow_forward),
|
padding: const EdgeInsets.fromLTRB(18, 22, 18, 14),
|
||||||
tooltip: 'Cari',
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
border: Border.all(color: const Color(0xFFCFE0FF)),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: const Color(0xFF2563EB).withValues(alpha: 0.05),
|
||||||
|
blurRadius: 18,
|
||||||
|
offset: const Offset(0, 10),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: TextField(
|
||||||
|
controller: _search,
|
||||||
|
textInputAction: TextInputAction.search,
|
||||||
|
onSubmitted: (_) => _reload(),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
prefixIcon: const Icon(Icons.search),
|
||||||
|
hintText: 'Cari no lab, RM, pasien, order',
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
suffixIcon: IconButton(
|
||||||
|
onPressed: _reload,
|
||||||
|
icon: const Icon(Icons.manage_search_rounded),
|
||||||
|
tooltip: 'Cari',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -103,7 +124,7 @@ class _ExaminationListScreenState extends State<ExaminationListScreen> {
|
|||||||
return RefreshIndicator(
|
return RefreshIndicator(
|
||||||
onRefresh: () async => _reload(),
|
onRefresh: () async => _reload(),
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
|
padding: const EdgeInsets.fromLTRB(18, 0, 18, 24),
|
||||||
itemCount: items.length,
|
itemCount: items.length,
|
||||||
itemBuilder: (context, index) => ExaminationCard(
|
itemBuilder: (context, index) => ExaminationCard(
|
||||||
item: asMap(items[index]),
|
item: asMap(items[index]),
|
||||||
@@ -135,17 +156,35 @@ class ExaminationCompactTile extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ListTile(
|
return Card(
|
||||||
title: Text(
|
margin: const EdgeInsets.only(bottom: 10),
|
||||||
item['nmpasien']?.toString() ?? '-',
|
elevation: 0,
|
||||||
maxLines: 1,
|
shape: RoundedRectangleBorder(
|
||||||
overflow: TextOverflow.ellipsis,
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
side: const BorderSide(color: Color(0xFFE1E8F5)),
|
||||||
),
|
),
|
||||||
subtitle: Text(
|
child: ListTile(
|
||||||
'${item['nofoto'] ?? '-'} - ${item['warning_label'] ?? item['status'] ?? '-'}',
|
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
||||||
|
title: Text(
|
||||||
|
item['nmpasien']?.toString() ?? '-',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Color(0xFF080D3D),
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
'${item['nofoto'] ?? '-'} / ${item['warning_label'] ?? item['status'] ?? '-'}',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
trailing: const Icon(
|
||||||
|
Icons.chevron_right_rounded,
|
||||||
|
color: Color(0xFF667095),
|
||||||
|
),
|
||||||
|
onTap: () => _openDetail(context, item, token, baseUrl),
|
||||||
),
|
),
|
||||||
trailing: const Icon(Icons.chevron_right),
|
|
||||||
onTap: () => _openDetail(context, item, token, baseUrl),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -165,13 +204,17 @@ class ExaminationCard extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Card(
|
return Card(
|
||||||
margin: const EdgeInsets.only(bottom: 10),
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
elevation: 0,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
side: const BorderSide(color: Color(0xFFE1E8F5)),
|
||||||
|
),
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(14),
|
||||||
onTap: () => _openDetail(context, item, token, baseUrl),
|
onTap: () => _openDetail(context, item, token, baseUrl),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(14),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@@ -180,30 +223,49 @@ class ExaminationCard extends StatelessWidget {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
item['nmpasien']?.toString() ?? '-',
|
item['nmpasien']?.toString() ?? '-',
|
||||||
style: const TextStyle(fontWeight: FontWeight.w800),
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Color(0xFF080D3D),
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
StatusPill(status: item['status']?.toString() ?? 'NEW'),
|
StatusPill(status: item['status']?.toString() ?? 'NEW'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Wrap(
|
||||||
'${item['nofoto'] ?? '-'} / RM ${item['noregister'] ?? '-'}',
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
BadgeLabel(
|
||||||
|
text: item['nofoto']?.toString() ?? '-',
|
||||||
|
color: const Color(0xFF0B7BFF),
|
||||||
|
),
|
||||||
|
BadgeLabel(
|
||||||
|
text: 'RM ${item['noregister'] ?? '-'}',
|
||||||
|
color: const Color(0xFF667095),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
Text(
|
const SizedBox(height: 10),
|
||||||
item['reques']?.toString() ?? '-',
|
BadgeLabel(
|
||||||
maxLines: 2,
|
text: item['reques']?.toString() ?? '-',
|
||||||
overflow: TextOverflow.ellipsis,
|
color: const Color(0xFF0891B2),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.schedule, size: 16, color: Colors.black54),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
item['daftar']?.toString() ?? '-',
|
item['daftar']?.toString() ?? '-',
|
||||||
style: const TextStyle(color: Colors.black54),
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Color(0xFF47517A),
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
TextButton.icon(
|
TextButton.icon(
|
||||||
@@ -221,6 +283,62 @@ class ExaminationCard extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _ExaminationScreenHeader extends StatelessWidget {
|
||||||
|
const _ExaminationScreenHeader({
|
||||||
|
required this.title,
|
||||||
|
required this.subtitle,
|
||||||
|
required this.onRefresh,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String title;
|
||||||
|
final String subtitle;
|
||||||
|
final VoidCallback onRefresh;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
icon: const Icon(Icons.arrow_back_ios_new_rounded),
|
||||||
|
color: const Color(0xFF080D3D),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||||
|
color: const Color(0xFF080D3D),
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
subtitle,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
color: const Color(0xFF47517A),
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: onRefresh,
|
||||||
|
icon: const Icon(Icons.refresh_rounded),
|
||||||
|
color: const Color(0xFF063B60),
|
||||||
|
tooltip: 'Refresh',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _openDetail(
|
Future<void> _openDetail(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
Map<String, dynamic> item,
|
Map<String, dynamic> item,
|
||||||
|
|||||||
@@ -33,15 +33,60 @@ class StatusPill extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final lower = status.toLowerCase();
|
final normalized = status.trim();
|
||||||
final color = lower.contains('selesai')
|
final lower = normalized.toLowerCase();
|
||||||
? const Color(0xFF15803D)
|
if (normalized.isEmpty || lower == 'new') {
|
||||||
: lower.contains('dibatalkan')
|
return const BadgeLabel(text: 'NEW', color: Color(0xFFDC2626));
|
||||||
? const Color(0xFF6B7280)
|
}
|
||||||
: lower.contains('warning') || lower.contains('target')
|
if (lower.contains('dibatalkan')) {
|
||||||
? const Color(0xFFDC2626)
|
return const Text(
|
||||||
: const Color(0xFF0F766E);
|
'Batal',
|
||||||
return BadgeLabel(text: status, color: color);
|
style: TextStyle(color: Color(0xFF6B7280), fontWeight: FontWeight.w800),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (lower.contains('pemeriksaan sampel')) {
|
||||||
|
return const Text(
|
||||||
|
'Pemeriksaan Sampel',
|
||||||
|
style: TextStyle(color: Color(0xFF15803D), fontWeight: FontWeight.w800),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (lower.contains('proses analisis sampel')) {
|
||||||
|
return const Text(
|
||||||
|
'Diperiksa',
|
||||||
|
style: TextStyle(color: Color(0xFF111827), fontWeight: FontWeight.w900),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (lower.contains('draft')) {
|
||||||
|
return const Text(
|
||||||
|
'Draft',
|
||||||
|
style: TextStyle(color: Color(0xFF15803D), fontWeight: FontWeight.w800),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (lower.contains('expertise')) {
|
||||||
|
return const Text(
|
||||||
|
'Expertise',
|
||||||
|
style: TextStyle(color: Color(0xFF15803D), fontWeight: FontWeight.w900),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (lower.contains('decliend')) {
|
||||||
|
return const Text(
|
||||||
|
'Decliend',
|
||||||
|
style: TextStyle(color: Color(0xFFDC2626), fontWeight: FontWeight.w900),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (lower.contains('selesai')) {
|
||||||
|
return const BadgeLabel(text: 'Selesai', color: Color(0xFF15803D));
|
||||||
|
}
|
||||||
|
if (lower.contains('arsip')) {
|
||||||
|
return const BadgeLabel(text: 'Arsip', color: Color(0xFF2563EB));
|
||||||
|
}
|
||||||
|
if (lower.contains('data vitek di terima')) {
|
||||||
|
return const BadgeLabel(
|
||||||
|
text: 'Data Vitek di Terima',
|
||||||
|
color: Color(0xFFF59E0B),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return BadgeLabel(text: normalized, color: const Color(0xFF0891B2));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,12 @@
|
|||||||
import FlutterMacOS
|
import FlutterMacOS
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
import mobile_scanner
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
import url_launcher_macos
|
import url_launcher_macos
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
|
MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||||
}
|
}
|
||||||
@@ -10,5 +10,7 @@
|
|||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.network.client</key>
|
<key>com.apple.security.network.client</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>com.apple.security.device.camera</key>
|
||||||
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
@@ -24,6 +24,8 @@
|
|||||||
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
|
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
|
||||||
<key>NSHumanReadableCopyright</key>
|
<key>NSHumanReadableCopyright</key>
|
||||||
<string>$(PRODUCT_COPYRIGHT)</string>
|
<string>$(PRODUCT_COPYRIGHT)</string>
|
||||||
|
<key>NSCameraUsageDescription</key>
|
||||||
|
<string>MyLIS memakai kamera untuk scan barcode sampel.</string>
|
||||||
<key>NSMainNibFile</key>
|
<key>NSMainNibFile</key>
|
||||||
<string>MainMenu</string>
|
<string>MainMenu</string>
|
||||||
<key>NSPrincipalClass</key>
|
<key>NSPrincipalClass</key>
|
||||||
|
|||||||
@@ -6,5 +6,7 @@
|
|||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.network.client</key>
|
<key>com.apple.security.network.client</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>com.apple.security.device.camera</key>
|
||||||
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
@@ -168,6 +168,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.18.0"
|
version: "1.18.0"
|
||||||
|
mobile_scanner:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: mobile_scanner
|
||||||
|
sha256: ce3f059ebd6dbfab7292bba0e893e354b46730636820d3c9ef69005ce2d55bce
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "7.4.0"
|
||||||
path:
|
path:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
+2
-1
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
|||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
# In Windows, build-name is used as the major, minor, and patch parts
|
# In Windows, build-name is used as the major, minor, and patch parts
|
||||||
# of the product and file versions while build-number is used as the build suffix.
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 1.0.0+1
|
version: 1.0.2+2
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.12.2
|
sdk: ^3.12.2
|
||||||
@@ -37,6 +37,7 @@ dependencies:
|
|||||||
http: ^1.2.2
|
http: ^1.2.2
|
||||||
shared_preferences: ^2.3.2
|
shared_preferences: ^2.3.2
|
||||||
url_launcher: ^6.3.1
|
url_launcher: ^6.3.1
|
||||||
|
mobile_scanner: ^7.4.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ void main() {
|
|||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
find.text('Mikrobiology Laboratory Information System (MyLIS)'),
|
find.text('Mikrobiology Laboratory Information System'),
|
||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
);
|
);
|
||||||
expect(find.text('Login'), findsOneWidget);
|
expect(find.text('Login'), findsOneWidget);
|
||||||
|
|||||||
Reference in New Issue
Block a user