update
This commit is contained in:
@@ -7,7 +7,9 @@ use App\CriticalValueSample;
|
||||
use App\Organisms;
|
||||
use App\Periksa;
|
||||
use App\PeriksaSYNC;
|
||||
use App\Paslab;
|
||||
use App\Riwayat;
|
||||
use App\SIMBHPReport;
|
||||
use App\Subjawaban;
|
||||
use App\User;
|
||||
use Carbon\Carbon;
|
||||
@@ -15,6 +17,7 @@ use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\Session;
|
||||
|
||||
class MobileApiController extends Controller
|
||||
@@ -154,6 +157,7 @@ class MobileApiController extends Controller
|
||||
'klinisi' => $row->klinisi,
|
||||
'klinis' => $row->klinis,
|
||||
'nmdokter' => $row->nmdokter,
|
||||
'kd_spesimen' => $row->kd_spesimen,
|
||||
'nm_spesimen' => $row->nm_spesimen,
|
||||
'kesimpulan' => $row->kesimpulan,
|
||||
'dlp' => $row->dlp,
|
||||
@@ -230,19 +234,187 @@ class MobileApiController extends Controller
|
||||
'butuh_verifikasi' => Periksa::where('status', 'like', '%Un Verified%')->count(),
|
||||
'antrian_hari_ini' => Periksa::whereDate('daftar', Carbon::today())->count(),
|
||||
'antrian_sinkron' => PeriksaSYNC::where('created_by', $user->username)->where('status', '')->count(),
|
||||
'criticalNotificationCount' => CriticalValueSample::whereNull('followed_up_at')->count(),
|
||||
],
|
||||
'books' => $books,
|
||||
'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)
|
||||
{
|
||||
$this->requireUser($request);
|
||||
$query = $this->activeExaminationQuery();
|
||||
$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);
|
||||
} elseif (isset($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([
|
||||
'master' => $master,
|
||||
'label' => $this->bookLabels[$master],
|
||||
'label' => $master === 'ALL' ? 'Semua Spesimen' : $this->bookLabels[$master],
|
||||
'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)
|
||||
{
|
||||
$this->requireUser($request);
|
||||
|
||||
@@ -6605,8 +6605,59 @@
|
||||
});
|
||||
});
|
||||
$("#btnsendexpertisetopacs").click(function(){
|
||||
$('#id_iframepreviewexpertise').attr('src', '{{ url("/") }}/hasil/' + document.getElementById('nofoto').value);
|
||||
$('#modalpreviewsendexpertise').modal('show');
|
||||
var keterangan = CKEDITOR.instances['keterangan'].getData();
|
||||
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(){
|
||||
var keterangan = CKEDITOR.instances['keterangan'].getData();
|
||||
|
||||
@@ -6663,8 +6663,59 @@
|
||||
});
|
||||
});
|
||||
$("#btnsendexpertisetopacs").click(function(){
|
||||
$('#id_iframepreviewexpertise').attr('src', '{{ url("/") }}/hasil/' + document.getElementById('nofoto').value);
|
||||
$('#modalpreviewsendexpertise').modal('show');
|
||||
var keterangan = CKEDITOR.instances['keterangan'].getData();
|
||||
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(){
|
||||
var keterangan = CKEDITOR.instances['keterangan'].getData();
|
||||
|
||||
@@ -6812,8 +6812,59 @@
|
||||
}
|
||||
});
|
||||
$("#btnsendexpertisetopacs").click(function(){
|
||||
$('#id_iframepreviewexpertise').attr('src', '{{ url("/") }}/hasil/' + document.getElementById('nofoto').value);
|
||||
$('#modalpreviewsendexpertise').modal('show');
|
||||
var keterangan = CKEDITOR.instances['keterangan'].getData();
|
||||
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(){
|
||||
var keterangan = CKEDITOR.instances['keterangan'].getData();
|
||||
|
||||
@@ -23,7 +23,13 @@ Route::prefix('mobile')->group(function () {
|
||||
Route::get('ping', [MobileApiController::class, 'ping']);
|
||||
Route::post('login', [MobileApiController::class, 'login']);
|
||||
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('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('examinations/{id}', [MobileApiController::class, 'examination']);
|
||||
Route::get('examinations/{id}/expertise', [MobileApiController::class, 'expertise']);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<application
|
||||
android:label="MyLIS"
|
||||
android:name="${applicationName}"
|
||||
|
||||
@@ -26,6 +26,8 @@
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>MyLIS memakai kamera untuk scan barcode sampel.</string>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/io_client.dart';
|
||||
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
@@ -13,6 +14,12 @@ part '../services/api_client.dart';
|
||||
part '../services/session_store.dart';
|
||||
part '../screens/login/login_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_detail_screen.dart';
|
||||
part '../screens/expertise/expertise_screen.dart';
|
||||
@@ -29,8 +36,7 @@ part '../data/expertise_data.dart';
|
||||
part '../widgets/common_widgets.dart';
|
||||
|
||||
const String kAppShortName = 'MyLIS';
|
||||
const String kAppLongName =
|
||||
'Mikrobiology Laboratory Information System';
|
||||
const String kAppLongName = 'Mikrobiology Laboratory Information System';
|
||||
const String kHospitalName = 'Rumah Sakit Umum Daerah Dr. Saiful Anwar';
|
||||
const String kAppLogoAsset = 'assets/branding/logo.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
|
||||
Widget build(BuildContext context) {
|
||||
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>>(
|
||||
future: _future,
|
||||
builder: (context, snapshot) {
|
||||
@@ -90,80 +80,27 @@ class _ExaminationDetailScreenState extends State<ExaminationDetailScreen> {
|
||||
final data = snapshot.data!;
|
||||
final item = asMap(data['item']);
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.fromLTRB(18, 18, 18, 28),
|
||||
children: [
|
||||
Card(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
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']),
|
||||
],
|
||||
),
|
||||
SafeArea(
|
||||
bottom: false,
|
||||
child: _ExaminationScreenHeader(
|
||||
title: 'Detail Pemeriksaan',
|
||||
subtitle: item['nofoto']?.toString() ?? '-',
|
||||
onRefresh: _reload,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.icon(
|
||||
onPressed: _openExpertise,
|
||||
icon: const Icon(Icons.edit_document),
|
||||
label: const Text('Expertise'),
|
||||
const SizedBox(height: 22),
|
||||
_ExaminationPatientCard(item: item),
|
||||
const SizedBox(height: 14),
|
||||
_ExaminationInfoCard(item: item),
|
||||
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.master,
|
||||
required this.title,
|
||||
this.initialSearch,
|
||||
});
|
||||
|
||||
final String token;
|
||||
final String baseUrl;
|
||||
final String master;
|
||||
final String title;
|
||||
final String? initialSearch;
|
||||
|
||||
@override
|
||||
State<ExaminationListScreen> createState() => _ExaminationListScreenState();
|
||||
@@ -27,6 +29,7 @@ class _ExaminationListScreenState extends State<ExaminationListScreen> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_api = ApiClient(baseUrl: widget.baseUrl, token: widget.token);
|
||||
_search.text = widget.initialSearch ?? '';
|
||||
_future = _load();
|
||||
}
|
||||
|
||||
@@ -53,32 +56,50 @@ class _ExaminationListScreenState extends State<ExaminationListScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(widget.title),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: _reload,
|
||||
icon: const Icon(Icons.refresh),
|
||||
tooltip: 'Refresh',
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 10),
|
||||
child: TextField(
|
||||
controller: _search,
|
||||
textInputAction: TextInputAction.search,
|
||||
onSubmitted: (_) => _reload(),
|
||||
decoration: InputDecoration(
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
labelText: 'Cari no lab, RM, pasien, order',
|
||||
border: const OutlineInputBorder(),
|
||||
suffixIcon: IconButton(
|
||||
onPressed: _reload,
|
||||
icon: const Icon(Icons.arrow_forward),
|
||||
tooltip: 'Cari',
|
||||
padding: const EdgeInsets.fromLTRB(18, 18, 18, 0),
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: _ExaminationScreenHeader(
|
||||
title: widget.title,
|
||||
subtitle: 'List Pemeriksaan',
|
||||
onRefresh: _reload,
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(18, 22, 18, 14),
|
||||
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(
|
||||
onRefresh: () async => _reload(),
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
|
||||
padding: const EdgeInsets.fromLTRB(18, 0, 18, 24),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) => ExaminationCard(
|
||||
item: asMap(items[index]),
|
||||
@@ -135,17 +156,35 @@ class ExaminationCompactTile extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
title: Text(
|
||||
item['nmpasien']?.toString() ?? '-',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: const BorderSide(color: Color(0xFFE1E8F5)),
|
||||
),
|
||||
subtitle: Text(
|
||||
'${item['nofoto'] ?? '-'} - ${item['warning_label'] ?? item['status'] ?? '-'}',
|
||||
child: ListTile(
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
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(8),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
onTap: () => _openDetail(context, item, token, baseUrl),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -180,30 +223,49 @@ class ExaminationCard extends StatelessWidget {
|
||||
Expanded(
|
||||
child: Text(
|
||||
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'),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'${item['nofoto'] ?? '-'} / RM ${item['noregister'] ?? '-'}',
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
BadgeLabel(
|
||||
text: item['nofoto']?.toString() ?? '-',
|
||||
color: const Color(0xFF0B7BFF),
|
||||
),
|
||||
BadgeLabel(
|
||||
text: 'RM ${item['noregister'] ?? '-'}',
|
||||
color: const Color(0xFF667095),
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
item['reques']?.toString() ?? '-',
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
const SizedBox(height: 10),
|
||||
BadgeLabel(
|
||||
text: item['reques']?.toString() ?? '-',
|
||||
color: const Color(0xFF0891B2),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.schedule, size: 16, color: Colors.black54),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
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(
|
||||
@@ -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(
|
||||
BuildContext context,
|
||||
Map<String, dynamic> item,
|
||||
|
||||
@@ -33,15 +33,60 @@ class StatusPill extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final lower = status.toLowerCase();
|
||||
final color = lower.contains('selesai')
|
||||
? const Color(0xFF15803D)
|
||||
: lower.contains('dibatalkan')
|
||||
? const Color(0xFF6B7280)
|
||||
: lower.contains('warning') || lower.contains('target')
|
||||
? const Color(0xFFDC2626)
|
||||
: const Color(0xFF0F766E);
|
||||
return BadgeLabel(text: status, color: color);
|
||||
final normalized = status.trim();
|
||||
final lower = normalized.toLowerCase();
|
||||
if (normalized.isEmpty || lower == 'new') {
|
||||
return const BadgeLabel(text: 'NEW', color: Color(0xFFDC2626));
|
||||
}
|
||||
if (lower.contains('dibatalkan')) {
|
||||
return const Text(
|
||||
'Batal',
|
||||
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 Foundation
|
||||
|
||||
import mobile_scanner
|
||||
import shared_preferences_foundation
|
||||
import url_launcher_macos
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||
}
|
||||
@@ -10,5 +10,7 @@
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.camera</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -24,6 +24,8 @@
|
||||
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>$(PRODUCT_COPYRIGHT)</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>MyLIS memakai kamera untuk scan barcode sampel.</string>
|
||||
<key>NSMainNibFile</key>
|
||||
<string>MainMenu</string>
|
||||
<key>NSPrincipalClass</key>
|
||||
|
||||
@@ -6,5 +6,7 @@
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.camera</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -168,6 +168,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
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
|
||||
# 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.
|
||||
version: 1.0.0+1
|
||||
version: 1.0.2+2
|
||||
|
||||
environment:
|
||||
sdk: ^3.12.2
|
||||
@@ -37,6 +37,7 @@ dependencies:
|
||||
http: ^1.2.2
|
||||
shared_preferences: ^2.3.2
|
||||
url_launcher: ^6.3.1
|
||||
mobile_scanner: ^7.4.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
@@ -18,7 +18,7 @@ void main() {
|
||||
await tester.pump();
|
||||
|
||||
expect(
|
||||
find.text('Mikrobiology Laboratory Information System (MyLIS)'),
|
||||
find.text('Mikrobiology Laboratory Information System'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('Login'), findsOneWidget);
|
||||
|
||||
Reference in New Issue
Block a user